Skip to main content

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

ComponentSpecMonthly Cost
Registry ServiceECS Fargate, 256MB, 0.25 vCPU~$10
License ServiceECS Fargate, 256MB, 0.25 vCPU~$10
Build ServiceECS Fargate, 512MB, 0.5 vCPU~$12
PostgreSQLRDS db.t4g.micro, 20GB~$15
S3Plugin storage, build logs~$2
CloudFrontPackage 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 (.arcanpkg files)
  • Serve plugin metadata (search, discovery, version history)
  • Track download statistics (aggregate, not per-user)
  • Serve as the plugin catalog for arcan plugin install and arcan 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/

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 .arcanpkg packages
  • 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:

ScanToolWhat it checks
Dependency auditgovulncheck (Go), npm audit (TS), pip-audit (Py)Known CVEs in dependencies
Static analysisgo vet + staticcheck (Go)Code quality, common bugs
Secret detectiongitleaksHardcoded secrets, API keys in source
License compliancego-licensesDependency 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──│ │
└──────────┘ └──────────┘
FromToWhyAuth
Registry → BuildTrigger build for new versionService-to-service API key
Build → RegistryReport build results, create artifactsService-to-service API key
Registry → LicenseVerify enterprise download entitlementService-to-service API key
Arcan CLI → RegistrySearch, download pluginsNone (public) or activation token (enterprise)
Arcan CLI → LicenseActivate, heartbeat, deactivateLicense 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

PhaseWhatWhen
OSS launchRegistry (metadata + downloads), no Build Service yet (we publish manually)v0.1.0
Enterprise launchLicense Service, enterprise plugins in registryv0.2.0
Build ServiceContributors can publish, automated buildsv0.3.0
SaaSStripe billing, self-service license management, admin portalv0.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

TierInstall CommandNamespace
Officialarcan plugin install postgresNo prefix — reserved by GetArcan
Enterprisearcan plugin install ssh-proxyNo prefix — reserved by GetArcan
Communityarcan plugin install acme/custom-dbPublisher 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_name index).
  • Community plugins can be promoted to official by us (moves to no-prefix namespace).