Quotas
Declaring per-org record limits enforced by the framework.
A quota caps how many rows of a module's data a single org can have. The cap is declared in the module's backend definition; the limit value comes from the license. The framework counts and enforces the cap before any POST reaches your code.
Declaring a quota
Add an entry to BackendModule.quotas:
quotas: [
{
key: 'records',
table: 'app.documents',
orgColumn: 'org_id',
routePath: '/documents',
description: 'Maximum number of documents',
},
]| Field | Purpose |
|---|---|
key | Identifier referenced by the license to declare the cap. |
table | Fully qualified table whose rows are counted. |
orgColumn | Column on that table holding the owning org id. |
routePath | The exact route path whose POST requests are gated. |
description | Human-readable description stored in app.modules.quota_defs. |
Identifier constraint
table and orgColumn must match the regex ^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$. The framework validates this at boot and refuses to start otherwise. The check protects against SQL injection in the count query, since the values are interpolated into a SELECT count(*) FROM <table> WHERE <orgColumn> = $1.
Never compute a quota identifier from user input or runtime configuration. The regex is strict for a reason. If boot fails with quota ... has invalid identifier, fix the literal in your module source.
How enforcement works
The framework registers a preHandler on the routes that match routePath. For every POST:
- Read
X-Org-Idfrom the request headers. If absent, respond400 { error: 'X-Org-Id header required' }. - Resolve the license's quota for
(moduleId, orgId, key)using the org's ancestor chain. - If the limit is
null(unlimited), skip. - Otherwise, count rows in
table WHERE orgColumn = orgId. - If
used >= limit, respond402 { error: 'quota_reached' }. - Otherwise, let the request through.
The count-then-insert is not atomic. If you need a hard guarantee under concurrency, add a database CHECK trigger that enforces the same cap.
License limit values
The license's scope.modules.<id>.limits.<key> value follows the same convention everywhere in Crudy:
| Value | Meaning |
|---|---|
| key absent | 0 (blocked) |
null | unlimited |
| non-negative integer | explicit cap |
Forgetting to declare a key in the license is never silently permissive. A module quota with no matching license key blocks every POST to the quota's route.
Scope resolution
When the preHandler resolves the limit for an org, it walks scopes in this order: exact match (scope.org_id === orgId), nearest ancestor with inherit: true, then the global default (scope.org_id === null). First match wins.
Discoverability
The quota declarations are upserted into app.modules.quota_defs on boot. The GET /license/quotas?orgId=<uuid> endpoint returns [{ moduleId, key, limit, used }] for the caller's reachable orgs. Modules can render quota meters in their pages by reading from that endpoint.
When quotas are not enough
A quota is a per-org record cap. It is not the right tool for:
- Rate limiting. Use the framework's built-in rate limit or a Fastify plugin.
- Per-user limits. The license model is org-scoped.
- Spend or usage metering. Implement that in the module itself and report it through telemetry.