Files
scope-gate/README.md
T
2026-09-28 14:55:57 -04:00

122 lines
5.0 KiB
Markdown

# ScopeGate
Server-side plan and entitlement checks, plus per-plan write quotas, so a
customer or partner gets exactly the scope they were granted. It is used in
Hitt Hosting products.
## What it does and why
Each product declares its gating once: the ordered list of plans and, if it
uses a feature map, which feature keys each plan unlocks. Every gated surface
reads from that one declaration. The map has to mirror the product's pricing
table, or the app grants scope the customer never bought, or withholds scope
they did.
`effectivePlan()` resolves a principal's plan for this product:
- signed out gives `''`, and no entitlement row gives the base plan;
- a paid plan counts only while its subscription status grants access
(`active`, `trialing` or `past_due` by default); otherwise the account
falls back to the base plan instead of erroring;
- `past_due` keeps access by design: a failed card should not lock
a paying customer out mid-cycle, and dunning makes that call, not the gate;
- an admin role, if configured, gets the top plan.
**Resolution reads only the product-scoped entitlement row, never a billing
field on a shared profile.** When several products share one identity store,
a subscription to a sibling product must never unlock this one. A test pins
this.
`hasEntitlement()` checks a feature against the plan's feature list.
`meetsPlan()` compares plan rank. An unknown `minPlan` in `meetsPlan()` is
treated as not gated, so a typo shows a feature instead of hiding it. That
fail-open suits surfaces that are shown locked, not removed, and should not be
reused for authorization decisions.
## Explaining a decision
A boolean cannot tell "this plan grants the feature" apart from "the gate did
not recognise the requirement and let it through". `explainPlan()` can. A real
grant is `pass`; a fail-open on an unknown plan is `not-applicable` and says
so. A denial names the subscription status that caused the fallback, so
support can answer an access question instead of only observing it.
`toReport()` and `formatAccess()` produce an `@extant2000/evidence-record`
report in which every decision cites the entitlement row behind it.
## Write quotas and the limiter
`planWriteQuota()` gives writes per minute by plan from `DEFAULT_QUOTAS`
(0 means unlimited). Environment variables override it:
`RATE_LIMIT_WRITES_<PLAN>` globally, and `RATE_LIMIT_WRITES_<PRODUCT>_<PLAN>`
per product, which wins. A malformed or negative override is ignored, never
read as unlimited. The environment can be injected, so the function stays
pure and works outside Node.
`createLimiter()` and `createSyncLimiter()` build a sliding-window limiter
keyed on product and account, not IP, since a per-IP limit is defeated by the
same account on two networks. They return a decision (allowed, limit,
remaining, retryAfter, message) and leave the HTTP response to you.
Two traps shaped the design:
- The counter store is pluggable. An in-process map is right on one instance
and wrong on several: each instance keeps its own counters, so N instances
allow N times the quota while every dashboard shows the configured limit.
`MemoryStore` is the default for a single instance; pass a shared, atomic
store (Redis, Postgres) when you run more than one.
- A synchronous limiter exists because a call site that forgets to await an
async limiter turns every rejection into an unhandled promise. The limit
looks configured and enforces nothing.
Unlimited plans and unauthenticated callers are not counted. Anonymous writes
are gated by authentication; counting them under one shared empty key would
let one caller use up the budget for everyone else.
## Usage
```ts
import { effectivePlan, hasEntitlement, explainPlan, formatAccess,
createSyncLimiter, MemoryStore, type SuiteGating } from '@extant2000/scope-gate'
const gating: SuiteGating = {
plans: ['free', 'pro', 'enterprise'],
planEntitlements: {
free: ['dashboard'],
pro: ['dashboard', 'exports'],
enterprise: ['dashboard', 'exports', 'audit'],
},
adminRole: 'admin',
}
const src = { profile: { id: 'u1' }, entitlement: { plan: 'pro', status: 'canceled' } }
effectivePlan(src, gating) // 'free': canceled does not grant access
hasEntitlement(src, 'exports', gating) // false
explainPlan(src, 'prro', gating) // not-applicable: unknown plan, fail-open
console.log(formatAccess(src, [
{ feature: 'exports', minPlan: 'pro' },
{ feature: 'reports', minPlan: 'prro' },
], gating, 'u1'))
const limit = createSyncLimiter({ store: new MemoryStore() })
const decision = limit({ product: 'notes', userId: 'u1', plan: 'free' })
if (!decision.allowed) {
// respond 429 with decision.message and a Retry-After of decision.retryAfter
}
```
For this principal the report fails the `exports` check and names the
canceled status as the cause. The `reports` check is not applicable, because
`prro` is not a known plan and the gate did not apply.
## Install
```
npm install @extant2000/scope-gate
```
## License
MIT. See [LICENSE](LICENSE).