122 lines
5.0 KiB
Markdown
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).
|