First public release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user