First public release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user