# 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_` globally, and `RATE_LIMIT_WRITES__` 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 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](LICENSE).