SaaS Licensing & Metering — Signed Entitlements and Usage Rollups
Audiences: developer, internal, investor
The licensing and metering subsystems of ddx-saas (the control plane, port 6300,
global route prefix saas/) answer two questions for every tenant: what is this
organization entitled to? (licensing) and how much has it used? (metering). Both are
internal, service-to-service surfaces consumed by ddx-api and the super-admin BFF; none
are public-tenant-facing except the read-only plan catalog.
All routes here sit under the saas/ global prefix set in ddx-saas/src/main.ts
(app.setGlobalPrefix('saas')).
Licensing — entitlement snapshots
Snapshot model (license.service.ts)
A tenant's entitlements are materialized into a cached LicenseSnapshot row (one per
organizationId, 60-second TTL). compute() loads the Subscription → Plan → FeaturePacks → AddOns graph, merges each feature pack's entitlements (features union, most-permissive
limit per key, models/transcribers union), signs the canonical result, and UPSERTs the
snapshot. check() reads the snapshot, recomputing only on miss or expiry, and returns a
LicenseCheckResult { allowed, planTier, status, features, limits, source, computedAt, expiresAt, reason? }. allowed is true only for ACTIVE/TRIAL status; a feature query
param additionally gates on membership in the entitled features list.
The reason field (CR-040) distinguishes why a non-allowed tenant is blocked —
SUSPENDED (admin kill-switch) vs PAST_DUE (billing) vs PAUSED/CANCELED/
PROVISIONING/PROVISION_FAILED — so downstream UX can render distinct messaging.
Signed offline licenses (Ed25519)
LicenseSnapshot rows carry three signature fields (signedLicenseKey, licenseHash,
signingKeyId) so ddx-api can verify entitlements offline, without a round-trip to
ddx-saas on every check.
canonical-json.ts—canonicalize()produces a deterministic sorted-key JSON string (keys sorted at every depth, array order preserved,undefineddropped). This is the byte source for both the hash and the signature, so the hash is stable across recomputes when entitlements are unchanged (the "signature churn on every 60s recompute" failure mode this design kills).license-signing.service.ts— signs with an asymmetric Ed25519 keypair vianode:crypto(algorithmnull; the digest is intrinsic to EdDSA). The token grammar is a cross-service contract copied verbatim intoddx-api:signedLicenseKey = base64url(\${keyId}.${base64url(canonicalJson)}.${base64url(sig)}`),licenseHash = sha256(canonicalJson)(hex). The signed payload wrapsorganizationId+expiresAtaround the entitlements (security H-2) so two tenants on the same plan tier cannot share a replayable signature.verify()never throws on bad input — it returns{ valid: false }` for any malformed token, unknown keyId, or failed signature.license-signing-key.service.ts— resolves key material from env. The private key never leavesddx-saas(invariant). Key material is referenced by NAME only:LICENSE_SIGNING_PRIVATE_KEY(base64 PEM of the Ed25519 private key),LICENSE_SIGNING_KEY_ID(active key id, embedded on each snapshot), andLICENSE_SIGNING_PUBLIC_KEYSET(JSON map{ keyId: base64(PEM) }). All three are OPTIONAL in dev so a dev box boots and signs nothing; a sign attempt without a key throws loudly at call time, andcompute()catches that to persist the snapshot UNSIGNED rather than failing the license check —ddx-apiverification fails-open on a missing signature.
Public keyset & offline verification (LK4)
GET saas/license/keyset publishes the PUBLIC verification keyset
{ activeKeyId, keys: { keyId: publicKeyPem }, revokedKeyIds }. Both active and retired
keys publish their public key (a retired key still verifies in-flight licenses signed before
rotation); revoked keyIds are excluded from keys and listed separately so ddx-api
rejects their signatures even when cryptographically valid. The private key is never
returned. The consumer side lives in ddx-api/src/platform/saas-client/saas-client.service.ts
(verifySignedLicense) — documented under 10-cross-cutting/billing-saas.md, not here.
Endpoints (license.controller.ts)
All saas/license/* routes are guarded by ApiKeyGuard (X-API-Key vs DDX_API_INTERNAL_TOKEN)
and throttled at 1000/hour on the hot paths.
| Route | Purpose |
|---|---|
GET saas/license/check?orgId&feature? | Cached entitlement check (recomputes on miss/expiry). |
GET saas/license/quota?orgId&meter | Usage vs plan limit for a UsageMeter in the current billing period. |
GET saas/license/keyset | Public Ed25519 verification keyset (LK4). |
POST saas/license/ensure | Idempotently ensure a perpetual ENTERPRISE ACTIVE subscription + snapshot. |
POST saas/license/invalidate?orgId | Set expiresAt = now() so the next check() recomputes. |
The ensure provisioning chain
ensure(orgId, orgSlug, planTier=ENTERPRISE) is the idempotent seam that guarantees a tenant
has a working license. It requires a Plan row for the tier (from pnpm seed:catalog),
UPSERTs a perpetual (now + 100 years) ACTIVE Subscription with synthetic non-Stripe
stripeCustomerId/stripeSubscriptionId sentinels (internal_<orgId>), then materializes the
snapshot via compute() so the very next check() is a cache hit. Safe to call on every seed.
A missing subscription surfaces as license_circuit_open HTTP 404 downstream — the seeder /
ddx-api / ddx-saas ensure chain closes that.
Metering — usage events and rollups
Ingest (metering.controller.ts / metering.service.ts)
POST saas/metering/event (guarded by ApiKeyGuard, throttled 1000/min for emitter bursts)
takes a batch of UsageEvent inputs. MeteringService.ingest() does a Prisma createMany with
skipDuplicates keyed on the externalId @unique column (idempotent replay), returning
{ accepted, deduplicated }. Security: it logs only counts + a distinct-org count, never
the full set of organizationIds in a batch (security-reviewer H-4 — co-tenancy leakage via
log aggregation).
Hourly rollup + archival (rollup.cron.ts)
RollupCron.run() runs @Cron(EVERY_HOUR):
- Acquire a Redis lease (
lease:metering-rollup, 15-min TTL,SET … NX) so only one process aggregates in a multi-process deploy. GROUP BY (organizationId, meter)over the[now-65min, now-1min]window (the 1-min tail avoids racing concurrent inserts), sumquantity.- UPSERT each group into
UsageRollup(uniqueorganizationId + meter + periodStart, hour-truncated), incrementing on conflict. - Archive
UsageEventrows older than 90 days to MinIO (metering-archive/<date>/<org>.jsonl, AWS SigV2 viafetch) then DELETE — archive-before-delete; a failed upload skips the delete so no data is lost.
quota() (in license.service.ts) reads these rollups for the current billing period and
compares against the snapshot's limit, returning { meter, used, limit, remaining, exceeded }
(-1 sentinels for an unlimited meter).
Admin surfaces (admin.controller.ts)
The super-admin BFF drives licensing/metering ops through internal saas/admin/* routes
(all ApiKeyGuard-guarded, each writing a SaasAuditLog row in-transaction):
- Kill-switch:
POST saas/admin/tenants/:slug/suspend|unsuspend— flipsSubscription.status, invalidates the snapshot, publishes a Redistenant.suspendevent so subscribers refresh within seconds rather than waiting out the 60s TTL. - License re-issue:
POST saas/admin/subscriptions/:id/license/issue— invalidate + recompute- re-sign.
- Signing-key lifecycle (LK3):
POST saas/admin/license/rotate(register a new active key, demote prior to retired — retire ≠ revoke),POST saas/admin/license/:keyId/revoke(exclude from keyset). Read surfaces:GET saas/admin/metrics|usage|plans|subscriptions|provisioning.
Catalog (adjacent surface)
GET saas/plans (catalog.controller.ts, public, throttle-skipped) lists active plans with
nested feature packs — the read side of the entitlement model. POST saas/admin/catalog
(catalog-admin.controller.ts) patches the ddx-stripe manifest source-of-truth and runs an
idempotent test-mode Stripe sync; it refuses live-mode keys unless allowLive is set (plan
invariant #4 — the UI never free-types into Stripe).