Files
scope-gate/README.md
T
Paul HittandClaude Opus 5.5 61abbd4395 Publish to the GitLab package registry on version tags
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>
2026-09-28 16:42:29 -04:00

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, 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

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.