Plans and entitlements
entitlementsSay what each plan includes, check it in one line, and set a tenant's plan from its subscription.
entitlementsFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
Declare plans in main.yaml, then check a feature or limit in one line with entitlements, which refuses with an upgrade hint and takes the plan from verified subscription events.
At a glance
plans:
free: { features: [basic], limits: { seats: 3, projects: 5, ai_spend: $5/month } }
pro:
features: [basic, export, sso]
limits: { seats: 50, requests: 100/s }
prices: [price_pro_monthly, price_pro_yearly]
entitlements:
driver: db
default: free
grace: 7d
upgrade: /settings/billing
usage: { seats: members, ai_spend: ai, projects: "model:projects" }import { entitlements } from "mainsdk";
// 403 with an upgrade hint unless the tenant's plan includes export
entitlements.require("export");
// 403 once the tenant has used all its projects
await entitlements.within("projects", 1, () => createProject(input));lib/limits.test.ts
import { it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { entitlements } from "mainsdk";
it("refuses a fourth seat on free", async () => {
const app = createTestApp({ main: "./main.yaml" }); // free: seats 3
await app.asTenant({ id: "tnt_free" }, async () => {
await app.entitlements.setUsage("tnt_free", "seats", 3);
await assert.rejects(entitlements.within("seats", 1), { code: "MAIN-PLAN-0002" });
});
await app.asTenant({ id: "tnt_pro", plan: "pro" }, async () => {
assert.equal(entitlements.has("export"), true);
});
});Calls
entitlements.has(feature)entitlements.require(feature)entitlements.limit(name)entitlements.within(name, add?, fn?)entitlements.plan()entitlements.set(tenantId, plan, { reason, trial?, until? })entitlements.grant(tenantId, { features?, limits? }, { reason, until? })entitlements.revoke(tenantId, grantId, { reason })entitlements.ungrant(tenantId, grantId, { reason })new Entitlements(o)PLAN_DDL(const)dbPlanStore(o)localPlanStore()parsePlans(plans, ent, { meter? })planIssues(plans, ent, { meter? })
Guarantees
Whatever the driver.
- has reads memory only; no network call per check
- Every refusal carries an upgrade hint that names the plans that include the feature
- The plan comes from verified subscription events, never from the browser
- Per-tenant overrides can only add features or raise limits, and are audited
- The last known plan is served when the store is down
Drivers
2 drivers. Select one to see its line in main.yaml.
entitlements:
driver: dbChange this one line to swap providers; your calls stay the same.Your database integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-PLAN-0006 | Checkmainsdk check, before deploy | plans or entitlements is invalid: the default plan isn't defined, a name is both a feature and a limit, a limit has a bad value, a windowed usage source has a limit with no window, or a price is under two plans. The app doesn't start |
MAIN-PLAN-0010 | Checkmainsdk check, before deploy | A feature or limit name used in code that no plan declares |
MAIN-PLAN-0001 | RuntimeError responses and logs | The tenant's plan doesn't include this feature (403, with an upgrade hint naming the plans that do) |
MAIN-PLAN-0002 | RuntimeError responses and logs | The tenant has reached this limit of its plan (403, with an upgrade hint) |
MAIN-PLAN-0003 | RuntimeError responses and logs | An override would remove a feature or lower a limit; overrides may only add (400) |
MAIN-PLAN-0004 | RuntimeError responses and logs | The plan store can't be reached; the last known plan is served, or the default plan for a tenant with none. Logged once per outage |
MAIN-PLAN-0005 | RuntimeError responses and logs | A subscription event names a price under no plan, or carries no tenant; it is ignored and the plan is unchanged |
MAIN-PLAN-0007 | RuntimeError responses and logs | A plan change for another tenant outside tenant.system(), or by a caller without the entitlements.manage permission (403) |
MAIN-PLAN-0008 | RuntimeError responses and logs | A feature or limit name that no plan declares. has returns false and logs once; limit and within throw (500) |
MAIN-PLAN-0009 | RuntimeError responses and logs | The tenant has reached its plan's request rate (429, with Retry-After) |
MAIN-PLAN-0011 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| plans and entitlements in main.yaml | Phase 1 | 0.3 | entitlements#schema |
| has, require, limit and within for the current tenant | Phase 1 | 0.3 | entitlements#checks |
| Plan state stored in the tenant's region | Phase 1 | 0.3 | entitlements#store |
| Plan set from pay subscription events | Phase 1 | 0.3 | entitlements#pay-source |
| Audited per-tenant overrides that only add | Phase 1 | 0.3 | entitlements#overrides |
| Last known plan served when the store is down | Phase 1 | 0.3 | entitlements#fail-static |
| Usage from members, records, AI spend and meter | Phase 1 | 0.3 | entitlements#usage |
| Plan request rates and plan in flag rules | Phase 1 | 0.3 | entitlements#limits-flags |
| Check for features and limits no plan declares | Phase 1 | 0.3 | entitlements#check |
| Plan helpers in the testing kit | Phase 1 | 0.3 | entitlements#testing |
Quick start
plans:
free: { features: [basic], limits: { seats: 3 } }
pro: { features: [basic, export], limits: { seats: 50 }, prices: [price_pro_monthly] }
entitlements:
default: freeimport { entitlements } from "mainsdk";
async function buildExport() {
return { rows: [] };
}
export async function POST() {
entitlements.require("export"); // throws MAIN-PLAN-0001 (403) with an upgrade hint on free
return Response.json(await buildExport());
}Configuration
plans:
free: { features: [basic], limits: { seats: 3, projects: 5, ai_spend: $5/month, exports: 100/month } }
pro:
features: [basic, export, sso]
limits: { seats: 50, requests: 100/s } # a limit not listed is unlimited
prices: [price_pro_monthly, price_pro_yearly]
entitlements:
driver: db # db (default) | local
default: free
grace: 7d # how long a past-due subscription keeps its plan
upgrade: /settings/billing # the link in every upgrade hint
usage:
seats: members
ai_spend: ai
projects: model:projects
exports: meter:exports # any other name reads the meter counter of that name| Key | What it does |
|---|---|
plans.<name>.features | Names a plan includes. Check them with has and require |
plans.<name>.limits | Numbers, money per window, or request rates. A limit not listed is unlimited |
plans.<name>.prices | The pay price ids that put a tenant on this plan |
entitlements.default | The plan of a tenant with no other plan |
entitlements.driver | db keeps plan state in the tenant's region store. local keeps it in process, for tests and local mode |
entitlements.usage | Where usage comes from: members, ai, model:<name> or meter:<metric> |
A limit that reads a meter counter needs a window (100/month, 20/day), because the meter counts per window. The metric must be declared under meter.metrics; AI spend reads the metric ai.spend. plans with a missing default plan, a bad limit value or a price under two plans stops the app with MAIN-PLAN-0006.
What you get
| Call | What it does |
|---|---|
entitlements.has(feature) | True when the current tenant's plan includes the feature. It reads memory only, never a store or a provider |
entitlements.require(feature) | Throw MAIN-PLAN-0001 (403) with an upgrade hint unless the plan includes the feature |
entitlements.revoke(tenantId, grantId, { reason }) | End a grant of one tenant now, with the reason in the audit log. An unknown grant id fails |
entitlements.ungrant(tenantId, grantId, { reason }) | The old name of revoke. It is deprecated and removed in 1.0, so use revoke |
new Entitlements(o) | The module behind entitlements.*. mainsdk builds it from the plans and entitlements sections of main.yaml; o takes the parsed config, a plan store, and the tenancy, access, log, limits and meter it works with |
parsePlans(plans, ent, { meter? }) | Read and check the plans and entitlements sections. A problem is MAIN-PLAN-0006 and the app does not start. It returns null when neither section exists |
planIssues(plans, ent, { meter? }) | Every problem in the two sections as a list of { key, message }, such as an unknown driver, a grace that isn't a duration or a usage source that isn't members, ai, model:<name> or meter:<metric> |
dbPlanStore(o) | The db driver: one row per tenant in the SQL store of that tenant's region. migrate: true also creates the table on Postgres, and sql() returns the statements |
PLAN_DDL (constant) | The statement that creates the mainsdk_plans table, for a migration file |
localPlanStore() | The local driver: plan state in this process, one map per region, for tests and local mode |
Examples
Check a feature
entitlements.has(feature) reads memory and returns a boolean. entitlements.require(feature) throws. Branch on has to show an upgrade prompt.
import { entitlements } from "mainsdk";
const canExport = entitlements.has("export");
const plan = entitlements.plan(); // { plan, status, source, trialEndsAt?, ... }Enforce a limit
entitlements.limit(name) reports the limit, what is used and what remains. entitlements.within(name, add, fn) refuses when the add would pass the limit, and with fn runs it under a lock so two requests cannot both squeeze in.
import { entitlements } from "mainsdk";
async function buildExport() {
return { rows: [] };
}
const { limit, used, remaining } = await entitlements.limit("exports"); // limit and remaining are null when unlimited
await entitlements.within("exports", 1, () => buildExport()); // throws MAIN-PLAN-0002 at the limitChange a plan
A verified subscription.updated event from pay sets the plan on its own: the event must carry a price listed under a plan's prices and the tenant id in metadata.tenant, or it is ignored with MAIN-PLAN-0005. To set one by hand, such as a trial or a sales deal, call entitlements.set(). For a tenant other than the current one, do it inside tenant.system().
import { entitlements, tenant } from "mainsdk";
const tenantId = "tnt_acme";
await tenant.system("sales deal", async () => {
await entitlements.set(tenantId, "pro", { reason: "annual deal", trial: "14d" });
const grant = await entitlements.grant(tenantId, { features: ["sso"] }, { reason: "pilot", until: "2026-12-31" });
await entitlements.revoke(tenantId, grant.id, { reason: "pilot over" });
});A grant can only add features or raise limits. One that would remove or lower fails with MAIN-PLAN-0003.
Errors and checks
| Code | When | Fix |
|---|---|---|
MAIN-PLAN-0001 | The plan lacks the feature (403, with an upgrade hint) | Upgrade, or grant it |
MAIN-PLAN-0002 | The tenant is at a limit (403) | Upgrade, or raise the limit |
MAIN-PLAN-0003 | A grant would remove a feature or lower a limit (400) | Grant only additions |
MAIN-PLAN-0004 | The plan store is unreachable; the last known plan is served (logged) | Restore the store |
MAIN-PLAN-0005 | A subscription event names a price under no plan, or no tenant; it is ignored (logged) | Add the price under plans.<name>.prices, and set metadata.tenant |
MAIN-PLAN-0006 | plans or entitlements is invalid; the app does not start | Fix the key the error names |
MAIN-PLAN-0007 | A plan change for another tenant outside tenant.system(), or without entitlements.manage (403) | Run it in tenant.system(), or grant the permission |
MAIN-PLAN-0008 | A feature or limit name that no plan declares: limit and within throw | Declare it under plans |
MAIN-PLAN-0009 | The plan's request rate is reached (429) | Wait for Retry-After |
MAIN-PLAN-0010 | mainsdk check found a feature or limit no plan declares | Declare it under plans |
See check codes for all of them.
Swapping drivers
driver: local keeps plan state in process, for tests and local mode. Use db in production; the local driver loses plans on restart.
Testing
createTestApp gives app.entitlements two helpers: setPlan(tenant, plan, { trial? }) and setUsage(tenant, limit, used). Run code as a tenant on a plan with app.asTenant({ id, plan }).
import { it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { entitlements } from "mainsdk";
it("refuses a fourth seat on free", async () => {
const app = createTestApp({ main: "./main.yaml" }); // free: seats 3
await app.asTenant({ id: "tnt_free" }, async () => {
await app.entitlements.setUsage("tnt_free", "seats", 3);
await assert.rejects(entitlements.within("seats", 1), { code: "MAIN-PLAN-0002" });
});
await app.asTenant({ id: "tnt_pro", plan: "pro" }, async () => {
assert.equal(entitlements.has("export"), true);
});
});See the testing kit.
Security and privacy
hasandrequireread memory, so a check adds no network call.- A plan changes only from a verified subscription event, a person with
entitlements.manage, ortenant.system(). - When the store is down, the last known plan is served, and an unknown tenant gets the default plan.
FAQ
Where is the plan stored? In the tenant's region, with an audit record for every change.
Can a flag target a plan? Yes. Flag rules can match the tenant's plan. See feature flags.
Next
- Tenants
- Payments: the subscriptions that set the plan.
- Rate limits and quotas: plan request rates.
- Limits, metering and entitlements
Was this page helpful?