Skip to content

Entitlement admin procedures (Garage v1)

Last Updated: 2026-06-10

Day-to-day operator procedures for manual billing on Garage (0–500 users). Operators mutate entitlements through admin.* (operator UI) and the internal admin API — not a CLI, not raw SQL (ADR 38 R6–R7, C6).

Operator surface

SurfaceRole
apps/admin (operator UI)Grant Once, grant base, lapse, extend grace — localized UI for non-technical staff
Operator admin API (/internal/v1/ops/…)Same mutations + lookup; served by apps/ops-api (separate deploy), called from the admin origin
ManualBillingAdapter (crates/entitlement)Shared library behind the admin API; PSP webhooks use the same adapter

Origins (separate from customer app)

The operator UI runs on a different origin and port than the customer file-explorer and gateway edge — not bundled into app.*.

SurfaceLocal devProduction
Customer app + gatewayhttp://127.0.0.1:8080 (nginx edge) or Vite :14200 internal; gateway http://127.0.0.1:18080app.* (e.g. app.substratum.cloud)
Operator UI + API (via proxy)http://127.0.0.1:14220 — UI + /internal on same originadmin.* only
Ops API process (internal):18280 loopback — not browser-facing (not gateway :18080)private network behind admin.* edge

Staff authenticate on the admin origin with PDS OAuth → substratum_ops_session — not customer substratum_session. See Garage v1 rollout topology.

API routing (everything on admin.*)

Operator routes run on substratum-ops-api, but the browser only ever uses the admin.* origin. The admin dev server or production edge forwards API paths to the internal ops process:

EnvironmentWiring
Local devVite on :14220 proxies /internal and /.well-knownhttp://127.0.0.1:18280 (apps/admin/vite.config.ts)
ProductionEdge on admin.* serves the admin SPA and forwards the same paths to ops-api — see admin deployment and infra/admin

Set ops-api PUBLIC_BASE_URL (or OPS_API_PUBLIC_BASE_URL) and OPS_UI_PUBLIC_BASE_URL to admin.* (:14220 local) — not gateway :18080 and not the internal ops bind :18280.

Cross-origin calls directly to :18280 are a misconfiguration; use the admin proxy so substratum_ops_session stays same-origin.

Until the admin UI is fully wired in staging, call /internal/v1/ops/… from the admin origin after ops OAuth (operator_role required) — same surface the scaffold UI will use. UI-only work without ops OAuth: VITE_ADMIN_MOCK=true.

Prerequisites (when admin UI is live)

RequirementNotes
Staff PDS OAuthSign in at admin.*substratum_ops_session (ADR 38 R17)
operator_role rowStaff did must have support_read, entitlement_mutator, or billing_reconciler
Ticket referenceEvery grant/lapse requires reason + ticket_ref (ADR 38 C4)

Production admin API should sit behind VPN or allowlist (ADR 38 C8).

Capability reference (ADR 37 CC7)

Customer statemetadata_write_allowedsafety_net_allowed
Substratum Once (paid)truefalse
Base activetruetrue
Base lapsed, no Oncefalsefalse
Base lapsed, has Oncetruefalse

Grant Substratum Once (manual payment verified)

After verifying $99 payment (bank transfer, invoice, etc.):

  1. Open admin.* and sign in with staff Substratum PDS account.
  2. Look up customer by DID, handle, or support email.
  3. Grant Once — enter ticket_ref and reason (required).
  4. Confirm capabilities: metadata_write_allowed=true, safety_net_allowed=false.

Post-grant: share support.* when Discourse is live (Phase 4); customer completes Substratum PDS signup when invite flow is enabled (Phase 3).

Grant base membership

After verifying $5/mo subscription (manual ledger until Stripe ships):

  1. Same lookup flow on admin.*.
  2. Grant base with ticket_ref and reason.
  3. Customer receives 25 GB safety-net quota (base_membership plan).

Full lapse

When subscription ends or payment fails after grace:

  1. Lapse action on admin.* with ticket_ref and reason.
  2. Lapse semantics follow ADR 37 CC7 (Once customers keep metadata write).
  3. Phase 4 adds Discourse deactivate on the same adapter event.

Extend grace

Before applying full lapse (PSP invoice.failed grace window):

  1. Extend grace on admin.* — set end date and document ticket_ref / reason.
  2. Capabilities stay unchanged until lapse runs or grace automation ships.

Staff DID provisioning (operator_role)

Before staff can use admin.*:

  1. Create staff handle on pds.substratum.cloud (existing PDS runbook).
  2. Insert operator_role row (engineering break-glass only until admin API can provision roles):
sql
SET LOCAL app.entitlement_admin = 'true';
INSERT INTO operator_role (id, staff_did, role)
VALUES (
  gen_random_uuid(),
  'did:plc:STAFF',
  'entitlement_mutator'
);
RoleUse
support_readLookup entitlement + audit tail only
entitlement_mutatorGrant, lapse, grace extend
billing_reconcilerPSP reconciliation + overrides

Revoke: UPDATE operator_role SET disabled_at = now() WHERE staff_did = '…' AND disabled_at IS NULL;

Audit verification

Every admin mutation appends entitlement_audit_log with operator_principal (staff did), reason, ticket_ref, and before/after JSON snapshots. The operator UI should expose the audit tail per customer (ADR 38 R15); engineering can query:

sql
SET LOCAL app.entitlement_admin = 'true';
SELECT created_at, action, ticket_ref, operator_principal
FROM entitlement_audit_log
WHERE customer_did = 'did:plc:CUSTOMER'
ORDER BY created_at DESC
LIMIT 20;

What not to do

Anti-patternWhy
UPDATE entitlement SET … by handBypasses audit and idempotency (C6)
Grant without ticket refRejected by adapter (C4)
Operator entitlement CLINot shipped — use admin.* UI + API
Customer substratum_session for opsUse staff OAuth + operator_role