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
+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)
}