verifyhash 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +883 -0
- package/cli/abi/ContributionRegistry.json +881 -0
- package/cli/agent.js +2173 -0
- package/cli/anchor-artifact.js +853 -0
- package/cli/anchor.js +400 -0
- package/cli/claim.js +881 -0
- package/cli/core/agent-commit.js +448 -0
- package/cli/core/agent-session.js +598 -0
- package/cli/core/anchor-binding.js +663 -0
- package/cli/core/attestation.js +580 -0
- package/cli/core/evidence-plans.js +495 -0
- package/cli/core/fixtures/evidence-plans/baseline.json +19 -0
- package/cli/core/fulfill-intake.js +1082 -0
- package/cli/core/go-live-preflight.js +481 -0
- package/cli/core/license.js +534 -0
- package/cli/core/manifest.js +243 -0
- package/cli/core/packetseal.js +591 -0
- package/cli/core/registryArtifact.js +49 -0
- package/cli/core/revocation.js +539 -0
- package/cli/core/rfc3161.js +389 -0
- package/cli/core/timestamp.js +482 -0
- package/cli/core/trust-asof.js +479 -0
- package/cli/dataset.js +2950 -0
- package/cli/evidence.js +2227 -0
- package/cli/fulfill-webhook-http.js +438 -0
- package/cli/git.js +220 -0
- package/cli/hash.js +550 -0
- package/cli/identity.js +1072 -0
- package/cli/journal-cli.js +1110 -0
- package/cli/journal-log.js +454 -0
- package/cli/journal.js +334 -0
- package/cli/lineage.js +447 -0
- package/cli/list.js +287 -0
- package/cli/parcel.js +1509 -0
- package/cli/proof.js +578 -0
- package/cli/prove.js +300 -0
- package/cli/receipt.js +631 -0
- package/cli/registry.js +331 -0
- package/cli/reputation.js +344 -0
- package/cli/revocation.js +495 -0
- package/cli/serve-verify-http.js +298 -0
- package/cli/serve-verify.js +333 -0
- package/cli/show.js +339 -0
- package/cli/verify.js +383 -0
- package/cli/vh.js +3927 -0
- package/docs/ADOPT.md +183 -0
- package/docs/ADOPTION.json +11 -0
- package/docs/AGENTTRACE.md +247 -0
- package/docs/ANCHORING.md +167 -0
- package/docs/AUDIT.md +55 -0
- package/docs/CONFORMANCE.md +107 -0
- package/docs/DATALEDGER.md +638 -0
- package/docs/DECIDE.md +47 -0
- package/docs/DECISIONS-PENDING.md +27 -0
- package/docs/DEPLOY-PUBLIC-SITE.md +301 -0
- package/docs/ENGINE-LEDGER.json +12 -0
- package/docs/EVIDENCE.md +519 -0
- package/docs/GO-LIVE.md +66 -0
- package/docs/IDENTITY.md +123 -0
- package/docs/INDEPENDENT-VERIFICATION.md +377 -0
- package/docs/INTEGRITY-JOURNAL.md +337 -0
- package/docs/KEY-LIFECYCLE.md +179 -0
- package/docs/LICENSING.md +46 -0
- package/docs/LINEAGE.md +307 -0
- package/docs/LOOP-AUDIT-2026-07-03.json +580 -0
- package/docs/LOOP-HARDENING-PLAN.md +44 -0
- package/docs/MERKLE-LEAVES.md +113 -0
- package/docs/METRICS.jsonl +31 -0
- package/docs/MORNING.md +204 -0
- package/docs/PILOT.md +444 -0
- package/docs/PROOFPARCEL.md +227 -0
- package/docs/PROOFS.md +262 -0
- package/docs/RECEIPTS.md +341 -0
- package/docs/REPUTATION.md +158 -0
- package/docs/SDK.md +301 -0
- package/docs/STRATEGY-ARCHIVE.md +5055 -0
- package/docs/SUPERVISOR-RUNBOOK.md +52 -0
- package/docs/TRUST-BOUNDARIES.md +335 -0
- package/docs/TRUSTLEDGER.md +1976 -0
- package/docs/USAGE-BUDGET.json +121 -0
- package/docs/VERIFY-SERVICE.md +168 -0
- package/index.js +160 -0
- package/package.json +41 -0
- package/trustledger/build-standalone.js +796 -0
- package/trustledger/cli.js +3179 -0
- package/trustledger/close.js +391 -0
- package/trustledger/corpus.js +159 -0
- package/trustledger/dist/BUILD-PROVENANCE.json +99 -0
- package/trustledger/dist/trustledger-standalone.html +6197 -0
- package/trustledger/dist/trustledger-standalone.html.sha256 +1 -0
- package/trustledger/door-core.js +442 -0
- package/trustledger/fixtures/bank.csv +7 -0
- package/trustledger/fixtures/bank.malformed.csv +3 -0
- package/trustledger/fixtures/bank.noalias.csv +5 -0
- package/trustledger/fixtures/bank.ofx +34 -0
- package/trustledger/fixtures/bank.real.csv +5 -0
- package/trustledger/fixtures/corpus/_shared/prior-close.json +22 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/inputs.json +14 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/inputs.json +14 -0
- package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/continuity-break--benign-twin/inputs.json +15 -0
- package/trustledger/fixtures/corpus/continuity-break--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/continuity-break--out-of-trust/inputs.json +15 -0
- package/trustledger/fixtures/corpus/continuity-break--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/inputs.json +13 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/inputs.json +15 -0
- package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/inputs.json +15 -0
- package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/inputs.json +16 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/inputs.json +13 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/meta.json +7 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/inputs.json +13 -0
- package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/meta.json +7 -0
- package/trustledger/fixtures/e2e/bank.aliased.csv +4 -0
- package/trustledger/fixtures/e2e/bank.csv +4 -0
- package/trustledger/fixtures/e2e/bank.nsf.csv +4 -0
- package/trustledger/fixtures/e2e/quickbooks.csv +6 -0
- package/trustledger/fixtures/e2e/quickbooks.nsf.csv +8 -0
- package/trustledger/fixtures/e2e/rentroll.csv +6 -0
- package/trustledger/fixtures/e2e/rentroll.nsf.csv +8 -0
- package/trustledger/fixtures/e2e/rentroll.short.csv +5 -0
- package/trustledger/fixtures/plans/baseline.json +25 -0
- package/trustledger/fixtures/plans/price-binding.example.json +27 -0
- package/trustledger/fixtures/policy/ambiguous-deposit-example.json +12 -0
- package/trustledger/fixtures/policy/baseline.json +19 -0
- package/trustledger/fixtures/policy/ca-example.json +12 -0
- package/trustledger/fixtures/policy/negative-tenant-ledger-example.json +12 -0
- package/trustledger/fixtures/policy/owner-overdraw-example.json +12 -0
- package/trustledger/fixtures/quickbooks.csv +7 -0
- package/trustledger/fixtures/quickbooks.real.csv +5 -0
- package/trustledger/fixtures/rentroll.csv +6 -0
- package/trustledger/fixtures/rentroll.real.csv +4 -0
- package/trustledger/ingest.js +1163 -0
- package/trustledger/lib/policy-bundled-loader.js +44 -0
- package/trustledger/lib/sha256-vendored.js +227 -0
- package/trustledger/license.js +563 -0
- package/trustledger/match.js +551 -0
- package/trustledger/plans.js +551 -0
- package/trustledger/policy.js +398 -0
- package/trustledger/public/index.html +512 -0
- package/trustledger/reconcile.js +1486 -0
- package/trustledger/report.js +887 -0
- package/trustledger/seal.js +854 -0
- package/trustledger/server.js +391 -0
- package/trustledger/valueproof.js +350 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// trustledger/plans.js — THE PLAN CATALOG (T-37.1).
|
|
5
|
+
//
|
|
6
|
+
// A versioned, strictly-validated planId -> { entitlements, term } mapping over
|
|
7
|
+
// the CLOSED ENTITLEMENTS set. This is the SHIPPING price-list of what a
|
|
8
|
+
// customer can buy: each plan names the bundle of paid features (entitlements)
|
|
9
|
+
// it grants and the length of the subscription TERM. The mint side of the
|
|
10
|
+
// license flow (`vh trust license issue`) chooses a planId; the catalog is the
|
|
11
|
+
// single authoritative source for which entitlements that plan unlocks and for
|
|
12
|
+
// how long.
|
|
13
|
+
//
|
|
14
|
+
// WHY A CATALOG (vs. typing entitlements by hand).
|
|
15
|
+
// Issuing a license by hand-listing entitlement flags is error-prone: a typo
|
|
16
|
+
// silently under- or over-entitles a paying customer, and there is no record
|
|
17
|
+
// of what "pro-annual" actually means. A catalog pins each sellable plan to an
|
|
18
|
+
// EXACT, reviewed bundle, so issuing is "pick a planId" and the entitlements
|
|
19
|
+
// are derived — never re-typed.
|
|
20
|
+
//
|
|
21
|
+
// DESIGN PROPERTIES.
|
|
22
|
+
// * PURE / I-O-FREE / DETERMINISTIC. This module has NO filesystem, NO clock,
|
|
23
|
+
// NO network, NO ethers/key handling. `validatePlanCatalog` takes the parsed
|
|
24
|
+
// object as an argument (the caller reads the bundled JSON and passes it);
|
|
25
|
+
// `getPlan` is a pure lookup. The same inputs always produce byte-identical
|
|
26
|
+
// output, and a grep finds no fs / http / ethers-require / clock use.
|
|
27
|
+
// * STRICT. A malformed catalog (wrong kind, unsupported schemaVersion,
|
|
28
|
+
// empty/missing plans, a duplicate planId, an unknown/forged entitlement
|
|
29
|
+
// flag, an empty entitlement bundle, a non-positive/non-integer term, a
|
|
30
|
+
// missing displayName) raises a NAMED PlanCatalogError on the FIRST defect —
|
|
31
|
+
// never a silent pass, never a partial accept, never a coercion.
|
|
32
|
+
// * CLOSED ENTITLEMENTS, single source of truth. The set of entitlement flags a
|
|
33
|
+
// plan may grant is the license module's CLOSED table, imported as
|
|
34
|
+
// `license.ENTITLEMENT_FLAGS` — NOT a hard-coded copy. A flag not in that
|
|
35
|
+
// table is a hard reject, so a typo'd or forged entitlement can never enter a
|
|
36
|
+
// plan, and the catalog can never drift from the gate that honors it.
|
|
37
|
+
//
|
|
38
|
+
// HONEST POSTURE.
|
|
39
|
+
// A plan is an ACCESS DESCRIPTION for delivered software value: which features
|
|
40
|
+
// the subscription unlocks and for how long. It is NOT a token, NOT tradeable,
|
|
41
|
+
// NOT an appreciating asset, and the catalog makes NO claim of regulatory
|
|
42
|
+
// compliance. The actual subscription agreement governs; this file only maps a
|
|
43
|
+
// purchased plan to the features it entitles.
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
const license = require("./license");
|
|
47
|
+
|
|
48
|
+
// The catalog has its OWN `kind`/`schemaVersion`, disjoint from the license /
|
|
49
|
+
// seal / policy payloads, so a catalog can never be mistaken for one of them.
|
|
50
|
+
// `validatePlanCatalog` REJECTS any unsupported version rather than guessing.
|
|
51
|
+
const PLAN_CATALOG_KIND = "trustledger-plan-catalog";
|
|
52
|
+
const PLAN_CATALOG_SCHEMA_VERSION = 1;
|
|
53
|
+
const SUPPORTED_PLAN_CATALOG_SCHEMA_VERSIONS = Object.freeze([1]);
|
|
54
|
+
|
|
55
|
+
// The SINGLE source of valid entitlement flags: the license module's CLOSED
|
|
56
|
+
// table, imported (never re-declared) so the catalog and the gate that honors a
|
|
57
|
+
// license can never drift. `ENTITLEMENT_FLAGS` is already the frozen, sorted key
|
|
58
|
+
// list of `license.ENTITLEMENTS`.
|
|
59
|
+
//
|
|
60
|
+
// We expose the closed table as a GENUINELY IMMUTABLE frozen array — NOT a Set:
|
|
61
|
+
// `Object.freeze(new Set(...))` does NOT stop `.add()`, so a frozen Set is a
|
|
62
|
+
// no-op lock and the security-critical closed table could be WIDENED in-process
|
|
63
|
+
// (a forged flag injected, then honored at validation). A frozen array cannot be
|
|
64
|
+
// extended (`.push`/index-set throw under "use strict"), so the exported closed
|
|
65
|
+
// set cannot grow, and the membership gate below derives from this same frozen
|
|
66
|
+
// source so the two can never drift.
|
|
67
|
+
const ALLOWED_ENTITLEMENT_FLAGS = Object.freeze([...license.ENTITLEMENT_FLAGS]);
|
|
68
|
+
|
|
69
|
+
// A private, frozen null-prototype membership index for O(1) lookup, derived from
|
|
70
|
+
// the SAME frozen array. Not exported (callers see the frozen array), and frozen +
|
|
71
|
+
// null-proto so it can neither be widened nor polluted via __proto__.
|
|
72
|
+
const _ALLOWED_FLAG_INDEX = Object.freeze(
|
|
73
|
+
ALLOWED_ENTITLEMENT_FLAGS.reduce((m, f) => {
|
|
74
|
+
m[f] = true;
|
|
75
|
+
return m;
|
|
76
|
+
}, Object.create(null))
|
|
77
|
+
);
|
|
78
|
+
function _isAllowedEntitlement(flag) {
|
|
79
|
+
return Object.prototype.hasOwnProperty.call(_ALLOWED_FLAG_INDEX, flag);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
class PlanCatalogError extends Error {
|
|
83
|
+
constructor(message) {
|
|
84
|
+
super(message);
|
|
85
|
+
this.name = "PlanCatalogError";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// validatePlanCatalog(obj) -> validated, deeply-FROZEN catalog
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
//
|
|
93
|
+
// Strictly validates and returns a NEW deeply-frozen, canonical catalog object.
|
|
94
|
+
// Throws PlanCatalogError on the FIRST defect. Never mutates the input. PURE:
|
|
95
|
+
// the caller parses the JSON (or passes an object) and hands it in.
|
|
96
|
+
function validatePlanCatalog(obj) {
|
|
97
|
+
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
|
|
98
|
+
throw new PlanCatalogError("plan catalog must be a JSON object");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- kind: must be exactly the catalog kind ------------------------------
|
|
102
|
+
if (obj.kind !== PLAN_CATALOG_KIND) {
|
|
103
|
+
throw new PlanCatalogError(
|
|
104
|
+
`plan catalog has wrong kind ${JSON.stringify(obj.kind)}; ` +
|
|
105
|
+
`expected ${JSON.stringify(PLAN_CATALOG_KIND)}`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---- schemaVersion: present and exactly a supported integer --------------
|
|
110
|
+
if (!Object.prototype.hasOwnProperty.call(obj, "schemaVersion")) {
|
|
111
|
+
throw new PlanCatalogError("plan catalog is missing required field: schemaVersion");
|
|
112
|
+
}
|
|
113
|
+
if (!SUPPORTED_PLAN_CATALOG_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
|
|
114
|
+
throw new PlanCatalogError(
|
|
115
|
+
`unsupported plan catalog schemaVersion ${JSON.stringify(obj.schemaVersion)}; ` +
|
|
116
|
+
`this build understands: ${SUPPORTED_PLAN_CATALOG_SCHEMA_VERSIONS.join(", ")}`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- plans: a non-empty array of plan entries ----------------------------
|
|
121
|
+
if (!Object.prototype.hasOwnProperty.call(obj, "plans")) {
|
|
122
|
+
throw new PlanCatalogError("plan catalog is missing required field: plans");
|
|
123
|
+
}
|
|
124
|
+
if (!Array.isArray(obj.plans)) {
|
|
125
|
+
throw new PlanCatalogError("plan catalog plans must be an array");
|
|
126
|
+
}
|
|
127
|
+
if (obj.plans.length === 0) {
|
|
128
|
+
throw new PlanCatalogError("plan catalog plans must be a non-empty array");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Validate each plan; collect into a planId -> frozen-plan map, rejecting a
|
|
132
|
+
// duplicate planId (an ambiguous catalog is a hard error, never a last-wins).
|
|
133
|
+
const byId = new Map();
|
|
134
|
+
for (let i = 0; i < obj.plans.length; i++) {
|
|
135
|
+
const plan = validatePlan(obj.plans[i], i);
|
|
136
|
+
if (byId.has(plan.planId)) {
|
|
137
|
+
throw new PlanCatalogError(
|
|
138
|
+
`plan catalog has duplicate planId ${JSON.stringify(plan.planId)}`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
byId.set(plan.planId, plan);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Build a canonical, deeply-frozen catalog. The plans array is emitted in
|
|
145
|
+
// planId-sorted order so the catalog's enumeration is deterministic regardless
|
|
146
|
+
// of input order; `plansById` is a frozen lookup map for getPlan.
|
|
147
|
+
const sortedIds = [...byId.keys()].sort();
|
|
148
|
+
const plans = Object.freeze(sortedIds.map((id) => byId.get(id)));
|
|
149
|
+
const plansById = Object.freeze(
|
|
150
|
+
sortedIds.reduce((m, id) => {
|
|
151
|
+
m[id] = byId.get(id);
|
|
152
|
+
return m;
|
|
153
|
+
}, Object.create(null))
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
return Object.freeze({
|
|
157
|
+
kind: PLAN_CATALOG_KIND,
|
|
158
|
+
schemaVersion: obj.schemaVersion,
|
|
159
|
+
plans,
|
|
160
|
+
plansById,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// validatePlan(plan, index) -> a frozen, canonical plan
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
//
|
|
168
|
+
// Strictly validates ONE plan entry. `index` is woven into the error so a defect
|
|
169
|
+
// is locatable. Throws PlanCatalogError on the first problem.
|
|
170
|
+
function validatePlan(plan, index) {
|
|
171
|
+
const at = `plan[${index}]`;
|
|
172
|
+
if (plan === null || typeof plan !== "object" || Array.isArray(plan)) {
|
|
173
|
+
throw new PlanCatalogError(`${at} must be an object`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- planId: a non-empty string handle -----------------------------------
|
|
177
|
+
if (typeof plan.planId !== "string" || plan.planId.trim() === "") {
|
|
178
|
+
throw new PlanCatalogError(`${at}.planId must be a non-empty string`);
|
|
179
|
+
}
|
|
180
|
+
const planId = plan.planId;
|
|
181
|
+
|
|
182
|
+
// ---- displayName: a non-empty human label (required) ---------------------
|
|
183
|
+
if (typeof plan.displayName !== "string" || plan.displayName.trim() === "") {
|
|
184
|
+
throw new PlanCatalogError(
|
|
185
|
+
`plan ${JSON.stringify(planId)} is missing a non-empty displayName`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---- entitlements: a non-empty closed set drawn ONLY from the license
|
|
190
|
+
// module's CLOSED table; no unknown flag, no duplicate -----------------
|
|
191
|
+
if (!Array.isArray(plan.entitlements)) {
|
|
192
|
+
throw new PlanCatalogError(
|
|
193
|
+
`plan ${JSON.stringify(planId)} entitlements must be an array`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
if (plan.entitlements.length === 0) {
|
|
197
|
+
throw new PlanCatalogError(
|
|
198
|
+
`plan ${JSON.stringify(planId)} entitlements must be a non-empty array`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const seen = new Set();
|
|
202
|
+
for (const flag of plan.entitlements) {
|
|
203
|
+
if (typeof flag !== "string") {
|
|
204
|
+
throw new PlanCatalogError(
|
|
205
|
+
`plan ${JSON.stringify(planId)} has a non-string entitlement ${JSON.stringify(flag)}`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (!_isAllowedEntitlement(flag)) {
|
|
209
|
+
throw new PlanCatalogError(
|
|
210
|
+
`plan ${JSON.stringify(planId)} has unknown entitlement ${JSON.stringify(flag)}; ` +
|
|
211
|
+
`closed set is: ${license.ENTITLEMENT_FLAGS.join(", ")}`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
if (seen.has(flag)) {
|
|
215
|
+
throw new PlanCatalogError(
|
|
216
|
+
`plan ${JSON.stringify(planId)} has duplicate entitlement ${JSON.stringify(flag)}`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
seen.add(flag);
|
|
220
|
+
}
|
|
221
|
+
// Emit entitlements in the CLOSED-table sort order regardless of input order,
|
|
222
|
+
// so a plan's serialization is byte-deterministic and order-independent.
|
|
223
|
+
const entitlements = Object.freeze([...seen].sort());
|
|
224
|
+
|
|
225
|
+
// ---- termDays: a positive INTEGER number of days -------------------------
|
|
226
|
+
// Integer cents-style discipline: a non-integer or non-positive term is a hard
|
|
227
|
+
// reject, never rounded or coerced.
|
|
228
|
+
if (!Object.prototype.hasOwnProperty.call(plan, "termDays")) {
|
|
229
|
+
throw new PlanCatalogError(
|
|
230
|
+
`plan ${JSON.stringify(planId)} is missing required field: termDays`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (typeof plan.termDays !== "number" || !Number.isInteger(plan.termDays)) {
|
|
234
|
+
throw new PlanCatalogError(
|
|
235
|
+
`plan ${JSON.stringify(planId)} termDays must be an integer; got ${JSON.stringify(plan.termDays)}`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
if (plan.termDays <= 0) {
|
|
239
|
+
throw new PlanCatalogError(
|
|
240
|
+
`plan ${JSON.stringify(planId)} termDays must be a positive integer; got ${JSON.stringify(plan.termDays)}`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return Object.freeze({
|
|
245
|
+
planId,
|
|
246
|
+
displayName: plan.displayName,
|
|
247
|
+
entitlements,
|
|
248
|
+
termDays: plan.termDays,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// getPlan(catalog, planId) -> the frozen plan, or throws on an unknown id
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
//
|
|
256
|
+
// PURE lookup against a VALIDATED catalog. Returns the frozen plan for a known
|
|
257
|
+
// id; throws a NAMED PlanCatalogError for an unknown id (never returns undefined
|
|
258
|
+
// — an unknown plan is an error, not an empty entitlement).
|
|
259
|
+
function getPlan(catalog, planId) {
|
|
260
|
+
if (
|
|
261
|
+
catalog === null ||
|
|
262
|
+
typeof catalog !== "object" ||
|
|
263
|
+
catalog.plansById === null ||
|
|
264
|
+
typeof catalog.plansById !== "object"
|
|
265
|
+
) {
|
|
266
|
+
throw new PlanCatalogError("getPlan requires a validated plan catalog");
|
|
267
|
+
}
|
|
268
|
+
if (typeof planId !== "string" || planId.trim() === "") {
|
|
269
|
+
throw new PlanCatalogError("getPlan requires a non-empty planId");
|
|
270
|
+
}
|
|
271
|
+
if (!Object.prototype.hasOwnProperty.call(catalog.plansById, planId)) {
|
|
272
|
+
const known = Object.keys(catalog.plansById).sort().join(", ");
|
|
273
|
+
throw new PlanCatalogError(
|
|
274
|
+
`unknown planId ${JSON.stringify(planId)}; known plans are: ${known}`
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
return catalog.plansById[planId];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ===========================================================================
|
|
281
|
+
// THE PRICE -> PLAN BINDING (T-38.1).
|
|
282
|
+
//
|
|
283
|
+
// A versioned, strictly-validated `(provider, priceId) -> planId` routing table
|
|
284
|
+
// over the SAME plan catalog. A billing provider's "payment succeeded / renewed"
|
|
285
|
+
// webhook does NOT carry our planId — it carries the PROVIDER'S own price/product
|
|
286
|
+
// id (e.g. a Stripe `price_...` id) plus a customer + a period-end epoch. The
|
|
287
|
+
// fulfillment handler must MAP that price id onto one of THIS catalog's plans
|
|
288
|
+
// BEFORE it mints a license, or a typo silently grants the wrong PLAN — the exact
|
|
289
|
+
// silent-mis-grant class the catalog closed for ENTITLEMENTS but left open one
|
|
290
|
+
// level up. This binding closes it: a price id can ONLY point at a planId the
|
|
291
|
+
// SUPPLIED CATALOG actually defines (validated here, at load time — never deferred
|
|
292
|
+
// to fulfill time), and an unmapped `(provider, priceId)` is a NAMED reject, never
|
|
293
|
+
// a silent pass.
|
|
294
|
+
//
|
|
295
|
+
// DESIGN PROPERTIES (identical posture to validatePlanCatalog).
|
|
296
|
+
// * PURE / I-O-FREE / DETERMINISTIC. No filesystem, clock, network, or ethers.
|
|
297
|
+
// `validatePriceBinding(obj, catalog)` takes the parsed object AND a VALIDATED
|
|
298
|
+
// catalog as arguments (the caller does the I/O); `resolvePlanId` is a pure
|
|
299
|
+
// lookup. The same inputs always produce byte-identical output.
|
|
300
|
+
// * STRICT. A malformed binding (wrong kind, unsupported schemaVersion,
|
|
301
|
+
// empty/missing mappings, a duplicate (provider, priceId), a missing/blank
|
|
302
|
+
// provider or priceId, or a planId NOT present in the supplied catalog) raises
|
|
303
|
+
// a NAMED PriceBindingError on the FIRST defect — never a silent pass.
|
|
304
|
+
// * THE CATALOG IS THE SINGLE SOURCE OF VALID planIds. A mapping pointing at a
|
|
305
|
+
// planId the catalog does not define is REJECTED at validation time (via
|
|
306
|
+
// getPlan), so a price can NEVER resolve to a non-existent plan downstream.
|
|
307
|
+
//
|
|
308
|
+
// HONEST POSTURE.
|
|
309
|
+
// This binding is an OPERATOR-MAINTAINED routing table, NOT a token / tradeable
|
|
310
|
+
// / appreciating asset, and makes NO claim of regulatory compliance. It does NOT
|
|
311
|
+
// authenticate the inbound webhook — verifying the provider's signing secret is
|
|
312
|
+
// a HUMAN step; this module only maps an already-authenticated event's price id
|
|
313
|
+
// onto a plan. The actual subscription agreement governs.
|
|
314
|
+
// ===========================================================================
|
|
315
|
+
|
|
316
|
+
// The binding has its OWN kind/schemaVersion, disjoint from the catalog / license
|
|
317
|
+
// / seal payloads, so a binding can never be mistaken for one of them.
|
|
318
|
+
const PRICE_BINDING_KIND = "trustledger-price-binding";
|
|
319
|
+
const PRICE_BINDING_SCHEMA_VERSION = 1;
|
|
320
|
+
const SUPPORTED_PRICE_BINDING_SCHEMA_VERSIONS = Object.freeze([1]);
|
|
321
|
+
|
|
322
|
+
class PriceBindingError extends Error {
|
|
323
|
+
constructor(message) {
|
|
324
|
+
super(message);
|
|
325
|
+
this.name = "PriceBindingError";
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// The composite key under which a mapping (and a lookup) is addressed. provider +
|
|
330
|
+
// priceId are joined with a separator that cannot appear in a validated provider
|
|
331
|
+
// (validation forbids it), so two distinct (provider, priceId) pairs can never
|
|
332
|
+
// collide onto the same key.
|
|
333
|
+
const _BINDING_KEY_SEP = "\u0000"; // NUL — forbidden in a provider/priceId below.
|
|
334
|
+
function _bindingKey(provider, priceId) {
|
|
335
|
+
return `${provider}${_BINDING_KEY_SEP}${priceId}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
// validatePriceBinding(obj, catalog) -> validated, deeply-FROZEN binding
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
//
|
|
342
|
+
// Strictly validates and returns a NEW deeply-frozen, canonical binding object.
|
|
343
|
+
// `catalog` MUST be a catalog already validated by validatePlanCatalog — it is the
|
|
344
|
+
// SINGLE source of valid planIds: every mapping's planId is checked against it (via
|
|
345
|
+
// getPlan) at validation time, so a binding can never point at a non-existent plan.
|
|
346
|
+
// Throws PriceBindingError on the FIRST defect. Never mutates either input. PURE.
|
|
347
|
+
function validatePriceBinding(obj, catalog) {
|
|
348
|
+
// The catalog is REQUIRED and must be a validated catalog (it is the authority
|
|
349
|
+
// for valid planIds). We probe its shape rather than re-validating it.
|
|
350
|
+
if (
|
|
351
|
+
catalog === null ||
|
|
352
|
+
typeof catalog !== "object" ||
|
|
353
|
+
catalog.plansById === null ||
|
|
354
|
+
typeof catalog.plansById !== "object"
|
|
355
|
+
) {
|
|
356
|
+
throw new PriceBindingError(
|
|
357
|
+
"validatePriceBinding requires a validated plan catalog (the single source of valid planIds)"
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
|
|
362
|
+
throw new PriceBindingError("price binding must be a JSON object");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---- kind: must be exactly the binding kind ------------------------------
|
|
366
|
+
if (obj.kind !== PRICE_BINDING_KIND) {
|
|
367
|
+
throw new PriceBindingError(
|
|
368
|
+
`price binding has wrong kind ${JSON.stringify(obj.kind)}; ` +
|
|
369
|
+
`expected ${JSON.stringify(PRICE_BINDING_KIND)}`
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---- schemaVersion: present and exactly a supported integer --------------
|
|
374
|
+
if (!Object.prototype.hasOwnProperty.call(obj, "schemaVersion")) {
|
|
375
|
+
throw new PriceBindingError("price binding is missing required field: schemaVersion");
|
|
376
|
+
}
|
|
377
|
+
if (!SUPPORTED_PRICE_BINDING_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
|
|
378
|
+
throw new PriceBindingError(
|
|
379
|
+
`unsupported price binding schemaVersion ${JSON.stringify(obj.schemaVersion)}; ` +
|
|
380
|
+
`this build understands: ${SUPPORTED_PRICE_BINDING_SCHEMA_VERSIONS.join(", ")}`
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ---- mappings: a non-empty array of mapping entries ----------------------
|
|
385
|
+
if (!Object.prototype.hasOwnProperty.call(obj, "mappings")) {
|
|
386
|
+
throw new PriceBindingError("price binding is missing required field: mappings");
|
|
387
|
+
}
|
|
388
|
+
if (!Array.isArray(obj.mappings)) {
|
|
389
|
+
throw new PriceBindingError("price binding mappings must be an array");
|
|
390
|
+
}
|
|
391
|
+
if (obj.mappings.length === 0) {
|
|
392
|
+
throw new PriceBindingError("price binding mappings must be a non-empty array");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Validate each mapping; collect into a (provider,priceId) -> frozen-mapping
|
|
396
|
+
// map, rejecting a duplicate composite key (an ambiguous routing table is a hard
|
|
397
|
+
// error, never a last-wins). Every planId is checked against the catalog.
|
|
398
|
+
const byKey = new Map();
|
|
399
|
+
for (let i = 0; i < obj.mappings.length; i++) {
|
|
400
|
+
const mapping = validateMapping(obj.mappings[i], i, catalog);
|
|
401
|
+
const key = _bindingKey(mapping.provider, mapping.priceId);
|
|
402
|
+
if (byKey.has(key)) {
|
|
403
|
+
throw new PriceBindingError(
|
|
404
|
+
`price binding has duplicate (provider, priceId) ` +
|
|
405
|
+
`(${JSON.stringify(mapping.provider)}, ${JSON.stringify(mapping.priceId)})`
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
byKey.set(key, mapping);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Build a canonical, deeply-frozen binding. The mappings array is emitted in
|
|
412
|
+
// (provider, priceId)-sorted order so the binding's enumeration is deterministic
|
|
413
|
+
// regardless of input order; `byKey` is a frozen lookup map for resolvePlanId.
|
|
414
|
+
const sortedKeys = [...byKey.keys()].sort();
|
|
415
|
+
const mappings = Object.freeze(sortedKeys.map((k) => byKey.get(k)));
|
|
416
|
+
const byKeyObj = Object.freeze(
|
|
417
|
+
sortedKeys.reduce((m, k) => {
|
|
418
|
+
m[k] = byKey.get(k);
|
|
419
|
+
return m;
|
|
420
|
+
}, Object.create(null))
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
// Assemble the public binding (the ENUMERABLE, serializable surface) first, then
|
|
424
|
+
// attach the internal lookup index as a TRULY non-enumerable property before
|
|
425
|
+
// freezing. `_byKey` is the NUL-keyed (provider,priceId) -> mapping index used
|
|
426
|
+
// ONLY by resolvePlanId; defining it non-enumerable keeps it off the public,
|
|
427
|
+
// serialized surface, so Object.keys / JSON.stringify of a binding emit exactly
|
|
428
|
+
// {kind, schemaVersion, mappings} and the raw NUL-separated keys never leak.
|
|
429
|
+
const result = {
|
|
430
|
+
kind: PRICE_BINDING_KIND,
|
|
431
|
+
schemaVersion: obj.schemaVersion,
|
|
432
|
+
mappings,
|
|
433
|
+
};
|
|
434
|
+
Object.defineProperty(result, "_byKey", {
|
|
435
|
+
value: byKeyObj,
|
|
436
|
+
enumerable: false,
|
|
437
|
+
writable: false,
|
|
438
|
+
configurable: false,
|
|
439
|
+
});
|
|
440
|
+
return Object.freeze(result);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// validateMapping(mapping, index, catalog) -> a frozen, canonical mapping
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
//
|
|
447
|
+
// Strictly validates ONE mapping entry. `index` is woven into the error so a defect
|
|
448
|
+
// is locatable. `catalog` is the authority for the planId. Throws on the first
|
|
449
|
+
// problem.
|
|
450
|
+
function validateMapping(mapping, index, catalog) {
|
|
451
|
+
const at = `mapping[${index}]`;
|
|
452
|
+
if (mapping === null || typeof mapping !== "object" || Array.isArray(mapping)) {
|
|
453
|
+
throw new PriceBindingError(`${at} must be an object`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ---- provider: a non-empty string with no NUL (the key separator) --------
|
|
457
|
+
if (typeof mapping.provider !== "string" || mapping.provider.trim() === "") {
|
|
458
|
+
throw new PriceBindingError(`${at}.provider must be a non-empty string`);
|
|
459
|
+
}
|
|
460
|
+
if (mapping.provider.includes(_BINDING_KEY_SEP)) {
|
|
461
|
+
throw new PriceBindingError(`${at}.provider must not contain a NUL character`);
|
|
462
|
+
}
|
|
463
|
+
const provider = mapping.provider;
|
|
464
|
+
|
|
465
|
+
// ---- priceId: a non-empty string with no NUL -----------------------------
|
|
466
|
+
if (typeof mapping.priceId !== "string" || mapping.priceId.trim() === "") {
|
|
467
|
+
throw new PriceBindingError(
|
|
468
|
+
`${at} (provider ${JSON.stringify(provider)}) priceId must be a non-empty string`
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
if (mapping.priceId.includes(_BINDING_KEY_SEP)) {
|
|
472
|
+
throw new PriceBindingError(
|
|
473
|
+
`${at} (provider ${JSON.stringify(provider)}) priceId must not contain a NUL character`
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
const priceId = mapping.priceId;
|
|
477
|
+
|
|
478
|
+
// ---- planId: a non-empty string that the SUPPLIED CATALOG defines --------
|
|
479
|
+
// getPlan is the authority: it throws a NAMED PlanCatalogError for an unknown id.
|
|
480
|
+
// We re-wrap it as a PriceBindingError so the binding's error TYPE is uniform and
|
|
481
|
+
// the message names the offending (provider, priceId).
|
|
482
|
+
if (typeof mapping.planId !== "string" || mapping.planId.trim() === "") {
|
|
483
|
+
throw new PriceBindingError(
|
|
484
|
+
`${at} (provider ${JSON.stringify(provider)}, priceId ${JSON.stringify(priceId)}) ` +
|
|
485
|
+
`planId must be a non-empty string`
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
const planId = mapping.planId;
|
|
489
|
+
try {
|
|
490
|
+
getPlan(catalog, planId);
|
|
491
|
+
} catch (e) {
|
|
492
|
+
const known = Object.keys(catalog.plansById).sort().join(", ");
|
|
493
|
+
throw new PriceBindingError(
|
|
494
|
+
`${at} (provider ${JSON.stringify(provider)}, priceId ${JSON.stringify(priceId)}) ` +
|
|
495
|
+
`points at planId ${JSON.stringify(planId)} which is NOT in the supplied catalog; ` +
|
|
496
|
+
`known plans are: ${known}`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return Object.freeze({ provider, priceId, planId });
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
// resolvePlanId(binding, provider, priceId) -> the bound planId, or throws
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
//
|
|
507
|
+
// PURE lookup against a VALIDATED binding. Returns the bound planId for a known
|
|
508
|
+
// (provider, priceId); throws a NAMED PriceBindingError NAMING both the provider and
|
|
509
|
+
// the priceId for an unmapped pair (never returns undefined — an unmapped price is
|
|
510
|
+
// an error, not a silent mis-grant).
|
|
511
|
+
function resolvePlanId(binding, provider, priceId) {
|
|
512
|
+
if (
|
|
513
|
+
binding === null ||
|
|
514
|
+
typeof binding !== "object" ||
|
|
515
|
+
binding._byKey === null ||
|
|
516
|
+
typeof binding._byKey !== "object"
|
|
517
|
+
) {
|
|
518
|
+
throw new PriceBindingError("resolvePlanId requires a validated price binding");
|
|
519
|
+
}
|
|
520
|
+
if (typeof provider !== "string" || provider.trim() === "") {
|
|
521
|
+
throw new PriceBindingError("resolvePlanId requires a non-empty provider");
|
|
522
|
+
}
|
|
523
|
+
if (typeof priceId !== "string" || priceId.trim() === "") {
|
|
524
|
+
throw new PriceBindingError("resolvePlanId requires a non-empty priceId");
|
|
525
|
+
}
|
|
526
|
+
const key = _bindingKey(provider, priceId);
|
|
527
|
+
if (!Object.prototype.hasOwnProperty.call(binding._byKey, key)) {
|
|
528
|
+
throw new PriceBindingError(
|
|
529
|
+
`no plan bound for (provider ${JSON.stringify(provider)}, ` +
|
|
530
|
+
`priceId ${JSON.stringify(priceId)}); the price binding has no such mapping`
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
return binding._byKey[key].planId;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
module.exports = {
|
|
537
|
+
PLAN_CATALOG_KIND,
|
|
538
|
+
PLAN_CATALOG_SCHEMA_VERSION,
|
|
539
|
+
SUPPORTED_PLAN_CATALOG_SCHEMA_VERSIONS,
|
|
540
|
+
ALLOWED_ENTITLEMENT_FLAGS,
|
|
541
|
+
PlanCatalogError,
|
|
542
|
+
validatePlanCatalog,
|
|
543
|
+
getPlan,
|
|
544
|
+
// T-38.1: the price -> plan binding over the same catalog.
|
|
545
|
+
PRICE_BINDING_KIND,
|
|
546
|
+
PRICE_BINDING_SCHEMA_VERSION,
|
|
547
|
+
SUPPORTED_PRICE_BINDING_SCHEMA_VERSIONS,
|
|
548
|
+
PriceBindingError,
|
|
549
|
+
validatePriceBinding,
|
|
550
|
+
resolvePlanId,
|
|
551
|
+
};
|