11-registration-and-onboardingwave: W6filled0 citations

SaaS Licensing & Metering — Signed Entitlements and Usage Rollups

Audiences: developer, internal, investor

SaaS Licensing & Metering — Signed Entitlements and Usage Rollups

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.tscanonicalize() produces a deterministic sorted-key JSON string (keys sorted at every depth, array order preserved, undefined dropped). 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 via node:crypto (algorithm null; the digest is intrinsic to EdDSA). The token grammar is a cross-service contract copied verbatim into ddx-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 leaves ddx-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), and LICENSE_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, and compute() catches that to persist the snapshot UNSIGNED rather than failing the license check — ddx-api verification 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.

RoutePurpose
GET saas/license/check?orgId&feature?Cached entitlement check (recomputes on miss/expiry).
GET saas/license/quota?orgId&meterUsage vs plan limit for a UsageMeter in the current billing period.
GET saas/license/keysetPublic Ed25519 verification keyset (LK4).
POST saas/license/ensureIdempotently ensure a perpetual ENTERPRISE ACTIVE subscription + snapshot.
POST saas/license/invalidate?orgIdSet 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):

  1. Acquire a Redis lease (lease:metering-rollup, 15-min TTL, SET … NX) so only one process aggregates in a multi-process deploy.
  2. GROUP BY (organizationId, meter) over the [now-65min, now-1min] window (the 1-min tail avoids racing concurrent inserts), sum quantity.
  3. UPSERT each group into UsageRollup (unique organizationId + meter + periodStart, hour-truncated), incrementing on conflict.
  4. Archive UsageEvent rows older than 90 days to MinIO (metering-archive/<date>/<org>.jsonl, AWS SigV2 via fetch) 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 — flips Subscription.status, invalidates the snapshot, publishes a Redis tenant.suspend event 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).

    SaaS Licensing & Metering — Signed Entitlements and Usage Rollups — Dudoxx Docs | Dudoxx