First public release

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 14:55:57 -04:00
co-authored by Claude Opus 5.5
commit 094d43c1e4
17 changed files with 2606 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
*.log
.DS_Store
.env*
!.env.example
.npmrc
dist/
dist-cjs/
+6
View File
@@ -0,0 +1,6 @@
stages: [test]
test:
stage: test
image: node:22-alpine
script: [npm ci, npm run build, npm test]
+16
View File
@@ -0,0 +1,16 @@
# Changelog
## 0.3.0 - 2026-09-28
First public release under MIT.
- `effectivePlan()`, `hasEntitlement()`, `planRank()` and `meetsPlan()`
resolve a principal's plan from the product-scoped entitlement row only.
- `explainPlan()` separates a real grant from a fail-open on an unknown plan,
and names the subscription status behind a denial. `toReport()` and
`formatAccess()` render decisions in the `@extant2000/evidence-record`
format.
- `planWriteQuota()` gives per-plan write quotas with global and per-product
environment overrides.
- `createLimiter()` and `createSyncLimiter()` build a per-account
sliding-window limiter with a pluggable counter store.
+22
View File
@@ -0,0 +1,22 @@
# Contributing
Issues and merge requests are welcome at
https://gitlab.com/extant2000/scope-gate.
## Ground rules
- **A test that cannot fail proves nothing.** If you fix a bug, add a test
that fails without your fix, and check that it does fail before you submit.
- Measure, do not assume. Two modules with the same line count can be
different programs.
- Keep dependencies minimal. Every new dependency needs a reason.
- Explain why in comments, not what.
## Before you open a merge request
Run both of these and make sure they pass:
```
npm run build
npm test
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Extant 2000 LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+121
View File
@@ -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).
+6
View File
@@ -0,0 +1,6 @@
# Security
Please report vulnerabilities privately by email to security@extant2000.com.
Do not open a public issue for a security problem.
We aim to acknowledge every report within 5 business days.
+1522
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@extant2000/scope-gate",
"version": "0.3.0",
"private": false,
"description": "Checks that each customer gets exactly the access they were granted.",
"license": "MIT",
"type": "module",
"main": "dist/index.js",
"files": [
"dist",
"src",
"README.md",
"LICENSE"
],
"repository": {
"type": "git",
"url": "https://gitlab.com/extant2000/scope-gate.git"
},
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"test": "vitest run",
"prepublishOnly": "npm run build"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
},
"dependencies": {
"@extant2000/evidence-record": "^0.1.2"
},
"author": "Extant 2000 LLC",
"homepage": "https://gitlab.com/extant2000/scope-gate",
"bugs": {
"url": "https://gitlab.com/extant2000/scope-gate/-/issues"
}
}
+111
View File
@@ -0,0 +1,111 @@
/**
* Config-driven plan/entitlement resolution.
*
* Each product declares its gating once — the ordered plan list and which
* feature groups each plan unlocks — and every gated surface reads from that
* one declaration. The map MUST mirror the product's pricing comparison table,
* or the app grants a scope the customer was never sold (or withholds one they
* were).
*
* CROSS-PRODUCT ISOLATION: resolution reads the PRODUCT-SCOPED entitlement row
* — the plan and status for THIS product — and never a shared billing field on
* the profile. Several products share one identity store, so a sibling
* product's subscription must never unlock this one. Pinned by test.
*/
export interface SuiteGating {
/** Plan keys ordered low → high, e.g. ['free', 'pro', 'govcon']. */
plans: string[]
/**
* plan key → the feature/entitlement keys it unlocks (mirror the pricing
* table). Optional: rank-model products (surfaces carry a `minPlan` and gate
* by tier order via meetsPlan) don't need a feature map.
*/
planEntitlements?: Record<string, string[]>
/** Role value (on the product's role field) that grants the top plan. */
adminRole?: string
/** Profile field holding the role, e.g. 'hr_role'. Defaults to 'role'. */
roleField?: string
/** Subscription statuses that grant access. Defaults below. */
activeStatuses?: string[]
}
export interface EntitlementSource {
// `any` (not `unknown`) so callers can pass a concrete product Profile
// interface — a named interface has no string index signature and is not
// assignable to Record<string, unknown>.
profile?: Record<string, any> | null
entitlement?: { plan?: string | null, status?: string | null } | null
}
/**
* Statuses that still grant access. `past_due` is deliberately included: a
* failed card should not lock a paying customer out mid-cycle. Dunning handles
* that decision, not the gate.
*/
export const DEFAULT_ACTIVE_STATUSES = ['active', 'trialing', 'past_due']
/** Resolve the principal's effective plan for this product. '' when signed out. */
export function effectivePlan(
src: EntitlementSource | null | undefined,
gating: SuiteGating,
): string {
const profile = src?.profile || null
const entitlement = src?.entitlement || null
if (!profile) return ''
const base = gating.plans[0] || 'free'
const top = gating.plans[gating.plans.length - 1] || base
const roleField = gating.roleField || 'role'
if (gating.adminRole && profile[roleField] === gating.adminRole) return top
if (!entitlement) return base
const plan = String(entitlement.plan || '').toLowerCase()
const active = gating.activeStatuses || DEFAULT_ACTIVE_STATUSES
// An inactive paid plan falls back to base rather than erroring: the account
// still works, at the free tier, which is the correct commercial behaviour.
if (gating.plans.includes(plan) && plan !== base) {
return active.includes(String(entitlement.status || '')) ? plan : base
}
return base
}
/** Whether the effective plan unlocks a feature key (feature-map model). */
export function hasEntitlement(
src: EntitlementSource | null | undefined,
feature: string,
gating: SuiteGating,
): boolean {
const plan = effectivePlan(src, gating)
const base = gating.plans[0] || 'free'
const map = gating.planEntitlements || {}
const ents = map[plan || base] || map[base] || []
return ents.includes(feature)
}
/** Rank of a plan within the ordered plans list; -1 if unknown. */
export function planRank(plan: string, gating: SuiteGating): number {
return gating.plans.indexOf(plan)
}
/**
* Rank model: whether the effective plan meets or exceeds `minPlan`.
*
* An UNKNOWN minPlan is treated as not-gated (true) so a typo never hides a
* feature outright. That is a deliberate fail-open, and it is the right
* direction here: this model is used for surfaces shown locked rather than
* removed, so the cost of a typo is a visible feature, not a silent one. Do
* NOT reuse this default for authorization decisions — see enforcement, which
* fails closed.
*/
export function meetsPlan(
src: EntitlementSource | null | undefined,
minPlan: string,
gating: SuiteGating,
): boolean {
const need = gating.plans.indexOf(minPlan)
if (need < 0) return true
return gating.plans.indexOf(effectivePlan(src, gating)) >= need
}
+5
View File
@@ -0,0 +1,5 @@
export * from './entitlements.js'
export * from './quota.js'
export * from './limiter.js'
export { explainPlan, toReport, formatAccess } from './report.js'
export type { AccessExplanation, AccessCheck } from './report.js'
+177
View File
@@ -0,0 +1,177 @@
import { planWriteQuota, type QuotaOptions } from './quota.js'
/**
* Sliding-window write limiter, decoupled from both the HTTP framework and the
* counter store.
*
* WHY THE STORE IS PLUGGABLE: the original implementation held buckets in a
* module-level Map. That is correct on ONE instance and silently wrong on more
* than one — each instance keeps its own counters, so N instances grant N×
* the quota while every dashboard still reports the configured limit. The
* limiter does not fail closed, it fails *quietly generous*, which is the
* hardest kind of limit failure to notice.
*
* MemoryStore is still the default because it is right for a single instance
* and needs no infrastructure. Passing a shared store (Redis, Postgres) is what
* makes the limit true across a fleet.
*/
export interface LimiterStore {
/**
* Atomically increment the counter for `key`, creating it with the given TTL
* if absent. Returns the new count and when the window resets (epoch ms).
*
* MUST be atomic in a shared store: a read-then-write pair races under
* concurrency and undercounts exactly when the limiter matters most.
*/
hit: (key: string, windowMs: number) => { count: number, resetAt: number } | Promise<{ count: number, resetAt: number }>
}
interface Bucket { count: number, resetAt: number }
/** In-process store. Correct for a single instance only — see above. */
export class MemoryStore implements LimiterStore {
private buckets = new Map<string, Bucket>()
private gc: ReturnType<typeof setInterval> | null = null
hit(key: string, windowMs: number): { count: number, resetAt: number } {
this.startGC()
const now = Date.now()
const b = this.buckets.get(key)
if (!b || b.resetAt < now) {
const fresh = { count: 1, resetAt: now + windowMs }
this.buckets.set(key, fresh)
return fresh
}
b.count++
return b
}
private startGC() {
if (this.gc) return
this.gc = setInterval(() => {
const now = Date.now()
for (const [k, v] of this.buckets) if (v.resetAt < now) this.buckets.delete(k)
}, 5 * 60 * 1000)
// Never hold the process open just to expire counters.
this.gc.unref?.()
}
/** Test helper — drop all counters. */
reset() { this.buckets.clear() }
}
export interface LimitInput {
product: string
/** The account this budget belongs to. Empty means unauthenticated. */
userId: string
plan: string | null | undefined
windowSec?: number
}
export interface LimitDecision {
/** False when the caller should be rejected. */
allowed: boolean
/** Configured ceiling. Infinity for unlimited plans. */
limit: number
/** Requests left in this window. Never negative. */
remaining: number
/** Seconds until the window resets. 0 when unlimited. */
retryAfter: number
/** Ready-made message for a 429 body. */
message?: string
}
const UNLIMITED: LimitDecision = {
allowed: true, limit: Infinity, remaining: Infinity, retryAfter: 0,
}
/** Store whose `hit` is synchronous. MemoryStore satisfies this. */
export interface SyncLimiterStore {
hit: (key: string, windowMs: number) => { count: number, resetAt: number }
}
export interface LimiterOptions extends QuotaOptions {
store?: LimiterStore
}
export interface SyncLimiterOptions extends QuotaOptions {
store?: SyncLimiterStore
}
/** Shared decision logic. Pure — no I/O, no store, no framework. */
function decide(max: number, count: number, resetAt: number): LimitDecision {
const remaining = Math.max(0, max - count)
if (count > max) {
const retryAfter = Math.max(1, Math.ceil((resetAt - Date.now()) / 1000))
return {
allowed: false,
limit: max,
remaining: 0,
retryAfter,
message: `You've hit your plan's limit of ${max} changes/minute. Try again in ${retryAfter}s, or upgrade for a higher limit.`,
}
}
return { allowed: true, limit: max, remaining, retryAfter: 0 }
}
/**
* True when this call needs no counting at all: an unlimited plan, or no
* principal to bill the write to. Unauthenticated writes are gated by auth,
* not by quota — counting them under a shared empty key would let one
* anonymous caller exhaust the budget for every other one.
*/
function isExempt(max: number, userId: string): boolean {
return !max || max <= 0 || !userId
}
function bucketKey(input: LimitInput): string {
return `w:${input.product}:${input.userId}`
}
/**
* Build an async limiter. Returns a DECISION rather than throwing, so the
* caller owns the HTTP shape — the same limiter serves an H3 handler, a queue
* worker, or a gRPC interceptor.
*
* Use this when the store is shared (Redis, Postgres) and therefore async.
*/
export function createLimiter(options: LimiterOptions = {}) {
const store = options.store || new MemoryStore()
return async function check(input: LimitInput): Promise<LimitDecision> {
const max = planWriteQuota(input.plan, options)
if (isExempt(max, input.userId)) return UNLIMITED
const windowMs = (input.windowSec || 60) * 1000
const { count, resetAt } = await store.hit(bucketKey(input), windowMs)
return decide(max, count, resetAt)
}
}
/**
* Build a SYNCHRONOUS limiter.
*
* Exists because callers that gate a request on the result must not be forced
* to become async. An existing sync call site that silently keeps calling an
* async limiter without awaiting turns every rejection into an unhandled
* promise: the limit appears configured, reports correctly, and enforces
* nothing. Offering a sync path removes that trap instead of relying on every
* call site remembering to await.
*
* Only usable with a synchronous store, which in practice means in-process —
* so this is single-instance by construction. For a fleet, use `createLimiter`
* with a shared store and await it.
*/
export function createSyncLimiter(options: SyncLimiterOptions = {}) {
const store = options.store || new MemoryStore()
return function check(input: LimitInput): LimitDecision {
const max = planWriteQuota(input.plan, options)
if (isExempt(max, input.userId)) return UNLIMITED
const windowMs = (input.windowSec || 60) * 1000
const { count, resetAt } = store.hit(bucketKey(input), windowMs)
return decide(max, count, resetAt)
}
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Per-plan write quotas.
*
* Caps how many mutating requests one ACCOUNT can make in a window, by plan.
* Keyed on the principal rather than the IP: a plan gates its budget regardless
* of where the requests come from, and per-IP limits are trivially defeated by
* the same account from two networks.
*/
/**
* Writes/minute by plan. Deliberately generous for a human working normally (a
* person almost never sustains ~1 write/sec) while stopping a script from
* posting thousands. Unlisted plans fall back to `default`. 0 = unlimited.
*/
export const DEFAULT_QUOTAS: Readonly<Record<string, number>> = Object.freeze({
none: 30,
free: 60,
starter: 60,
basic: 120,
pro: 300,
professional: 300,
plus: 300,
team: 600,
business: 600,
growth: 600,
govcon: 600,
scale: 1200,
unlimited: 0,
enterprise: 0,
default: 120,
})
export interface QuotaOptions {
/** Product key, so one product can be tuned independently of the suite. */
product?: string
/** Quota table override. Defaults to DEFAULT_QUOTAS. */
quotas?: Record<string, number>
/**
* Environment lookup, injected so this stays pure and testable. Defaults to
* `process.env` when available, and to an empty map otherwise — which keeps
* the library usable in a browser or edge runtime.
*/
env?: Record<string, string | undefined>
}
function defaultEnv(): Record<string, string | undefined> {
return typeof process !== 'undefined' && process.env ? process.env : {}
}
/**
* Env override lookup. Per-product wins over global so a noisy product can be
* loosened without raising the ceiling for the whole suite:
* RATE_LIMIT_WRITES_<PLAN> (global)
* RATE_LIMIT_WRITES_<PRODUCT>_<PLAN> (per product, wins)
*/
function envQuota(
plan: string,
env: Record<string, string | undefined>,
product?: string,
): number | null {
const P = String(plan || '').toUpperCase().replace(/[^A-Z0-9]/g, '_')
const keys = product
? [`RATE_LIMIT_WRITES_${String(product).toUpperCase()}_${P}`, `RATE_LIMIT_WRITES_${P}`]
: [`RATE_LIMIT_WRITES_${P}`]
for (const k of keys) {
const v = env[k]
if (v != null && v !== '') {
if (/^unlimited$/i.test(v)) return 0
const n = Number(v)
if (Number.isFinite(n) && n >= 0) return n
}
}
return null
}
/** Writes-per-minute quota for a plan (0 = unlimited). Env overrides win. */
export function planWriteQuota(
plan: string | null | undefined,
opts: QuotaOptions = {},
): number {
const p = String(plan || 'none').toLowerCase()
const table = opts.quotas || DEFAULT_QUOTAS
const env = opts.env || defaultEnv()
const override = envQuota(p, env, opts.product)
if (override != null) return override
const q = table[p]
return q != null ? q : (table.default ?? 120)
}
+132
View File
@@ -0,0 +1,132 @@
import {
type CapabilityReport,
type Determination,
type Finding,
type FormatOptions,
evidence,
formatReport as renderReport,
} from '@extant2000/evidence-record'
import {
DEFAULT_ACTIVE_STATUSES,
effectivePlan,
type EntitlementSource,
type SuiteGating,
} from './entitlements.js'
export interface AccessExplanation {
determination: Determination
/** One sentence a support agent can read to a customer. */
reason: string
effectivePlan: string
required: string
}
/**
* Explain a plan-gate decision instead of only deciding it.
*
* `meetsPlan` returns `true` for an UNKNOWN `minPlan` — a deliberate
* fail-open, correct for surfaces shown locked rather than removed. But a
* boolean cannot distinguish "this plan grants the feature" from "the gate
* did not recognise the requirement and let it through", and those are very
* different facts to see in an access log. The first is `pass`; the second is
* `not-applicable`, and it says why.
*/
export function explainPlan(
src: EntitlementSource | null | undefined,
minPlan: string,
gating: SuiteGating,
): AccessExplanation {
const plan = effectivePlan(src, gating)
const need = gating.plans.indexOf(minPlan)
if (need < 0) {
return {
determination: 'not-applicable',
reason: `"${minPlan}" is not a known plan, so the gate did not apply. Access was allowed by fail-open, NOT granted by the plan.`,
effectivePlan: plan,
required: minPlan,
}
}
if (!plan) {
return {
determination: 'fail',
reason: 'No profile is loaded, so there is no principal to grant anything to.',
effectivePlan: '',
required: minPlan,
}
}
const has = gating.plans.indexOf(plan) >= need
const status = String(src?.entitlement?.status ?? '')
const active = gating.activeStatuses || DEFAULT_ACTIVE_STATUSES
return {
determination: has ? 'pass' : 'fail',
reason: has
? `Effective plan "${plan}" meets the "${minPlan}" requirement.`
: status && !active.includes(status)
? `Subscription status "${status}" is not an access-granting status, so the plan fell back to "${plan}", which does not meet "${minPlan}".`
: `Effective plan "${plan}" does not meet the "${minPlan}" requirement.`,
effectivePlan: plan,
required: minPlan,
}
}
export interface AccessCheck {
/** What is being gated, e.g. "bulk export". */
feature: string
minPlan: string
}
/**
* Report a set of gate decisions for one principal.
*
* Each decision cites the entitlement it turned on, so an access denial is
* answerable — a support agent can see the plan and the status that produced
* it rather than only the verdict.
*/
export function toReport(
src: EntitlementSource | null | undefined,
checks: AccessCheck[],
gating: SuiteGating,
subject = 'principal',
): CapabilityReport {
const plan = effectivePlan(src, gating)
const status = src?.entitlement?.status ?? null
const findings: Finding[] = checks.map((c): Finding => {
const x = explainPlan(src, c.minPlan, gating)
return {
id: c.feature,
summary: `${c.feature} — requires "${c.minPlan}", principal has "${x.effectivePlan || 'none'}"`,
determination: x.determination,
severity: x.determination === 'fail' ? 'info' : 'info',
detail: x.reason,
evidence: [
evidence.record('entitlements', subject, 'plan', src?.entitlement?.plan ?? '(none)'),
...(status ? [evidence.record('entitlements', subject, 'status', status)] : []),
],
}
})
return {
capability: 'ScopeGate',
scope: `${checks.length} gated feature(s) for ${subject}`,
examined: checks.length,
findings,
notes: plan
? [`effective plan: ${plan}${status ? ` (subscription status "${status}")` : ''}`]
: ['No profile loaded — the principal is signed out.'],
}
}
export function formatAccess(
src: EntitlementSource | null | undefined,
checks: AccessCheck[],
gating: SuiteGating,
subject = 'principal',
opts: FormatOptions = {},
): string {
return renderReport(toReport(src, checks, gating, subject), opts)
}
+297
View File
@@ -0,0 +1,297 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { validateReport } from '@extant2000/evidence-record'
import {
DEFAULT_QUOTAS,
explainPlan,
formatAccess,
toReport,
MemoryStore,
createLimiter,
effectivePlan,
hasEntitlement,
meetsPlan,
planRank,
createSyncLimiter,
planWriteQuota,
type SuiteGating,
} from '../src/index.js'
const gating: SuiteGating = {
plans: ['free', 'pro', 'govcon'],
planEntitlements: {
free: ['dashboard'],
pro: ['dashboard', 'exports'],
govcon: ['dashboard', 'exports', 'audit'],
},
adminRole: 'admin',
roleField: 'role',
}
describe('effectivePlan', () => {
it('returns empty string when signed out', () => {
expect(effectivePlan(null, gating)).toBe('')
expect(effectivePlan({ profile: null }, gating)).toBe('')
})
it('falls back to the base plan with no entitlement row', () => {
expect(effectivePlan({ profile: {} }, gating)).toBe('free')
})
it('grants the paid plan on an active subscription', () => {
expect(effectivePlan(
{ profile: {}, entitlement: { plan: 'pro', status: 'active' } }, gating,
)).toBe('pro')
})
it('keeps access while past_due — dunning decides, not the gate', () => {
expect(effectivePlan(
{ profile: {}, entitlement: { plan: 'pro', status: 'past_due' } }, gating,
)).toBe('pro')
})
it('drops to base on a cancelled subscription', () => {
expect(effectivePlan(
{ profile: {}, entitlement: { plan: 'pro', status: 'canceled' } }, gating,
)).toBe('free')
})
it('grants the top plan to the admin role', () => {
expect(effectivePlan({ profile: { role: 'admin' } }, gating)).toBe('govcon')
})
it('honours a custom roleField', () => {
const g = { ...gating, roleField: 'hr_role' }
expect(effectivePlan({ profile: { hr_role: 'admin' } }, g)).toBe('govcon')
// The default 'role' field must NOT be consulted once roleField is set.
expect(effectivePlan({ profile: { role: 'admin' } }, g)).toBe('free')
})
it('CROSS-PRODUCT ISOLATION: a sibling product subscription does not unlock this one', () => {
// Several products share one identity store. A billing field on the shared
// profile must never grant scope — only the product-scoped entitlement row.
const src = {
profile: { stripe_subscription_status: 'active', stripe_plan: 'govcon' },
entitlement: null,
}
expect(effectivePlan(src, gating)).toBe('free')
})
it('ignores a plan key that is not in this product gating', () => {
expect(effectivePlan(
{ profile: {}, entitlement: { plan: 'enterprise', status: 'active' } }, gating,
)).toBe('free')
})
})
describe('hasEntitlement / planRank / meetsPlan', () => {
it('gates features by the effective plan', () => {
const pro = { profile: {}, entitlement: { plan: 'pro', status: 'active' } }
expect(hasEntitlement(pro, 'exports', gating)).toBe(true)
expect(hasEntitlement(pro, 'audit', gating)).toBe(false)
})
it('ranks plans by declared order', () => {
expect(planRank('free', gating)).toBe(0)
expect(planRank('govcon', gating)).toBe(2)
expect(planRank('nope', gating)).toBe(-1)
})
it('meetsPlan compares rank', () => {
const pro = { profile: {}, entitlement: { plan: 'pro', status: 'active' } }
expect(meetsPlan(pro, 'free', gating)).toBe(true)
expect(meetsPlan(pro, 'pro', gating)).toBe(true)
expect(meetsPlan(pro, 'govcon', gating)).toBe(false)
})
it('treats an UNKNOWN minPlan as not-gated (documented fail-open)', () => {
// Deliberate: this model drives surfaces shown locked rather than removed,
// so a typo costs a visible feature, not a silent one.
expect(meetsPlan({ profile: {} }, 'typoo', gating)).toBe(true)
})
})
describe('planWriteQuota', () => {
it('uses the default table', () => {
expect(planWriteQuota('free')).toBe(DEFAULT_QUOTAS.free)
expect(planWriteQuota('pro')).toBe(DEFAULT_QUOTAS.pro)
})
it('falls back to default for an unknown plan', () => {
expect(planWriteQuota('mystery')).toBe(DEFAULT_QUOTAS.default)
})
it('treats a null plan as none', () => {
expect(planWriteQuota(null)).toBe(DEFAULT_QUOTAS.none)
})
it('returns 0 (unlimited) for enterprise', () => {
expect(planWriteQuota('enterprise')).toBe(0)
})
it('lets a global env var override', () => {
expect(planWriteQuota('free', { env: { RATE_LIMIT_WRITES_FREE: '5' } })).toBe(5)
})
it('lets a per-product env var beat the global', () => {
const env = { RATE_LIMIT_WRITES_FREE: '5', RATE_LIMIT_WRITES_CRM_FREE: '99' }
expect(planWriteQuota('free', { env, product: 'crm' })).toBe(99)
})
it('accepts the literal "unlimited"', () => {
expect(planWriteQuota('free', { env: { RATE_LIMIT_WRITES_FREE: 'unlimited' } })).toBe(0)
})
it('ignores a malformed env value rather than failing open to 0', () => {
// A negative or non-numeric override must not silently become "unlimited".
expect(planWriteQuota('free', { env: { RATE_LIMIT_WRITES_FREE: 'abc' } })).toBe(60)
expect(planWriteQuota('free', { env: { RATE_LIMIT_WRITES_FREE: '-1' } })).toBe(60)
})
})
describe('createLimiter', () => {
let store: MemoryStore
beforeEach(() => { store = new MemoryStore() })
it('allows up to the quota then rejects', async () => {
const check = createLimiter({ store, env: { RATE_LIMIT_WRITES_FREE: '3' } })
const input = { product: 'crm', userId: 'u1', plan: 'free' }
for (let i = 0; i < 3; i++) {
expect((await check(input)).allowed).toBe(true)
}
const denied = await check(input)
expect(denied.allowed).toBe(false)
expect(denied.limit).toBe(3)
expect(denied.remaining).toBe(0)
expect(denied.retryAfter).toBeGreaterThan(0)
expect(denied.message).toContain('3 changes/minute')
})
it('counts down remaining', async () => {
const check = createLimiter({ store, env: { RATE_LIMIT_WRITES_FREE: '3' } })
const input = { product: 'crm', userId: 'u1', plan: 'free' }
expect((await check(input)).remaining).toBe(2)
expect((await check(input)).remaining).toBe(1)
expect((await check(input)).remaining).toBe(0)
})
it('budgets each account separately', async () => {
const check = createLimiter({ store, env: { RATE_LIMIT_WRITES_FREE: '1' } })
expect((await check({ product: 'crm', userId: 'u1', plan: 'free' })).allowed).toBe(true)
expect((await check({ product: 'crm', userId: 'u2', plan: 'free' })).allowed).toBe(true)
expect((await check({ product: 'crm', userId: 'u1', plan: 'free' })).allowed).toBe(false)
})
it('budgets each product separately for the same account', async () => {
const check = createLimiter({ store, env: { RATE_LIMIT_WRITES_FREE: '1' } })
expect((await check({ product: 'crm', userId: 'u1', plan: 'free' })).allowed).toBe(true)
expect((await check({ product: 'books', userId: 'u1', plan: 'free' })).allowed).toBe(true)
})
it('never limits an unlimited plan', async () => {
const check = createLimiter({ store })
for (let i = 0; i < 50; i++) {
expect((await check({ product: 'crm', userId: 'u1', plan: 'enterprise' })).allowed).toBe(true)
}
})
it('does not bill unauthenticated writes to a shared empty key', async () => {
// Otherwise one anonymous caller exhausts the budget for every other one.
const check = createLimiter({ store, env: { RATE_LIMIT_WRITES_FREE: '1' } })
for (let i = 0; i < 10; i++) {
expect((await check({ product: 'crm', userId: '', plan: 'free' })).allowed).toBe(true)
}
})
it('accepts an injected shared store — the multi-instance fix', async () => {
// Two "instances" sharing one store must share one budget. With the old
// module-level Map, each instance kept its own counters and the effective
// limit silently became N× the configured one.
const shared = new MemoryStore()
const a = createLimiter({ store: shared, env: { RATE_LIMIT_WRITES_FREE: '2' } })
const b = createLimiter({ store: shared, env: { RATE_LIMIT_WRITES_FREE: '2' } })
const input = { product: 'crm', userId: 'u1', plan: 'free' }
expect((await a(input)).allowed).toBe(true)
expect((await b(input)).allowed).toBe(true)
expect((await a(input)).allowed).toBe(false)
})
})
describe('createSyncLimiter', () => {
it('returns a decision synchronously, not a promise', () => {
// The reason this exists: every product middleware calls the limiter
// WITHOUT await. If the limiter were async, the 429 would surface as an
// unhandled rejection and the write would proceed — a limit that reports
// correctly and enforces nothing.
const check = createSyncLimiter({
store: new MemoryStore(), env: { RATE_LIMIT_WRITES_FREE: '2' },
})
const d = check({ product: 'crm', userId: 'u1', plan: 'free' })
expect(d).not.toBeInstanceOf(Promise)
expect(d.allowed).toBe(true)
})
it('enforces the same budget as the async limiter', () => {
const check = createSyncLimiter({
store: new MemoryStore(), env: { RATE_LIMIT_WRITES_FREE: '2' },
})
const input = { product: 'crm', userId: 'u1', plan: 'free' }
expect(check(input).allowed).toBe(true)
expect(check(input).allowed).toBe(true)
expect(check(input).allowed).toBe(false)
})
it('exempts unlimited plans and anonymous callers', () => {
const check = createSyncLimiter({ store: new MemoryStore() })
expect(check({ product: 'crm', userId: 'u1', plan: 'enterprise' }).limit).toBe(Infinity)
expect(check({ product: 'crm', userId: '', plan: 'free' }).limit).toBe(Infinity)
})
})
describe('explainPlan — a decision you can answer for', () => {
const gating: SuiteGating = {
plans: ['free', 'pro', 'enterprise'],
adminRole: 'admin',
}
const pro = { profile: { id: 'u1' }, entitlement: { plan: 'pro', status: 'active' } }
it('separates a genuine grant from a fail-open on an unknown plan', () => {
// meetsPlan returns true for both. Only one of them is a grant.
expect(meetsPlan(pro, 'pro', gating)).toBe(true)
expect(meetsPlan(pro, 'tpyo', gating)).toBe(true)
expect(explainPlan(pro, 'pro', gating).determination).toBe('pass')
const typo = explainPlan(pro, 'tpyo', gating)
expect(typo.determination).toBe('not-applicable')
expect(typo.reason).toContain('allowed by fail-open, NOT granted by the plan')
})
it('names the subscription status that caused a fallback', () => {
const cancelled = { profile: { id: 'u1' }, entitlement: { plan: 'pro', status: 'cancelled' } }
const x = explainPlan(cancelled, 'pro', gating)
expect(x.determination).toBe('fail')
expect(x.reason).toContain('"cancelled" is not an access-granting status')
expect(x.effectivePlan).toBe('free')
})
it('past_due keeps access, and says so as a pass', () => {
const pastDue = { profile: { id: 'u1' }, entitlement: { plan: 'pro', status: 'past_due' } }
expect(explainPlan(pastDue, 'pro', gating).determination).toBe('pass')
})
it('a signed-out principal is a denial, not an absence of assessment', () => {
expect(explainPlan(null, 'pro', gating).determination).toBe('fail')
})
it('cites the entitlement row behind each decision', () => {
const text = formatAccess(pro, [{ feature: 'bulk export', minPlan: 'enterprise' }], gating, 'u1', { evidence: 'full' })
expect(text).toContain('entitlements#u1 (plan)')
expect(text).toContain('entitlements#u1 (status)')
})
it('every conclusion carries a citation', () => {
const r = toReport(pro, [{ feature: 'bulk export', minPlan: 'enterprise' }], gating, 'u1')
expect(validateReport(r)).toEqual([])
})
})
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}