From 094d43c1e4be9f3556e52eb385a532c6c3fbb7d2 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Mon, 28 Sep 2026 14:54:59 -0400 Subject: [PATCH] First public release Co-Authored-By: Claude Opus 5.5 --- .editorconfig | 12 + .gitignore | 9 + .gitlab-ci.yml | 6 + CHANGELOG.md | 16 + CONTRIBUTING.md | 22 + LICENSE | 21 + README.md | 121 ++++ SECURITY.md | 6 + package-lock.json | 1522 +++++++++++++++++++++++++++++++++++++++ package.json | 44 ++ src/entitlements.ts | 111 +++ src/index.ts | 5 + src/limiter.ts | 177 +++++ src/quota.ts | 91 +++ src/report.ts | 132 ++++ test/scope-gate.test.ts | 297 ++++++++ tsconfig.json | 14 + 17 files changed, 2606 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/entitlements.ts create mode 100644 src/index.ts create mode 100644 src/limiter.ts create mode 100644 src/quota.ts create mode 100644 src/report.ts create mode 100644 test/scope-gate.test.ts create mode 100644 tsconfig.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f15441a --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d6cf0b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +*.log +.DS_Store +.env* +!.env.example +.npmrc +dist/ +dist-cjs/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..e5a37d2 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,6 @@ +stages: [test] + +test: + stage: test + image: node:22-alpine + script: [npm ci, npm run build, npm test] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..18388a2 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2f41d50 --- /dev/null +++ b/CONTRIBUTING.md @@ -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 +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..34e49b1 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a00079f --- /dev/null +++ b/README.md @@ -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_` globally, and `RATE_LIMIT_WRITES__` +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). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b88987e --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..185311f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1522 @@ +{ + "name": "@extant2000/scope-gate", + "version": "0.3.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@extant2000/scope-gate", + "version": "0.3.0", + "license": "MIT", + "dependencies": { + "@extant2000/evidence-record": "^0.1.2" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@extant2000/evidence-record": { + "version": "0.1.2", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", + "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz", + "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz", + "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz", + "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz", + "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz", + "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz", + "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz", + "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz", + "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz", + "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz", + "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz", + "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz", + "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz", + "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz", + "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz", + "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz", + "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.5.tgz", + "integrity": "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz", + "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz", + "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz", + "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz", + "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz", + "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.5.tgz", + "integrity": "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.5.tgz", + "integrity": "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", + "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz", + "integrity": "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.5", + "@rollup/rollup-android-arm64": "4.63.5", + "@rollup/rollup-darwin-arm64": "4.63.5", + "@rollup/rollup-darwin-x64": "4.63.5", + "@rollup/rollup-freebsd-arm64": "4.63.5", + "@rollup/rollup-freebsd-x64": "4.63.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", + "@rollup/rollup-linux-arm-musleabihf": "4.63.5", + "@rollup/rollup-linux-arm64-gnu": "4.63.5", + "@rollup/rollup-linux-arm64-musl": "4.63.5", + "@rollup/rollup-linux-loong64-gnu": "4.63.5", + "@rollup/rollup-linux-loong64-musl": "4.63.5", + "@rollup/rollup-linux-ppc64-gnu": "4.63.5", + "@rollup/rollup-linux-ppc64-musl": "4.63.5", + "@rollup/rollup-linux-riscv64-gnu": "4.63.5", + "@rollup/rollup-linux-riscv64-musl": "4.63.5", + "@rollup/rollup-linux-s390x-gnu": "4.63.5", + "@rollup/rollup-linux-x64-gnu": "4.63.5", + "@rollup/rollup-linux-x64-musl": "4.63.5", + "@rollup/rollup-openbsd-x64": "4.63.5", + "@rollup/rollup-openharmony-arm64": "4.63.5", + "@rollup/rollup-win32-arm64-msvc": "4.63.5", + "@rollup/rollup-win32-ia32-msvc": "4.63.5", + "@rollup/rollup-win32-x64-gnu": "4.63.5", + "@rollup/rollup-win32-x64-msvc": "4.63.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3d012db --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/entitlements.ts b/src/entitlements.ts new file mode 100644 index 0000000..27eda2a --- /dev/null +++ b/src/entitlements.ts @@ -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 + /** 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. + profile?: Record | 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 +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..01cc933 --- /dev/null +++ b/src/index.ts @@ -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' diff --git a/src/limiter.ts b/src/limiter.ts new file mode 100644 index 0000000..13e41dc --- /dev/null +++ b/src/limiter.ts @@ -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() + private gc: ReturnType | 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 { + 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) + } +} diff --git a/src/quota.ts b/src/quota.ts new file mode 100644 index 0000000..8b86963 --- /dev/null +++ b/src/quota.ts @@ -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> = 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 + /** + * 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 +} + +function defaultEnv(): Record { + 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_ (global) + * RATE_LIMIT_WRITES__ (per product, wins) + */ +function envQuota( + plan: string, + env: Record, + 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) +} diff --git a/src/report.ts b/src/report.ts new file mode 100644 index 0000000..3dcfe5a --- /dev/null +++ b/src/report.ts @@ -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) +} diff --git a/test/scope-gate.test.ts b/test/scope-gate.test.ts new file mode 100644 index 0000000..9d41692 --- /dev/null +++ b/test/scope-gate.test.ts @@ -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([]) + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f755f43 --- /dev/null +++ b/tsconfig.json @@ -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"] +}