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.
Files changed (154) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +883 -0
  3. package/cli/abi/ContributionRegistry.json +881 -0
  4. package/cli/agent.js +2173 -0
  5. package/cli/anchor-artifact.js +853 -0
  6. package/cli/anchor.js +400 -0
  7. package/cli/claim.js +881 -0
  8. package/cli/core/agent-commit.js +448 -0
  9. package/cli/core/agent-session.js +598 -0
  10. package/cli/core/anchor-binding.js +663 -0
  11. package/cli/core/attestation.js +580 -0
  12. package/cli/core/evidence-plans.js +495 -0
  13. package/cli/core/fixtures/evidence-plans/baseline.json +19 -0
  14. package/cli/core/fulfill-intake.js +1082 -0
  15. package/cli/core/go-live-preflight.js +481 -0
  16. package/cli/core/license.js +534 -0
  17. package/cli/core/manifest.js +243 -0
  18. package/cli/core/packetseal.js +591 -0
  19. package/cli/core/registryArtifact.js +49 -0
  20. package/cli/core/revocation.js +539 -0
  21. package/cli/core/rfc3161.js +389 -0
  22. package/cli/core/timestamp.js +482 -0
  23. package/cli/core/trust-asof.js +479 -0
  24. package/cli/dataset.js +2950 -0
  25. package/cli/evidence.js +2227 -0
  26. package/cli/fulfill-webhook-http.js +438 -0
  27. package/cli/git.js +220 -0
  28. package/cli/hash.js +550 -0
  29. package/cli/identity.js +1072 -0
  30. package/cli/journal-cli.js +1110 -0
  31. package/cli/journal-log.js +454 -0
  32. package/cli/journal.js +334 -0
  33. package/cli/lineage.js +447 -0
  34. package/cli/list.js +287 -0
  35. package/cli/parcel.js +1509 -0
  36. package/cli/proof.js +578 -0
  37. package/cli/prove.js +300 -0
  38. package/cli/receipt.js +631 -0
  39. package/cli/registry.js +331 -0
  40. package/cli/reputation.js +344 -0
  41. package/cli/revocation.js +495 -0
  42. package/cli/serve-verify-http.js +298 -0
  43. package/cli/serve-verify.js +333 -0
  44. package/cli/show.js +339 -0
  45. package/cli/verify.js +383 -0
  46. package/cli/vh.js +3927 -0
  47. package/docs/ADOPT.md +183 -0
  48. package/docs/ADOPTION.json +11 -0
  49. package/docs/AGENTTRACE.md +247 -0
  50. package/docs/ANCHORING.md +167 -0
  51. package/docs/AUDIT.md +55 -0
  52. package/docs/CONFORMANCE.md +107 -0
  53. package/docs/DATALEDGER.md +638 -0
  54. package/docs/DECIDE.md +47 -0
  55. package/docs/DECISIONS-PENDING.md +27 -0
  56. package/docs/DEPLOY-PUBLIC-SITE.md +301 -0
  57. package/docs/ENGINE-LEDGER.json +12 -0
  58. package/docs/EVIDENCE.md +519 -0
  59. package/docs/GO-LIVE.md +66 -0
  60. package/docs/IDENTITY.md +123 -0
  61. package/docs/INDEPENDENT-VERIFICATION.md +377 -0
  62. package/docs/INTEGRITY-JOURNAL.md +337 -0
  63. package/docs/KEY-LIFECYCLE.md +179 -0
  64. package/docs/LICENSING.md +46 -0
  65. package/docs/LINEAGE.md +307 -0
  66. package/docs/LOOP-AUDIT-2026-07-03.json +580 -0
  67. package/docs/LOOP-HARDENING-PLAN.md +44 -0
  68. package/docs/MERKLE-LEAVES.md +113 -0
  69. package/docs/METRICS.jsonl +31 -0
  70. package/docs/MORNING.md +204 -0
  71. package/docs/PILOT.md +444 -0
  72. package/docs/PROOFPARCEL.md +227 -0
  73. package/docs/PROOFS.md +262 -0
  74. package/docs/RECEIPTS.md +341 -0
  75. package/docs/REPUTATION.md +158 -0
  76. package/docs/SDK.md +301 -0
  77. package/docs/STRATEGY-ARCHIVE.md +5055 -0
  78. package/docs/SUPERVISOR-RUNBOOK.md +52 -0
  79. package/docs/TRUST-BOUNDARIES.md +335 -0
  80. package/docs/TRUSTLEDGER.md +1976 -0
  81. package/docs/USAGE-BUDGET.json +121 -0
  82. package/docs/VERIFY-SERVICE.md +168 -0
  83. package/index.js +160 -0
  84. package/package.json +41 -0
  85. package/trustledger/build-standalone.js +796 -0
  86. package/trustledger/cli.js +3179 -0
  87. package/trustledger/close.js +391 -0
  88. package/trustledger/corpus.js +159 -0
  89. package/trustledger/dist/BUILD-PROVENANCE.json +99 -0
  90. package/trustledger/dist/trustledger-standalone.html +6197 -0
  91. package/trustledger/dist/trustledger-standalone.html.sha256 +1 -0
  92. package/trustledger/door-core.js +442 -0
  93. package/trustledger/fixtures/bank.csv +7 -0
  94. package/trustledger/fixtures/bank.malformed.csv +3 -0
  95. package/trustledger/fixtures/bank.noalias.csv +5 -0
  96. package/trustledger/fixtures/bank.ofx +34 -0
  97. package/trustledger/fixtures/bank.real.csv +5 -0
  98. package/trustledger/fixtures/corpus/_shared/prior-close.json +22 -0
  99. package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/inputs.json +14 -0
  100. package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/meta.json +7 -0
  101. package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/inputs.json +14 -0
  102. package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/meta.json +7 -0
  103. package/trustledger/fixtures/corpus/continuity-break--benign-twin/inputs.json +15 -0
  104. package/trustledger/fixtures/corpus/continuity-break--benign-twin/meta.json +7 -0
  105. package/trustledger/fixtures/corpus/continuity-break--out-of-trust/inputs.json +15 -0
  106. package/trustledger/fixtures/corpus/continuity-break--out-of-trust/meta.json +7 -0
  107. package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/inputs.json +13 -0
  108. package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/meta.json +7 -0
  109. package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/inputs.json +13 -0
  110. package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/meta.json +7 -0
  111. package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/inputs.json +15 -0
  112. package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/meta.json +7 -0
  113. package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/inputs.json +15 -0
  114. package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/meta.json +7 -0
  115. package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/inputs.json +16 -0
  116. package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/meta.json +7 -0
  117. package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/inputs.json +13 -0
  118. package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/meta.json +7 -0
  119. package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/inputs.json +13 -0
  120. package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/meta.json +7 -0
  121. package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/inputs.json +13 -0
  122. package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/meta.json +7 -0
  123. package/trustledger/fixtures/e2e/bank.aliased.csv +4 -0
  124. package/trustledger/fixtures/e2e/bank.csv +4 -0
  125. package/trustledger/fixtures/e2e/bank.nsf.csv +4 -0
  126. package/trustledger/fixtures/e2e/quickbooks.csv +6 -0
  127. package/trustledger/fixtures/e2e/quickbooks.nsf.csv +8 -0
  128. package/trustledger/fixtures/e2e/rentroll.csv +6 -0
  129. package/trustledger/fixtures/e2e/rentroll.nsf.csv +8 -0
  130. package/trustledger/fixtures/e2e/rentroll.short.csv +5 -0
  131. package/trustledger/fixtures/plans/baseline.json +25 -0
  132. package/trustledger/fixtures/plans/price-binding.example.json +27 -0
  133. package/trustledger/fixtures/policy/ambiguous-deposit-example.json +12 -0
  134. package/trustledger/fixtures/policy/baseline.json +19 -0
  135. package/trustledger/fixtures/policy/ca-example.json +12 -0
  136. package/trustledger/fixtures/policy/negative-tenant-ledger-example.json +12 -0
  137. package/trustledger/fixtures/policy/owner-overdraw-example.json +12 -0
  138. package/trustledger/fixtures/quickbooks.csv +7 -0
  139. package/trustledger/fixtures/quickbooks.real.csv +5 -0
  140. package/trustledger/fixtures/rentroll.csv +6 -0
  141. package/trustledger/fixtures/rentroll.real.csv +4 -0
  142. package/trustledger/ingest.js +1163 -0
  143. package/trustledger/lib/policy-bundled-loader.js +44 -0
  144. package/trustledger/lib/sha256-vendored.js +227 -0
  145. package/trustledger/license.js +563 -0
  146. package/trustledger/match.js +551 -0
  147. package/trustledger/plans.js +551 -0
  148. package/trustledger/policy.js +398 -0
  149. package/trustledger/public/index.html +512 -0
  150. package/trustledger/reconcile.js +1486 -0
  151. package/trustledger/report.js +887 -0
  152. package/trustledger/seal.js +854 -0
  153. package/trustledger/server.js +391 -0
  154. 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
+ };