The README's install line failed: the package was never published, and nothing told npm where the @extant2000 scope lives. A tag like v1.2.3 now publishes to this project's registry with CI_JOB_TOKEN, and the README sets the scope's registry before installing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5.1 KiB
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,trialingorpast_dueby default); otherwise the account falls back to the base plan instead of erroring; past_duekeeps 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.
MemoryStoreis 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
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
The package is published to the GitLab package registry, so point the
@extant2000 scope there first:
npm config set @extant2000:registry https://gitlab.com/api/v4/packages/npm/
npm install @extant2000/scope-gate
License
MIT. See LICENSE.