First public release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
.npmrc
|
||||||
|
dist/
|
||||||
|
dist-cjs/
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
stages: [test]
|
||||||
|
|
||||||
|
test:
|
||||||
|
stage: test
|
||||||
|
image: node:22-alpine
|
||||||
|
script: [npm ci, npm run build, npm test]
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
Generated
+1522
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user