Backend Services — Registry, Licensing & Build
Three separate Go services sharing one PostgreSQL cluster. Each runs independently, deploys independently, and can scale independently. Total infrastructure cost: ~$52/mo.
Architecture Overview
┌──────────────────────────────────────────┐
│ CLOUDFLARE DNS │
└───┬──────────────┬──────────────┬────────┘
│ │ │
registry.getarcan.dev license.getarcan.dev (internal only)
│ │ │
┌────▼────┐ ┌────▼────┐ ┌───▼─────┐
│ CloudFront│ │ ALB │ │ (no │
│ (downloads)│ │ │ │ public │
│ + ALB │ │ │ │ endpoint)│
│ (API) │ │ │ │ │
└────┬──────┘ └────┬────┘ └────┬────┘
│ │ │
┌────▼────┐ ┌────▼────┐ ┌───▼─────┐
│Registry │ │License │ │ Build │
│ Service │ │ Service │ │ Service │
│(ECS 256M)│ │(ECS 256M)│ │(ECS 512M)│
└────┬──────┘ └────┬────┘ └────┬────┘
│ │ │
└──────────┬───┘──────────────┘
│
┌──────▼──────┐
│ PostgreSQL │
│ (RDS t4g.micro)│
│ shared DB │
└──────┬──────┘
│
┌──────▼──────┐
│ S3 │
│ (packages + │
│ build logs) │
└─────────────┘
Infrastructure Cost
| Component | Spec | Monthly Cost |
|---|---|---|
| Registry Service | ECS Fargate, 256MB, 0.25 vCPU | ~$10 |
| License Service | ECS Fargate, 256MB, 0.25 vCPU | ~$10 |
| Build Service | ECS Fargate, 512MB, 0.5 vCPU | ~$12 |
| PostgreSQL | RDS db.t4g.micro, 20GB | ~$15 |
| S3 | Plugin storage, build logs | ~$2 |
| CloudFront | Package delivery CDN | ~$3 |
| Total | ~$52/mo |
Shared Conventions
All three services follow the same standards as Arcan core:
- Go, chi router, slog structured logging
- Same error taxonomy (see Error Taxonomy)
- Same auth patterns (API keys for service-to-service, GitHub OAuth for publishers)
- TLS everywhere
- PostgreSQL with embedded migrations
Service 1: Plugin Registry
Domain: registry.getarcan.dev
Purpose
- Host plugin packages (
.arcanpkgfiles) - Serve plugin metadata (search, discovery, version history)
- Track download statistics (aggregate, not per-user)
- Serve as the plugin catalog for
arcan plugin installandarcan plugin search
Database Schema
-- Publishers (GitHub-authenticated accounts)
CREATE TABLE publishers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
github_id BIGINT NOT NULL UNIQUE,
github_username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
email TEXT,
avatar_url TEXT,
is_official BOOLEAN NOT NULL DEFAULT false, -- GetArcan team members
is_verified BOOLEAN NOT NULL DEFAULT false, -- verified publisher badge
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Plugins (one row per plugin name)
CREATE TABLE plugins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
publisher_id UUID NOT NULL REFERENCES publishers(id),
name TEXT NOT NULL, -- "postgres", "aws", "ssh-proxy"
display_name TEXT NOT NULL, -- "PostgreSQL"
description TEXT NOT NULL DEFAULT '',
homepage_url TEXT, -- link to docs/repo
repository_url TEXT, -- source code repo
tier TEXT NOT NULL DEFAULT 'community'
CHECK (tier IN ('official', 'enterprise', 'community')),
is_public BOOLEAN NOT NULL DEFAULT true,
total_downloads BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (publisher_id, name)
);
-- For official plugins: no namespace needed (arcan plugin install postgres)
-- For community plugins: namespace required (arcan plugin install acme/postgres)
CREATE UNIQUE INDEX uq_plugins_official_name
ON plugins(name) WHERE tier = 'official';
-- Plugin versions (one row per version per plugin)
CREATE TABLE plugin_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plugin_id UUID NOT NULL REFERENCES plugins(id),
version TEXT NOT NULL, -- "0.1.0" (semver)
sdk_version INTEGER NOT NULL DEFAULT 1,
min_core_version TEXT NOT NULL DEFAULT '0.1.0',
capabilities JSONB NOT NULL DEFAULT '[]',
config_schema JSONB, -- JSON Schema for setup wizard
default_roles JSONB, -- starter role templates
readme TEXT, -- version-specific readme
published_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_by UUID NOT NULL REFERENCES publishers(id),
is_yanked BOOLEAN NOT NULL DEFAULT false,
yank_reason TEXT,
yanked_at TIMESTAMPTZ,
UNIQUE (plugin_id, version)
);
-- Platform-specific artifacts (one per OS/arch per version)
CREATE TABLE plugin_artifacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
version_id UUID NOT NULL REFERENCES plugin_versions(id),
platform TEXT NOT NULL, -- "linux/amd64", "darwin/arm64", "wasm"
s3_key TEXT NOT NULL, -- S3 object key for the .arcanpkg
checksum_sha256 TEXT NOT NULL, -- "sha256:abc123..."
signature TEXT NOT NULL, -- "ed25519:<base64>"
size_bytes BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (version_id, platform)
);
-- Download tracking (daily aggregates, not per-user)
CREATE TABLE download_stats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plugin_id UUID NOT NULL REFERENCES plugins(id),
version_id UUID NOT NULL REFERENCES plugin_versions(id),
platform TEXT NOT NULL,
download_date DATE NOT NULL,
download_count INTEGER NOT NULL DEFAULT 0,
UNIQUE (version_id, platform, download_date)
);
CREATE INDEX idx_download_stats_plugin_date ON download_stats(plugin_id, download_date);
-- API keys for publisher automation (CI/CD publish)
CREATE TABLE publisher_api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
publisher_id UUID NOT NULL REFERENCES publishers(id),
name TEXT NOT NULL,
key_hash TEXT NOT NULL, -- SHA-256 of the API key
key_prefix TEXT NOT NULL, -- first 8 chars for identification
capabilities TEXT[] NOT NULL DEFAULT '{"publish"}',
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (publisher_id, name)
);
API Endpoints
Public (no auth)
GET /v1/plugins
Query: ?q=postgres&tier=official&capability=dynamic_credentials&page=1&limit=25
Returns: paginated plugin list with latest version metadata
GET /v1/plugins/{publisher}/{name}
Returns: full plugin metadata + all versions
Note: official plugins accessible as /v1/plugins/postgres (no publisher prefix)
GET /v1/plugins/{publisher}/{name}/{version}
Returns: version-specific metadata, checksums, capabilities
GET /v1/plugins/{publisher}/{name}/{version}/download/{platform}
Returns: 302 redirect to CloudFront CDN URL (signed, time-limited)
Note: enterprise plugins require Authorization header (see License Service)
GET /v1/plugins/{publisher}/{name}/{version}/readme
Returns: version-specific README content
Publisher (GitHub OAuth or API key)
POST /v1/plugins
Body: { "name": "my-engine", "display_name": "My Engine", "description": "..." }
Creates: new plugin under authenticated publisher
POST /v1/plugins/{name}/versions
Body: multipart — manifest.json + .arcanpkg file(s) per platform
Note: for source uploads, triggers Build Service instead of direct publish
Creates: new version, uploads artifacts to S3, signs
POST /v1/plugins/{name}/versions/{version}/yank
Body: { "reason": "Security vulnerability CVE-2026-xxxx" }
Marks: version as yanked, returns 410 on future download attempts
DELETE /v1/plugins/{name}/versions/{version}
Note: NOT allowed. Versions are immutable. Use yank instead.
Auth (GitHub OAuth)
GET /v1/auth/github
Redirects: to GitHub OAuth consent screen
GET /v1/auth/github/callback
Exchanges: code for token, creates/updates publisher record
Returns: session JWT
POST /v1/auth/api-keys
Body: { "name": "ci-publish", "expires_in": "365d" }
Returns: { "key": "reg_xxxxxxxxxxxx", "prefix": "reg_xxxx" }
Note: key shown ONCE, stored as SHA-256 hash
Download Flow
1. arcan plugin install postgres
2. CLI calls: GET /v1/plugins/postgres/latest
3. Registry returns: version metadata with checksums
4. CLI calls: GET /v1/plugins/postgres/0.1.0/download/darwin/arm64
5. Registry increments download counter (async)
6. Registry returns: 302 → CloudFront signed URL (expires in 5 min)
7. CLI downloads .arcanpkg from CloudFront CDN
8. CLI verifies: SHA-256 checksum matches metadata
9. CLI verifies: Ed25519 signature against embedded public key
10. CLI extracts to ~/.arcan/plugins/postgres/
Search
Full-text search using PostgreSQL tsvector:
ALTER TABLE plugins ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', name), 'A') ||
setweight(to_tsvector('english', display_name), 'A') ||
setweight(to_tsvector('english', coalesce(description, '')), 'B')
) STORED;
CREATE INDEX idx_plugins_search ON plugins USING gin(search_vector);
No external search engine needed until we have 1000+ plugins.
Service 2: License Service
Domain: license.getarcan.dev
Purpose
- Generate and manage enterprise license keys
- Handle activation (bind key to org fingerprint)
- Process heartbeats (periodic renewal)
- Manage feature entitlements
- Track seat usage
- Integrate with Stripe for billing
License Model
License Key (arc_ent_xxx)
│
├── Tier: enterprise
├── Units: 5 (activations allowed)
├── Features: [ssh-proxy, kmip, break-glass, mcp-server]
├── Expires: 2027-04-01
│
└── Activations:
├── Activation 1: org_fingerprint_aaa (node-1.prod)
├── Activation 2: org_fingerprint_bbb (node-2.prod)
└── Activation 3: org_fingerprint_ccc (staging)
2 units remaining
Unit = one Arcan server instance. Each arcan activate consumes one unit.
Deactivation releases the unit (arcan deactivate).
Database Schema
-- License keys (issued to customers)
CREATE TABLE licenses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key_hash TEXT NOT NULL UNIQUE, -- SHA-256 of arc_ent_xxx
key_prefix TEXT NOT NULL, -- "arc_ent_xxxx" (first 12 chars for display)
tier TEXT NOT NULL DEFAULT 'enterprise'
CHECK (tier IN ('enterprise', 'trial')),
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'suspended', 'expired', 'revoked')),
-- Entitlements
features TEXT[] NOT NULL DEFAULT '{}', -- ["ssh-proxy", "kmip", ...]
max_units INTEGER NOT NULL DEFAULT 1, -- max concurrent activations
used_units INTEGER NOT NULL DEFAULT 0,
-- Billing
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
-- Customer info
org_name TEXT NOT NULL,
contact_email TEXT NOT NULL,
contact_name TEXT,
-- Dates
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
trial_ends_at TIMESTAMPTZ, -- only for trial tier
suspended_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Activations (one per arcan server instance)
CREATE TABLE activations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id),
org_fingerprint TEXT NOT NULL, -- SHA-256(machine_id + org_name)
instance_name TEXT, -- human-readable label set by user
arcan_version TEXT, -- version reported during activation
-- Token management
token_hash TEXT NOT NULL, -- SHA-256 of activation JWT
token_expires_at TIMESTAMPTZ NOT NULL,
-- Health
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'grace', 'expired', 'deactivated')),
last_heartbeat TIMESTAMPTZ NOT NULL DEFAULT now(),
grace_deadline TIMESTAMPTZ, -- set when heartbeat missed
heartbeat_count BIGINT NOT NULL DEFAULT 0,
-- Dates
activated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deactivated_at TIMESTAMPTZ,
UNIQUE (license_id, org_fingerprint)
);
CREATE INDEX idx_activations_license ON activations(license_id);
CREATE INDEX idx_activations_heartbeat ON activations(status, last_heartbeat);
-- Activation events (audit trail)
CREATE TABLE activation_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
activation_id UUID NOT NULL REFERENCES activations(id),
event_type TEXT NOT NULL
CHECK (event_type IN (
'activated', 'heartbeat', 'renewed', 'grace_entered',
'grace_expired', 'deactivated', 'token_refreshed'
)),
metadata JSONB DEFAULT '{}',
ip_address INET,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_activation_events_activation ON activation_events(activation_id, created_at);
-- License events (admin audit trail)
CREATE TABLE license_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id),
event_type TEXT NOT NULL
CHECK (event_type IN (
'created', 'activated', 'renewed', 'upgraded',
'suspended', 'revoked', 'expired', 'units_changed',
'features_changed', 'billing_updated'
)),
actor TEXT NOT NULL, -- "admin:mohan", "system:billing", "customer:email"
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_license_events_license ON license_events(license_id, created_at);
API Endpoints
Activation (called by Arcan CLI)
POST /v1/activate
Body: {
"license_key": "arc_ent_xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"org_fingerprint": "sha256:...",
"instance_name": "prod-node-1",
"arcan_version": "0.1.0"
}
Returns: {
"activation_id": "uuid",
"token": "eyJ...", // JWT, 7-day expiry
"features": ["ssh-proxy", "kmip"],
"license_expires_at": "2027-04-01T00:00:00Z",
"token_expires_at": "2026-04-08T00:00:00Z",
"units_remaining": 2,
"grace_period_days": 30
}
Errors:
400 — invalid key format
401 — key not found or expired
403 — all units consumed (max_units reached)
409 — fingerprint already activated (returns existing activation)
POST /v1/heartbeat
Headers: Authorization: Bearer <activation_token>
Body: {
"arcan_version": "0.1.0",
"engines_loaded": ["postgres", "aws"],
"uptime": "72h"
}
Returns: {
"token": "eyJ...", // refreshed JWT
"token_expires_at": "2026-04-15T00:00:00Z",
"features": ["ssh-proxy", "kmip"],
"license_status": "active"
}
Notes:
- Called every 7 days by Arcan core (background goroutine)
- Returns fresh token on each heartbeat
- If license suspended/revoked, returns 403 with reason
POST /v1/deactivate
Headers: Authorization: Bearer <activation_token>
Returns: { "deactivated": true, "units_released": 1 }
Notes: releases the unit, allows reuse on another instance
GET /v1/license/status
Headers: Authorization: Bearer <activation_token>
Returns: license status, features, units used/remaining, expiry dates
Enterprise Download (called by Registry on behalf of CLI)
POST /v1/verify-download
Headers: X-Internal-Key: <service-to-service key>
Body: {
"activation_token": "eyJ...",
"plugin_name": "ssh-proxy",
"plugin_tier": "enterprise"
}
Returns: { "allowed": true, "reason": "" }
or: { "allowed": false, "reason": "feature ssh-proxy not in license" }
Notes: Registry calls this before serving enterprise plugin downloads.
The activation token proves the user has a valid enterprise license,
and this endpoint checks feature entitlements.
Admin (internal, API key auth)
POST /v1/admin/licenses
Body: {
"org_name": "Acme Corp",
"contact_email": "[email protected]",
"tier": "enterprise",
"features": ["ssh-proxy", "kmip", "break-glass"],
"max_units": 5,
"duration_days": 365
}
Returns: {
"license_id": "uuid",
"license_key": "arc_ent_xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"expires_at": "2027-04-01T00:00:00Z"
}
Note: key shown ONCE, stored as SHA-256 hash
GET /v1/admin/licenses
Query: ?status=active&tier=enterprise&page=1
Returns: paginated license list
GET /v1/admin/licenses/{id}
Returns: full license detail with activations and events
PATCH /v1/admin/licenses/{id}
Body: { "max_units": 10 } or { "features": [...] } or { "status": "suspended" }
Updates: license attributes, logs event
GET /v1/admin/licenses/{id}/activations
Returns: all activations for this license
GET /v1/admin/licenses/{id}/events
Returns: audit trail for this license
POST /v1/admin/licenses/{id}/revoke
Body: { "reason": "non-payment" }
Revokes: license, all activations enter grace period immediately
GET /v1/admin/dashboard
Returns: {
"total_licenses": 42,
"active_licenses": 38,
"total_activations": 156,
"active_activations": 142,
"revenue_mrr": 24500, // from Stripe
"trial_conversions_30d": 12,
"expiring_7d": 3
}
Activation Flow (detailed)
┌─────────────┐ ┌──────────────┐ ┌──────────┐
│ Arcan CLI │ │License Service│ │PostgreSQL│
└──────┬──────┘ └──────┬───────┘ └────┬─────┘
│ │ │
│ POST /v1/activate │ │
│ { key, fingerprint } │ │
│─────────────────────────────────→│ │
│ │ │
│ │ Lookup license by key_hash │
│ │────────────────────────────────→│
│ │ │
│ │ Check: status=active? │
│ │ Check: not expired? │
│ │ Check: used_units < max_units? │
│ │ │
│ │ Check existing activation │
│ │ for this fingerprint │
│ │────────────────────────────────→│
│ │ │
│ │ If exists: return existing │
│ │ (idempotent activation) │
│ │ │
│ │ If new: create activation │
│ │ Increment used_units │
│ │ Generate JWT (7-day) │
│ │ Log activation event │
│ │────────────────────────────────→│
│ │ │
│ { token, features, expiry } │ │
│←─────────────────────────────────│ │
│ │ │
│ Store token in │ │
│ ~/.arcan/license.json │ │
│ │ │
Heartbeat & Grace Period
Normal operation:
Arcan server sends heartbeat every 7 days
License Service returns fresh token (7-day expiry)
Heartbeat missed:
Day 0-7: Token still valid (hasn't expired yet)
Day 7: Token expires. Arcan tries heartbeat.
If fails → enters grace period (30 days).
Day 7-37: Grace period. Enterprise plugins still work.
Arcan logs warning every startup:
"Enterprise license: offline for N days. Reconnect within M days."
Day 37: Grace period expires. Enterprise plugins refuse to load.
"Enterprise license expired. Reconnect: arcan activate --refresh"
Core + OSS plugins continue working normally.
License suspended by admin:
Next heartbeat returns 403.
Arcan enters grace period immediately.
Same 30-day countdown.
License revoked:
Next heartbeat returns 403 with "revoked" status.
Grace period = 0. Enterprise plugins stop immediately.
Core + OSS plugins continue working.
Trial Flow
1. User signs up on getarcan.dev
2. System generates trial license:
- tier: "trial"
- features: ALL enterprise features
- max_units: 1
- duration: 14 days
- no Stripe subscription (no credit card)
3. User runs: arcan activate arc_trial_xxxxxxxxxxxx
4. Full enterprise experience for 14 days
5. Day 12: Arcan shows reminder:
"Enterprise trial expires in 2 days. Upgrade: https://getarcan.dev/pricing"
6. Day 14: Trial expires.
Enterprise plugins stop loading.
"Trial expired. Upgrade to continue using enterprise features."
7. Upgrade: User enters payment on getarcan.dev
System creates enterprise license, links to Stripe subscription
User runs: arcan activate arc_ent_xxxxxxxxxxxx (new key)
Stripe Integration
License created → Stripe Customer created
→ Stripe Subscription created (monthly/annual)
→ Webhook endpoint: /v1/webhooks/stripe
Stripe events we handle:
invoice.paid → extend license expiry
invoice.payment_failed → log warning, retry (Stripe handles)
customer.subscription.deleted → suspend license
customer.subscription.updated → update features/units if plan changed
License key is linked to Stripe via:
licenses.stripe_customer_id
licenses.stripe_subscription_id
Service 3: Build Service
No public endpoint — triggered internally by Registry Service.
Purpose
- Compile plugin source code into
.arcanpkgpackages - Support multiple languages (Go initially, TypeScript and Python later)
- Run security scans before signing
- Sign packages with the registry Ed25519 key
- Upload artifacts to S3
- Provide build logs to publishers
Database Schema
-- Build jobs
CREATE TABLE builds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plugin_id UUID NOT NULL, -- from registry DB
version TEXT NOT NULL,
publisher_id UUID NOT NULL,
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'building', 'signing', 'publishing',
'completed', 'failed', 'cancelled')),
-- Source
source_type TEXT NOT NULL -- "upload" | "git"
CHECK (source_type IN ('upload', 'git')),
source_ref TEXT, -- git commit SHA or S3 key for upload
source_repo TEXT, -- git repo URL (if git type)
-- Build config
sdk_language TEXT NOT NULL DEFAULT 'go'
CHECK (sdk_language IN ('go', 'typescript', 'python')),
target_platforms TEXT[] NOT NULL DEFAULT '{"linux/amd64", "linux/arm64", "darwin/amd64", "darwin/arm64"}',
-- Results
build_log_s3 TEXT, -- S3 key for build log
error_message TEXT,
artifacts JSONB DEFAULT '[]', -- [{platform, s3_key, checksum, size}]
-- Security scan
scan_status TEXT DEFAULT 'pending'
CHECK (scan_status IN ('pending', 'passed', 'failed', 'skipped')),
scan_report JSONB,
-- Timing
queued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_builds_plugin ON builds(plugin_id, created_at DESC);
CREATE INDEX idx_builds_status ON builds(status) WHERE status IN ('queued', 'building');
Build Flow
1. Publisher uploads source:
POST /v1/plugins/my-engine/versions (to Registry)
Registry creates build job → inserts into builds table
2. Build Service picks up queued job (polling or SQS):
- Downloads source from S3
- Validates manifest.json
- Runs security scan (dependency audit, static analysis)
- If scan fails → build fails with report
3. Compilation (per target platform):
Go: GOOS=linux GOARCH=amd64 go build → binary
TypeScript: tsc + esbuild → bundled JS (for WASM phase: wasm-pack)
Python: package validation (for WASM phase: Pyodide compile)
4. Package:
- Create .arcanpkg (tar.gz: manifest.json + compiled artifact)
- Calculate SHA-256 checksum
5. Sign:
- Retrieve Ed25519 signing key from AWS KMS
- Sign the checksum → Ed25519 signature
- Include signature in package
6. Publish:
- Upload .arcanpkg to S3 (per platform)
- Create plugin_artifacts records in Registry DB
- Update build status → completed
7. Notify publisher:
- Webhook or email: "Your plugin v0.1.0 is published"
Security Scanning
Before any package is signed, the build service runs:
| Scan | Tool | What it checks |
|---|---|---|
| Dependency audit | govulncheck (Go), npm audit (TS), pip-audit (Py) | Known CVEs in dependencies |
| Static analysis | go vet + staticcheck (Go) | Code quality, common bugs |
| Secret detection | gitleaks | Hardcoded secrets, API keys in source |
| License compliance | go-licenses | Dependency license compatibility (Apache 2.0) |
If any critical scan fails, the build fails and the publisher sees the report. They fix the issue and re-submit.
Build Isolation
Each build runs in an isolated container (ECS task with a fresh filesystem):
- No network access during compilation (dependencies pre-fetched)
- Read-only source mount
- Time-limited (5 minute max)
- Memory-limited (512MB max)
- No access to signing keys (signing happens after build, in the service process)
Service Communication
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Registry │────→│ Build │ │ License │
│ │ │ Service │ │ Service │
│ │←────│ │ │ │
│ │ └──────────┘ │ │
│ │ │ │
│ │──verify-download────→│ │
│ │←─────allowed/denied──│ │
└──────────┘ └──────────┘
| From | To | Why | Auth |
|---|---|---|---|
| Registry → Build | Trigger build for new version | Service-to-service API key | |
| Build → Registry | Report build results, create artifacts | Service-to-service API key | |
| Registry → License | Verify enterprise download entitlement | Service-to-service API key | |
| Arcan CLI → Registry | Search, download plugins | None (public) or activation token (enterprise) | |
| Arcan CLI → License | Activate, heartbeat, deactivate | License key or activation token |
Service-to-service keys: svc_ prefix, stored as env vars in each ECS task, rotated via AWS Secrets Manager.
Admin Portal (future)
A simple web dashboard for us to manage licenses and monitor the ecosystem:
getarcan.dev/admin (behind auth)
├── Dashboard
│ ├── Active licenses (count, MRR)
│ ├── Active activations (count, by version)
│ ├── Download stats (today, this week, this month)
│ └── Expiring licenses (next 7/30 days)
│
├── Licenses
│ ├── List all licenses (search, filter by status/tier)
│ ├── Create new license
│ ├── View license detail (activations, events, billing)
│ ├── Suspend / revoke license
│ └── Adjust units / features
│
├── Plugins
│ ├── List all plugins (search, filter by tier)
│ ├── View plugin detail (versions, downloads, publisher)
│ ├── Yank version
│ └── Promote community → official
│
├── Builds
│ ├── List recent builds (filter by status)
│ ├── View build detail (logs, scan results)
│ └── Retry failed build
│
└── Publishers
├── List publishers
├── Verify publisher
└── View publisher plugins
This is deferred until after OSS launch. Initially, admin operations
use the API directly via curl or a simple CLI tool.
Deployment Sequence
| Phase | What | When |
|---|---|---|
| OSS launch | Registry (metadata + downloads), no Build Service yet (we publish manually) | v0.1.0 |
| Enterprise launch | License Service, enterprise plugins in registry | v0.2.0 |
| Build Service | Contributors can publish, automated builds | v0.3.0 |
| SaaS | Stripe billing, self-service license management, admin portal | v0.4.0 |
Each service deploys independently. Adding the License Service doesn't require touching the Registry. Adding the Build Service doesn't require touching either.
Namespace Rules
| Tier | Install Command | Namespace |
|---|---|---|
| Official | arcan plugin install postgres | No prefix — reserved by GetArcan |
| Enterprise | arcan plugin install ssh-proxy | No prefix — reserved by GetArcan |
| Community | arcan plugin install acme/custom-db | Publisher prefix required |
- Official plugin names are globally unique and reserved.
- Community plugins are scoped to their publisher namespace.
- A community plugin cannot use an official name (enforced by
uq_plugins_official_nameindex). - Community plugins can be promoted to official by us (moves to no-prefix namespace).