Entitlements enforcement
Scope: how the backend decides "allowed?" for every quota- and feature-gated
action — EntitlementsService, the plan matrix, and the error bodies clients
see. Not what billing charges: subscription/checkout/webhooks live in
Billing architecture.
Source-checked against optolink-backend @ 29f8589519ac85445ed0598c1b9cfc125a9dd6f2, 2026-10-07 (B-028 re-verification pass;
src/entitlements/unchanged since the 2026-10-06 dissection check — file list now includes the API-key link surface:src/entitlements/entitlements.service.ts,src/entitlements/plans.config.ts,src/portal/portal-entitlements.controller.ts,src/link/link.controller.ts,src/team/team.controller.ts, the three exception classes). HTTP-status claims are code-read only — no live curl pass was run for this harvest.
One chokepoint
EntitlementsService (src/entitlements/entitlements.service.ts) is the only
plan-logic layer — no if org.plan === 'free' anywhere else. Two calls:
canUseFeature(orgId, feature)—plan.features[feature], unless an activeplan_overridesrow (ops-set, optionalexpiresAt) supersedes with its booleanvalue.checkQuota(orgId, quota)—used < limit, wherelimitis the plan config unless an override supersedes with a numericvalue. Returns{allowed, used, limit, onExhausted}.
resolvePlan discriminates on isOpsOrg: ops orgs get the OPS_PLAN sentinel
(every feature true, every quota unlimited); a customer org with plan: null
throws PlanNotSelectedException on gated actions — until a plan is picked,
nothing gated passes.
Plan data comes from the seeded plan_config DB table (5 plans × 2 currencies),
mirrored in code at src/entitlements/plans.config.ts; the billing engine is
the only writer of Organization.plan.
Quota keys and how "used" is counted
Usage is derived live from DB rows — no ledger write on the create path
(click overage metering via recordUsage is the exception, billed by the
billing sweep).
| Quota key | countUsage |
|---|---|
links | count of Link rows |
domains | count where status != GRACE — a domain in its removal window doesn't hold a slot (countUsage switch, entitlements.service.ts lines 278–284) |
clicks_per_month | clicks since the subscription period start (calendar month for orgs without a subscription) |
team_members | OrgMember rows + Clerk pending invitations (fail-open on Clerk errors; invites reserve seats — Team management) |
templates | non-system LinkTemplate rows — delete frees the slot instantly, no recordUsage |
api_keys is not a quota: the two fixed keys are structural, gated by the
api_access feature (Authentication architecture).
The plan matrix
src/entitlements/plans.config.ts (mirrors seeded plan_config; overrides win per org):
| STARTER | SOLO | GROWTH | SCALE | ENTERPRISE | |
|---|---|---|---|---|---|
| links | 10 | 50 | 200 | 1000 | unlimited¹ |
| clicks_per_month | 1,000 | 10,000 | 50,000 | 200,000 | unlimited¹ |
| domains | 1 | 3 | 5 | 25 | unlimited¹ |
| team_members | 1 | 2 | 5 | 15 | unlimited¹ |
| templates | 3 | 5 | 10 | 50 | unlimited¹ |
custom_domains | — | ✓ | ✓ | ✓ | ✓ |
team_members (feature) | — | ✓ | ✓ | ✓ | ✓ |
api_access | — | — | ✓ | ✓ | ✓ |
analytics_export | — | — | — | ✓ | ✓ |
¹ ENTERPRISE limits are Number.MAX_SAFE_INTEGER with onExhausted: 'overage'. Every other plan is 'block' (hard 402) except clicks_per_month on the paid tiers: SOLO/GROWTH/SCALE run it as 'overage' (rates 0.06/0.05/0.04 USD per click, ×50 in EGP) — usage past the limit keeps flowing and is metered to the UsageLedger, then billed at renewal. STARTER clicks_per_month is 'block'.
Gate ordering (the part tests get wrong)
POST /portal/links— the 428 comes first. ZeroAppConfigrows → 428APP_CONFIG_REQUIREDbeforecheckQuotaruns. An un-onboarded org over quota sees 428, not 402 — assertcheckQuotais NOT called on the 428 path (portal-link.controller.tslines 289–298). The API-key surface mirrors it:POST /links(link.controller.ts, comment "Mirror the portal's create gates") runs the same 428-then-402 order. Details in Link creation pipeline.- Feature gate before quota check on domains, api-keys, and team invite:
a plan without the feature gets 403, and the 402 quota check never
matters. (
POST /portal/domains:canUseFeature('custom_domains')at line 72,checkQuota('domains')at line 75.) - Guards run before parameter pipes — a bad tier on
/portal/api-keys/:tier/regenerate400s and still consumes a rate-limit point (apiKeyRegenpreset).
Error contract
All three funnel through GlobalExceptionFilter, which forwards the extra
fields — stable bodies the portal forms key their upgrade prompts on:
// 402 — QuotaExceededException (src/entitlements/quota-exceeded.exception.ts)
{"statusCode":402,"error":"Payment Required",
"message":"You've reached your links limit.",
"code":"QUOTA_EXCEEDED","quota":"links"}
// 403 — FeatureNotAvailableException
{"statusCode":403,"error":"Forbidden",
"message":"custom domains are not available on your plan.",
"code":"FEATURE_NOT_AVAILABLE","feature":"custom_domains"}
// 428 — AppConfigRequiredException (src/portal/app-config-required.exception.ts)
{"statusCode":428,"error":"Precondition Required",
"message":"Register your app before creating a link.",
"code":"APP_CONFIG_REQUIRED"}
(The message interpolates the quota/feature key with underscores → spaces; the feature message picks are/is by key.)
GET /portal/entitlements
Any org role. One read powers every quota bar and locked-feature badge:
{
"quotas": {
"links": {"used": 3, "limit": 10, "exceeded": false},
"clicks_per_month":{"used": 137, "limit": 1000,"exceeded": false},
"domains": {"used": 1, "limit": 1, "exceeded": true},
"team_members": {"used": 1, "limit": 1, "exceeded": true},
"templates": {"used": 1, "limit": 3, "exceeded": false}
},
"features": {
"custom_domains": false, "team_members": false,
"api_access": false, "analytics_export": false
}
}
(Example values are a starter-plan org.) exceeded = !(used < limit) after
overrides. limit is never serialized as null: the controller passes
checkQuota's limit straight through, so an unlimited quota (ENTERPRISE/ops)
ships the raw sentinel 9007199254740991 (Number.MAX_SAFE_INTEGER). The
OpenAPI DTO doc comment says "null = unlimited", but no such mapping exists in
code — portal code comparing against null would never match.
analytics_export gates no endpoint yet — the flag is live, the feature
isn't; nothing to probe until one exists.
Gotchas
- Check-then-write race:
checkQuotahas no transaction or unique constraint — two concurrent creates at the limit can both pass and overshoot by one. Inferred from code order, not reproduced under load. - The seeded Demo Org is
starter— mostapi_access/custom_domainsprobes need a plan bump (the seed resets it; see API-key minting). - Templates never call
recordUsage— the count is whatever the DB says, so a failed create never leaks a slot. - Paid-tier
clicks_per_monthnever 402s on its own —'overage'plans only meter viarecordUsage; the "hard 402" mental model applies to the'block'quotas only.checkQuota().allowedis stillused < limit, so the entitlements bar shows "over" for an overage quota that keeps working.