Skip to main content

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 active plan_overrides row (ops-set, optional expiresAt) supersedes with its boolean value.
  • checkQuota(orgId, quota) — used < limit, where limit is the plan config unless an override supersedes with a numeric value. 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 keycountUsage
linkscount of Link rows
domainscount where status != GRACE — a domain in its removal window doesn't hold a slot (countUsage switch, entitlements.service.ts lines 278–284)
clicks_per_monthclicks since the subscription period start (calendar month for orgs without a subscription)
team_membersOrgMember rows + Clerk pending invitations (fail-open on Clerk errors; invites reserve seats — Team management)
templatesnon-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):

STARTERSOLOGROWTHSCALEENTERPRISE
links10502001000unlimited¹
clicks_per_month1,00010,00050,000200,000unlimited¹
domains13525unlimited¹
team_members12515unlimited¹
templates351050unlimited¹
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)​

  1. POST /portal/links — the 428 comes first. Zero AppConfig rows → 428 APP_CONFIG_REQUIRED before checkQuota runs. An un-onboarded org over quota sees 428, not 402 — assert checkQuota is NOT called on the 428 path (portal-link.controller.ts lines 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.
  2. 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.)
  3. Guards run before parameter pipes — a bad tier on /portal/api-keys/:tier/regenerate 400s and still consumes a rate-limit point (apiKeyRegen preset).

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: checkQuota has 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 — most api_access/custom_domains probes 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_month never 402s on its own — 'overage' plans only meter via recordUsage; the "hard 402" mental model applies to the 'block' quotas only. checkQuota().allowed is still used < limit, so the entitlements bar shows "over" for an overage quota that keeps working.