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,1082 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// cli/core/fulfill-intake.js — THE SELF-SERVE FULFILLMENT-INTAKE CORE (T-62.1).
|
|
5
|
+
//
|
|
6
|
+
// The EVIDENCE vertical already ships the LAST link of the self-serve revenue
|
|
7
|
+
// chain — `fulfillEvidenceOrder(order, catalog)` (cli/core/evidence-plans.js), a
|
|
8
|
+
// PURE order -> license-params mapping. But between a raw billing-provider webhook
|
|
9
|
+
// (a Stripe `checkout.session.completed` / `invoice.paid` POST) and that call there
|
|
10
|
+
// are TWO pure seams that existed NOWHERE in the evidence tree, so a human wiring
|
|
11
|
+
// Stripe -> `vh evidence license fulfill` had to WRITE and SECURE them by hand:
|
|
12
|
+
//
|
|
13
|
+
// SEAM 1 — AUTHENTICATE the raw request. Stripe signs each delivery with a
|
|
14
|
+
// `Stripe-Signature: t=<unix>,v1=<hmac_sha256_hex>` header over `${t}.${rawBody}`
|
|
15
|
+
// using the endpoint's signing secret. Verifying it (constant-time, inside a
|
|
16
|
+
// replay window) is the ONLY thing standing between "a paid event" and "anyone
|
|
17
|
+
// who can POST forges a license." `verifyProviderSignature` is that check.
|
|
18
|
+
//
|
|
19
|
+
// SEAM 2 — MAP the provider's own vocabulary onto OUR order. A real webhook body
|
|
20
|
+
// carries the PROVIDER's price id (`price_...`), a customer ref, and a period
|
|
21
|
+
// end as a UNIX epoch in SECONDS — NOT our planId, NOT a canonical ISO instant.
|
|
22
|
+
// `parseEvidenceEvent` flattens the real Stripe body to a normalized envelope;
|
|
23
|
+
// `validateEvidencePriceBinding` / `resolveEvidencePlanId` route (provider,
|
|
24
|
+
// priceId) -> OUR planId over the EVIDENCE catalog (mirroring the TrustLedger
|
|
25
|
+
// price binding, but bound to THIS product's plans); `normalizeEvidenceEvent`
|
|
26
|
+
// produces the EXACT `{ plan, customer, paidThrough, issuedAt }` order
|
|
27
|
+
// `fulfillEvidenceOrder` consumes; `intakeDedupKey` is the retry-stable
|
|
28
|
+
// idempotency key an at-least-once delivery dedupes on.
|
|
29
|
+
//
|
|
30
|
+
// DESIGN PROPERTIES (the whole module).
|
|
31
|
+
// * PURE / I-O-FREE / DETERMINISTIC. NO filesystem, NO network, NO system clock,
|
|
32
|
+
// NO key/secret held. The wall clock is INJECTED (`verifyProviderSignature`
|
|
33
|
+
// takes `nowSec`; `normalizeEvidenceEvent` takes `issuedAt`) so the core is
|
|
34
|
+
// deterministic under test and the same inputs always produce byte-identical
|
|
35
|
+
// output. A grep finds no fs / http / Date.now / no-argument Date construction.
|
|
36
|
+
// * ZERO NEW DEPENDENCY. Requires ONLY node-core `crypto` and this repo's
|
|
37
|
+
// `./evidence-plans` (which is the EVIDENCE plan catalog + fulfill). The
|
|
38
|
+
// canonical-ISO grammar + epoch->ISO math are inlined here over node-core Date,
|
|
39
|
+
// so nothing new is pulled in.
|
|
40
|
+
// * DEFENSIVE AGAINST HOSTILE INPUT. `verifyProviderSignature` NEVER throws on a
|
|
41
|
+
// malformed/forged/absent header or body — it returns `{ ok:false, reason }`
|
|
42
|
+
// with a specific, stable reason code, and compares digests in CONSTANT TIME.
|
|
43
|
+
// `parseEvidenceEvent` reads the RAW body string with its OWN strict JSON parser
|
|
44
|
+
// that bounds size + depth and REJECTS duplicate object keys (a JSON smuggling
|
|
45
|
+
// vector `JSON.parse` silently last-wins), and builds prototype-free objects.
|
|
46
|
+
// * NAMED, LOCALIZED REJECTS. Every failure is a NAMED error (or reason code)
|
|
47
|
+
// stating the SPECIFIC cause on the FIRST defect — never a silent pass, never a
|
|
48
|
+
// partial/last-wins accept.
|
|
49
|
+
//
|
|
50
|
+
// HONEST POSTURE. This module authenticates + maps an inbound event; it does NOT
|
|
51
|
+
// call any provider API, does NOT deploy, and holds NO real key/secret — the real
|
|
52
|
+
// signing secret + vendor key and the deploy stay HUMAN-owned. A license is an
|
|
53
|
+
// ACCESS credential for delivered software value: NOT a token, NOT tradeable, NOT
|
|
54
|
+
// an appreciating asset. The binding is an operator-maintained routing table and
|
|
55
|
+
// makes NO claim of regulatory compliance. The subscription agreement governs.
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
const crypto = require("crypto");
|
|
59
|
+
const evidencePlans = require("./evidence-plans");
|
|
60
|
+
|
|
61
|
+
// ===========================================================================
|
|
62
|
+
// SHARED: named error + the canonical-ISO / epoch grammar (inlined, node-core only).
|
|
63
|
+
// ===========================================================================
|
|
64
|
+
|
|
65
|
+
// One named error for the intake seams (parse + normalize + config misuse). The
|
|
66
|
+
// binding has its OWN error type (below) so a caller can catch the routing-table
|
|
67
|
+
// class distinctly, exactly as TrustLedger separates PriceBindingError.
|
|
68
|
+
class FulfillIntakeError extends Error {
|
|
69
|
+
constructor(message) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = "FulfillIntakeError";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The strict canonical-instant grammar the evidence license consumes: millis
|
|
76
|
+
// REQUIRED, no rolled-over/impossible fields. Identical to cli/core/license.js's
|
|
77
|
+
// ISO_INSTANT_RE — inlined here so this module needs only node-core + evidence-plans.
|
|
78
|
+
const ISO_INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
|
|
79
|
+
|
|
80
|
+
// Beyond this a Date's toISOString throws; guarding it keeps the *1000 ms math exact
|
|
81
|
+
// and inside JS's safe-integer window. == Date max (ms) / 1000.
|
|
82
|
+
const MAX_EPOCH_SECONDS = 8640000000000;
|
|
83
|
+
|
|
84
|
+
// value -> epoch-ms, or throw FulfillIntakeError naming `field`. Mirrors the
|
|
85
|
+
// evidence-plans _requireCanonicalInstant grammar so a downstream buildLicense never
|
|
86
|
+
// re-surfaces a buried date error.
|
|
87
|
+
function _requireCanonicalInstant(field, value) {
|
|
88
|
+
if (typeof value !== "string" || !ISO_INSTANT_RE.test(value)) {
|
|
89
|
+
throw new FulfillIntakeError(
|
|
90
|
+
`${field} must be an ISO-8601 UTC instant ("YYYY-MM-DDTHH:MM:SS(.mmm)Z"), got: ${String(value)}`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const ms = Date.parse(value);
|
|
94
|
+
if (Number.isNaN(ms) || new Date(ms).toISOString() !== value) {
|
|
95
|
+
throw new FulfillIntakeError(
|
|
96
|
+
`${field} must be a CANONICAL ISO-8601 UTC instant ("YYYY-MM-DDTHH:MM:SS.mmmZ", millis required, ` +
|
|
97
|
+
`no rolled-over/impossible fields), got: ${String(value)}`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return ms;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// UNIX epoch SECONDS -> canonical ISO. STRICT: a non-integer / negative /
|
|
104
|
+
// out-of-range epoch is a NAMED reject, never coerced or rounded.
|
|
105
|
+
function _epochSecondsToCanonicalISO(field, epochSeconds) {
|
|
106
|
+
if (
|
|
107
|
+
typeof epochSeconds !== "number" ||
|
|
108
|
+
!Number.isInteger(epochSeconds) ||
|
|
109
|
+
epochSeconds < 0 ||
|
|
110
|
+
epochSeconds > MAX_EPOCH_SECONDS
|
|
111
|
+
) {
|
|
112
|
+
throw new FulfillIntakeError(
|
|
113
|
+
`${field} must be a non-negative INTEGER UNIX epoch in SECONDS (0..${MAX_EPOCH_SECONDS}), got: ${String(epochSeconds)}`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return new Date(epochSeconds * 1000).toISOString();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ===========================================================================
|
|
120
|
+
// SEAM 1 — verifyProviderSignature: authenticate the raw Stripe webhook.
|
|
121
|
+
// ===========================================================================
|
|
122
|
+
//
|
|
123
|
+
// Stripe (and Stripe-compatible providers) sign each delivery with a header
|
|
124
|
+
// `Stripe-Signature: t=<unix-seconds>,v1=<hex>[,v1=<hex>...][,v0=<...>]`
|
|
125
|
+
// where each `v1` is `HMAC_SHA256(secret, "<t>.<rawBody>")` in lowercase hex. During
|
|
126
|
+
// a secret rotation MULTIPLE `v1`s can be present; ANY match authenticates. Verify:
|
|
127
|
+
// 1. the header is present + parseable into an integer `t` and >=1 `v1`;
|
|
128
|
+
// 2. some provided `v1` equals our recomputed HMAC (CONSTANT-TIME compare);
|
|
129
|
+
// 3. `t` is inside the replay window `|nowSec - t| <= toleranceSec`.
|
|
130
|
+
//
|
|
131
|
+
// `nowSec` is INJECTED (never read from the system clock) so the check is
|
|
132
|
+
// deterministic under test. On ANY malformed/forged/absent/expired input this NEVER
|
|
133
|
+
// throws — it returns `{ ok:false, reason }` with a stable, specific reason code.
|
|
134
|
+
// (Config misuse — a missing secret / non-integer nowSec — DOES throw, since that is
|
|
135
|
+
// a programmer error, not hostile network input.)
|
|
136
|
+
|
|
137
|
+
const DEFAULT_TOLERANCE_SEC = 300; // Stripe's own default replay window (5 minutes).
|
|
138
|
+
|
|
139
|
+
// Stable reason codes. "Localized" = a SPECIFIC cause per failure, not a generic
|
|
140
|
+
// "bad signature"; a caller/UI maps these to a message + a fixed exit posture.
|
|
141
|
+
const SIGNATURE_REASONS = Object.freeze({
|
|
142
|
+
OK: "ok",
|
|
143
|
+
MISSING_HEADER: "missing_signature_header",
|
|
144
|
+
MALFORMED_HEADER: "malformed_signature_header",
|
|
145
|
+
SIGNATURE_MISMATCH: "signature_mismatch",
|
|
146
|
+
TIMESTAMP_OUT_OF_TOLERANCE: "timestamp_out_of_tolerance",
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Constant-time hex compare. timingSafeEqual requires equal-length buffers, so an
|
|
150
|
+
// unequal length is a definite non-match (the expected length — 64 for sha256 hex —
|
|
151
|
+
// is not secret). Never throws.
|
|
152
|
+
function _timingSafeHexEqual(aHex, bHex) {
|
|
153
|
+
if (typeof aHex !== "string" || typeof bHex !== "string") return false;
|
|
154
|
+
const a = Buffer.from(aHex, "utf8");
|
|
155
|
+
const b = Buffer.from(bHex, "utf8");
|
|
156
|
+
if (a.length !== b.length) return false;
|
|
157
|
+
try {
|
|
158
|
+
return crypto.timingSafeEqual(a, b);
|
|
159
|
+
} catch (_e) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Parse `t=..,v1=..,v1=..` -> { t, v1s, dupT }. Collects ALL `v1`s. A DUPLICATE `t=`
|
|
165
|
+
// is flagged (`dupT`) so the caller can treat it as MALFORMED — mirroring the strict
|
|
166
|
+
// JSON parser's duplicate-key rejection instead of silently first-winning an ambiguous
|
|
167
|
+
// timestamp (a wrong `t` would only ever yield a mismatch, but ambiguity is a defect,
|
|
168
|
+
// not an accept path).
|
|
169
|
+
function _parseSignatureHeader(header) {
|
|
170
|
+
let t = null;
|
|
171
|
+
let dupT = false;
|
|
172
|
+
const v1s = [];
|
|
173
|
+
for (const part of header.split(",")) {
|
|
174
|
+
const eq = part.indexOf("=");
|
|
175
|
+
if (eq === -1) continue;
|
|
176
|
+
const k = part.slice(0, eq).trim();
|
|
177
|
+
const v = part.slice(eq + 1).trim();
|
|
178
|
+
if (k === "t") {
|
|
179
|
+
if (t === null) t = v;
|
|
180
|
+
else dupT = true;
|
|
181
|
+
} else if (k === "v1") {
|
|
182
|
+
if (v.length > 0) v1s.push(v);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { t, v1s, dupT };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* verifyProviderSignature(rawBody, sigHeader, secret, opts) — authenticate a raw
|
|
190
|
+
* provider webhook. PURE + constant-time; NEVER throws on hostile header/body.
|
|
191
|
+
*
|
|
192
|
+
* @param {string|Buffer} rawBody the EXACT raw request body the signature covers
|
|
193
|
+
* @param {string} sigHeader the `Stripe-Signature` header value (`t=..,v1=..`)
|
|
194
|
+
* @param {string} secret the endpoint signing secret (HMAC key)
|
|
195
|
+
* @param {object} opts
|
|
196
|
+
* @param {number} opts.nowSec REQUIRED current time as UNIX epoch SECONDS (injected clock)
|
|
197
|
+
* @param {number} [opts.toleranceSec] replay window in seconds (default 300)
|
|
198
|
+
* @returns {{ ok: boolean, reason: string, timestamp?: number }}
|
|
199
|
+
*/
|
|
200
|
+
function verifyProviderSignature(rawBody, sigHeader, secret, opts) {
|
|
201
|
+
// ---- config validation (programmer error MAY throw) ----------------------
|
|
202
|
+
if (typeof secret !== "string" || secret.length === 0) {
|
|
203
|
+
throw new FulfillIntakeError(
|
|
204
|
+
"verifyProviderSignature requires a non-empty signing secret string"
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (opts == null || typeof opts !== "object" || Array.isArray(opts)) {
|
|
208
|
+
throw new FulfillIntakeError(
|
|
209
|
+
"verifyProviderSignature requires an opts object { nowSec, toleranceSec? }"
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (typeof opts.nowSec !== "number" || !Number.isInteger(opts.nowSec)) {
|
|
213
|
+
throw new FulfillIntakeError(
|
|
214
|
+
"verifyProviderSignature requires an integer opts.nowSec (UNIX epoch seconds; the injected clock)"
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
let toleranceSec = DEFAULT_TOLERANCE_SEC;
|
|
218
|
+
if (opts.toleranceSec != null) {
|
|
219
|
+
if (
|
|
220
|
+
typeof opts.toleranceSec !== "number" ||
|
|
221
|
+
!Number.isInteger(opts.toleranceSec) ||
|
|
222
|
+
opts.toleranceSec < 0
|
|
223
|
+
) {
|
|
224
|
+
throw new FulfillIntakeError(
|
|
225
|
+
"verifyProviderSignature opts.toleranceSec, when given, must be a non-negative integer number of seconds"
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
toleranceSec = opts.toleranceSec;
|
|
229
|
+
}
|
|
230
|
+
if (!(typeof rawBody === "string" || Buffer.isBuffer(rawBody))) {
|
|
231
|
+
throw new FulfillIntakeError(
|
|
232
|
+
"verifyProviderSignature requires rawBody to be a string or Buffer (the exact bytes the signature covers)"
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---- from here on, NEVER throw — hostile input yields { ok:false, reason } ----
|
|
237
|
+
try {
|
|
238
|
+
if (typeof sigHeader !== "string" || sigHeader.trim() === "") {
|
|
239
|
+
return { ok: false, reason: SIGNATURE_REASONS.MISSING_HEADER };
|
|
240
|
+
}
|
|
241
|
+
const { t, v1s, dupT } = _parseSignatureHeader(sigHeader);
|
|
242
|
+
if (dupT || t === null || !/^\d+$/.test(t) || v1s.length === 0) {
|
|
243
|
+
return { ok: false, reason: SIGNATURE_REASONS.MALFORMED_HEADER };
|
|
244
|
+
}
|
|
245
|
+
const tNum = Number(t);
|
|
246
|
+
if (!Number.isSafeInteger(tNum)) {
|
|
247
|
+
return { ok: false, reason: SIGNATURE_REASONS.MALFORMED_HEADER };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Recompute the expected HMAC over the signed payload `${t}.${rawBody}` and
|
|
251
|
+
// compare against EVERY provided v1 in constant time (rotation-friendly). We
|
|
252
|
+
// build the signed payload as bytes so a Buffer body is covered verbatim.
|
|
253
|
+
const bodyBuf = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, "utf8");
|
|
254
|
+
const signedPayload = Buffer.concat([Buffer.from(`${t}.`, "utf8"), bodyBuf]);
|
|
255
|
+
const expected = crypto.createHmac("sha256", secret).update(signedPayload).digest("hex");
|
|
256
|
+
|
|
257
|
+
let matched = false;
|
|
258
|
+
for (const candidate of v1s) {
|
|
259
|
+
// OR-accumulate (no early break) so a match's position can't leak via timing.
|
|
260
|
+
if (_timingSafeHexEqual(expected, candidate)) matched = true;
|
|
261
|
+
}
|
|
262
|
+
if (!matched) {
|
|
263
|
+
return { ok: false, reason: SIGNATURE_REASONS.SIGNATURE_MISMATCH };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Signature is authentic; enforce the replay window LAST so a forged event is
|
|
267
|
+
// always a mismatch (never masqueraded as merely "expired").
|
|
268
|
+
if (Math.abs(opts.nowSec - tNum) > toleranceSec) {
|
|
269
|
+
return { ok: false, reason: SIGNATURE_REASONS.TIMESTAMP_OUT_OF_TOLERANCE };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { ok: true, reason: SIGNATURE_REASONS.OK, timestamp: tNum };
|
|
273
|
+
} catch (_e) {
|
|
274
|
+
// Absolute belt-and-suspenders: any unexpected condition on hostile input is a
|
|
275
|
+
// reject, never a throw and never a silent accept.
|
|
276
|
+
return { ok: false, reason: SIGNATURE_REASONS.MALFORMED_HEADER };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ===========================================================================
|
|
281
|
+
// STRICT JSON PARSE — bounded size + depth, duplicate-key + prototype safe.
|
|
282
|
+
// ===========================================================================
|
|
283
|
+
//
|
|
284
|
+
// A webhook body is attacker-influenced. `JSON.parse` silently keeps the LAST value
|
|
285
|
+
// for a duplicated key (a smuggling vector: `{"type":"x","type":"invoice.paid"}`)
|
|
286
|
+
// and has no depth/size bound. This small recursive-descent parser closes those:
|
|
287
|
+
// * REJECTS a body over `maxBytes` (measured on the raw string's UTF-8 length);
|
|
288
|
+
// * REJECTS nesting deeper than `maxDepth` (adversarial stack blow-up);
|
|
289
|
+
// * REJECTS a DUPLICATE key in ANY object (NAMED);
|
|
290
|
+
// * builds objects with a NULL prototype so a `__proto__`/`constructor` key is an
|
|
291
|
+
// inert own property, never prototype pollution.
|
|
292
|
+
// It accepts the standard JSON grammar (RFC 8259) and rejects trailing content.
|
|
293
|
+
|
|
294
|
+
const DEFAULT_MAX_BODY_BYTES = 256 * 1024; // generous vs. a real event (~a few KB).
|
|
295
|
+
const MAX_JSON_DEPTH = 64;
|
|
296
|
+
|
|
297
|
+
function _strictJsonParse(text, maxDepth) {
|
|
298
|
+
let i = 0;
|
|
299
|
+
const n = text.length;
|
|
300
|
+
|
|
301
|
+
function err(msg) {
|
|
302
|
+
return new FulfillIntakeError(`invalid webhook JSON: ${msg} at position ${i}`);
|
|
303
|
+
}
|
|
304
|
+
function skipWs() {
|
|
305
|
+
while (i < n) {
|
|
306
|
+
const c = text.charCodeAt(i);
|
|
307
|
+
if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) i++;
|
|
308
|
+
else break;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
function parseValue(depth) {
|
|
312
|
+
if (depth > maxDepth) throw err(`nesting exceeds max depth ${maxDepth}`);
|
|
313
|
+
skipWs();
|
|
314
|
+
if (i >= n) throw err("unexpected end of input");
|
|
315
|
+
const c = text[i];
|
|
316
|
+
if (c === "{") return parseObject(depth);
|
|
317
|
+
if (c === "[") return parseArray(depth);
|
|
318
|
+
if (c === '"') return parseString();
|
|
319
|
+
if (c === "-" || (c >= "0" && c <= "9")) return parseNumber();
|
|
320
|
+
if (text.startsWith("true", i)) {
|
|
321
|
+
i += 4;
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
if (text.startsWith("false", i)) {
|
|
325
|
+
i += 5;
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
if (text.startsWith("null", i)) {
|
|
329
|
+
i += 4;
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
throw err(`unexpected token ${JSON.stringify(c)}`);
|
|
333
|
+
}
|
|
334
|
+
function parseObject(depth) {
|
|
335
|
+
i++; // consume '{'
|
|
336
|
+
const obj = Object.create(null);
|
|
337
|
+
const seen = new Set();
|
|
338
|
+
skipWs();
|
|
339
|
+
if (text[i] === "}") {
|
|
340
|
+
i++;
|
|
341
|
+
return obj;
|
|
342
|
+
}
|
|
343
|
+
for (;;) {
|
|
344
|
+
skipWs();
|
|
345
|
+
if (text[i] !== '"') throw err("expected object key string");
|
|
346
|
+
const key = parseString();
|
|
347
|
+
if (seen.has(key)) {
|
|
348
|
+
throw new FulfillIntakeError(
|
|
349
|
+
`invalid webhook JSON: duplicate object key ${JSON.stringify(key)}`
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
seen.add(key);
|
|
353
|
+
skipWs();
|
|
354
|
+
if (text[i] !== ":") throw err("expected ':' after object key");
|
|
355
|
+
i++;
|
|
356
|
+
const value = parseValue(depth + 1);
|
|
357
|
+
// Own, enumerable, prototype-free assignment (null-proto obj => no pollution).
|
|
358
|
+
Object.defineProperty(obj, key, {
|
|
359
|
+
value,
|
|
360
|
+
enumerable: true,
|
|
361
|
+
writable: true,
|
|
362
|
+
configurable: true,
|
|
363
|
+
});
|
|
364
|
+
skipWs();
|
|
365
|
+
const ch = text[i];
|
|
366
|
+
if (ch === ",") {
|
|
367
|
+
i++;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (ch === "}") {
|
|
371
|
+
i++;
|
|
372
|
+
return obj;
|
|
373
|
+
}
|
|
374
|
+
throw err("expected ',' or '}' in object");
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function parseArray(depth) {
|
|
378
|
+
i++; // consume '['
|
|
379
|
+
const arr = [];
|
|
380
|
+
skipWs();
|
|
381
|
+
if (text[i] === "]") {
|
|
382
|
+
i++;
|
|
383
|
+
return arr;
|
|
384
|
+
}
|
|
385
|
+
for (;;) {
|
|
386
|
+
arr.push(parseValue(depth + 1));
|
|
387
|
+
skipWs();
|
|
388
|
+
const ch = text[i];
|
|
389
|
+
if (ch === ",") {
|
|
390
|
+
i++;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (ch === "]") {
|
|
394
|
+
i++;
|
|
395
|
+
return arr;
|
|
396
|
+
}
|
|
397
|
+
throw err("expected ',' or ']' in array");
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function parseString() {
|
|
401
|
+
i++; // consume opening quote
|
|
402
|
+
let out = "";
|
|
403
|
+
for (;;) {
|
|
404
|
+
if (i >= n) throw err("unterminated string");
|
|
405
|
+
const ch = text[i++];
|
|
406
|
+
if (ch === '"') return out;
|
|
407
|
+
if (ch === "\\") {
|
|
408
|
+
if (i >= n) throw err("unterminated escape");
|
|
409
|
+
const e = text[i++];
|
|
410
|
+
if (e === '"') out += '"';
|
|
411
|
+
else if (e === "\\") out += "\\";
|
|
412
|
+
else if (e === "/") out += "/";
|
|
413
|
+
else if (e === "b") out += "\b";
|
|
414
|
+
else if (e === "f") out += "\f";
|
|
415
|
+
else if (e === "n") out += "\n";
|
|
416
|
+
else if (e === "r") out += "\r";
|
|
417
|
+
else if (e === "t") out += "\t";
|
|
418
|
+
else if (e === "u") {
|
|
419
|
+
const hex = text.slice(i, i + 4);
|
|
420
|
+
if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw err("invalid \\u escape");
|
|
421
|
+
out += String.fromCharCode(parseInt(hex, 16));
|
|
422
|
+
i += 4;
|
|
423
|
+
} else throw err(`invalid escape \\${e}`);
|
|
424
|
+
} else {
|
|
425
|
+
const code = ch.charCodeAt(0);
|
|
426
|
+
if (code < 0x20) throw err("unescaped control character in string");
|
|
427
|
+
out += ch;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function parseNumber() {
|
|
432
|
+
const start = i;
|
|
433
|
+
if (text[i] === "-") i++;
|
|
434
|
+
if (text[i] === "0") {
|
|
435
|
+
i++;
|
|
436
|
+
} else if (text[i] >= "1" && text[i] <= "9") {
|
|
437
|
+
while (i < n && text[i] >= "0" && text[i] <= "9") i++;
|
|
438
|
+
} else {
|
|
439
|
+
throw err("invalid number");
|
|
440
|
+
}
|
|
441
|
+
if (text[i] === ".") {
|
|
442
|
+
i++;
|
|
443
|
+
if (!(text[i] >= "0" && text[i] <= "9")) throw err("invalid fraction");
|
|
444
|
+
while (i < n && text[i] >= "0" && text[i] <= "9") i++;
|
|
445
|
+
}
|
|
446
|
+
if (text[i] === "e" || text[i] === "E") {
|
|
447
|
+
i++;
|
|
448
|
+
if (text[i] === "+" || text[i] === "-") i++;
|
|
449
|
+
if (!(text[i] >= "0" && text[i] <= "9")) throw err("invalid exponent");
|
|
450
|
+
while (i < n && text[i] >= "0" && text[i] <= "9") i++;
|
|
451
|
+
}
|
|
452
|
+
return Number(text.slice(start, i));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
skipWs();
|
|
456
|
+
const value = parseValue(0);
|
|
457
|
+
skipWs();
|
|
458
|
+
if (i !== n) throw err("unexpected trailing content");
|
|
459
|
+
return value;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Safe own-property read over a possibly null-prototype parsed object.
|
|
463
|
+
function _own(obj, key) {
|
|
464
|
+
if (obj == null || typeof obj !== "object") return undefined;
|
|
465
|
+
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ===========================================================================
|
|
469
|
+
// SEAM 2a — parseEvidenceEvent: real Stripe body -> normalized envelope.
|
|
470
|
+
// ===========================================================================
|
|
471
|
+
//
|
|
472
|
+
// Maps a real-shaped Stripe event body to `{ provider, type, priceId, customer,
|
|
473
|
+
// periodEnd }`. Two event types are understood.
|
|
474
|
+
//
|
|
475
|
+
// STRIPE API-VERSION NOTE (why field extraction accepts TWO shapes). Stripe's
|
|
476
|
+
// 2025-03-31 ("Basil") API version MOVED two of the fields this bridge reads:
|
|
477
|
+
// * a subscription's billing-cycle end moved from the single top-level
|
|
478
|
+
// `subscription.current_period_end` to a PER-ITEM
|
|
479
|
+
// `subscription.items.data[i].current_period_end`;
|
|
480
|
+
// * an invoice line item's price moved from the expanded `line.price` object to
|
|
481
|
+
// `line.pricing.price_details.price` (a bare price-id STRING).
|
|
482
|
+
// So a webhook endpoint on a MODERN default API version carries the new shapes, while
|
|
483
|
+
// an endpoint pinned to a pre-Basil version carries the legacy ones. To be a genuine
|
|
484
|
+
// DROP-IN bridge regardless of the account/endpoint's pinned `Stripe-Version`, the
|
|
485
|
+
// extractor below accepts BOTH; the operator does NOT have to pin an old API version.
|
|
486
|
+
//
|
|
487
|
+
// * "invoice.paid" (a subscription renewal / first invoice)
|
|
488
|
+
// priceId <- line.price.id (legacy) OR
|
|
489
|
+
// line.pricing.price_details.price (current, a bare id string)
|
|
490
|
+
// periodEnd<- line.period.end (epoch SECONDS; unchanged across versions)
|
|
491
|
+
// customer <- data.object.customer
|
|
492
|
+
// where `line` is the SELECTED line item (see line-selection note below), NOT
|
|
493
|
+
// blindly data[0].
|
|
494
|
+
//
|
|
495
|
+
// * "checkout.session.completed" (with the subscription EXPANDED, as the
|
|
496
|
+
// integrator configures `expand:['subscription']`)
|
|
497
|
+
// priceId <- item.price.id (legacy) OR
|
|
498
|
+
// item.pricing.price_details.price (current, a bare id string)
|
|
499
|
+
// periodEnd<- item.current_period_end (current) OR
|
|
500
|
+
// subscription.current_period_end (legacy) (epoch SECONDS)
|
|
501
|
+
// customer <- data.object.customer
|
|
502
|
+
// where `item` is the SELECTED subscription item.
|
|
503
|
+
//
|
|
504
|
+
// LINE/ITEM SELECTION (why not positional [0]). A multi-item subscription or a
|
|
505
|
+
// proration invoice can carry MORE than one line; data[0] is not guaranteed to be the
|
|
506
|
+
// bound subscription line, so a blind [0] could silently mis-select the PLAN. When a
|
|
507
|
+
// list has >1 item this parser therefore: (a) with an `opts.binding`, selects the ONE
|
|
508
|
+
// item whose price is bound and NAMED-rejects if zero or more-than-one are bound
|
|
509
|
+
// (ambiguous); (b) without a binding, accepts only when every item shares one price
|
|
510
|
+
// and NAMED-rejects a mix of distinct prices (telling the operator to pass
|
|
511
|
+
// `opts.binding`). A single-item list is used directly (the common case).
|
|
512
|
+
//
|
|
513
|
+
// `provider` is fixed to "stripe" (this is the Stripe parser). The RAW body STRING
|
|
514
|
+
// is read (so size + duplicate-key defenses apply and the exact signed bytes are the
|
|
515
|
+
// thing parsed). NAMED-rejects an oversized body, malformed JSON, a duplicate key, an
|
|
516
|
+
// unknown/unsupported `type`, and any missing/mistyped required field.
|
|
517
|
+
|
|
518
|
+
const STRIPE_PROVIDER = "stripe";
|
|
519
|
+
const SUPPORTED_STRIPE_EVENT_TYPES = Object.freeze([
|
|
520
|
+
"checkout.session.completed",
|
|
521
|
+
"invoice.paid",
|
|
522
|
+
]);
|
|
523
|
+
|
|
524
|
+
function _requireEventString(value, label) {
|
|
525
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
526
|
+
throw new FulfillIntakeError(`event ${label} must be a non-empty string`);
|
|
527
|
+
}
|
|
528
|
+
return value;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Navigate a Stripe list object (`{ object:'list', data:[...] }`) -> its non-empty
|
|
532
|
+
// `data` array, NAMED-rejecting a non-list, an empty list, or a non-object element.
|
|
533
|
+
function _requireListData(listObj, label) {
|
|
534
|
+
const data = _own(listObj, "data");
|
|
535
|
+
if (!Array.isArray(data) || data.length === 0) {
|
|
536
|
+
throw new FulfillIntakeError(`event ${label} must be a non-empty { data: [...] } list`);
|
|
537
|
+
}
|
|
538
|
+
for (let idx = 0; idx < data.length; idx++) {
|
|
539
|
+
const it = data[idx];
|
|
540
|
+
if (it == null || typeof it !== "object" || Array.isArray(it)) {
|
|
541
|
+
throw new FulfillIntakeError(`event ${label}.data[${idx}] must be an object`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return data;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Extract a price id from a Stripe line item / subscription item, accepting BOTH the
|
|
548
|
+
// legacy `.price` (expanded object with an id, or a bare id string) shape AND the
|
|
549
|
+
// current (Basil, 2025-03-31+) `.pricing.price_details.price` (a bare id string)
|
|
550
|
+
// shape. Returns the id string, or null if none is present (a non-throwing probe used
|
|
551
|
+
// during multi-item scanning).
|
|
552
|
+
function _priceIdOf(node) {
|
|
553
|
+
if (node == null || typeof node !== "object") return null;
|
|
554
|
+
const price = _own(node, "price");
|
|
555
|
+
if (price != null && typeof price === "object" && !Array.isArray(price)) {
|
|
556
|
+
const id = _own(price, "id");
|
|
557
|
+
if (typeof id === "string" && id.length > 0) return id;
|
|
558
|
+
} else if (typeof price === "string" && price.length > 0) {
|
|
559
|
+
return price;
|
|
560
|
+
}
|
|
561
|
+
const pricing = _own(node, "pricing");
|
|
562
|
+
const details = _own(pricing, "price_details");
|
|
563
|
+
const modern = _own(details, "price");
|
|
564
|
+
if (typeof modern === "string" && modern.length > 0) return modern;
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Same as _priceIdOf but NAMED-rejects a missing/mistyped price (the single-selected
|
|
569
|
+
// item path, where a good error message names the field).
|
|
570
|
+
function _requirePriceId(node, label) {
|
|
571
|
+
const id = _priceIdOf(node);
|
|
572
|
+
if (id == null) {
|
|
573
|
+
throw new FulfillIntakeError(
|
|
574
|
+
`event ${label} must carry a price id (either ${label}.price.id or ` +
|
|
575
|
+
`${label}.pricing.price_details.price)`
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
return id;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Select the ONE relevant item from a (possibly multi-item) Stripe list, SAFELY —
|
|
582
|
+
// never a blind positional [0] when the choice is ambiguous. See the LINE/ITEM
|
|
583
|
+
// SELECTION note above for the rules.
|
|
584
|
+
function _selectBoundListItem(items, provider, binding, label) {
|
|
585
|
+
if (items.length === 1) return items[0];
|
|
586
|
+
const scanned = items.map((it) => ({ it, priceId: _priceIdOf(it) }));
|
|
587
|
+
if (binding != null) {
|
|
588
|
+
const bound = scanned.filter(
|
|
589
|
+
(x) => x.priceId != null && _isBound(binding, provider, x.priceId)
|
|
590
|
+
);
|
|
591
|
+
if (bound.length === 1) return bound[0].it;
|
|
592
|
+
if (bound.length === 0) {
|
|
593
|
+
throw new FulfillIntakeError(
|
|
594
|
+
`event ${label} has ${items.length} items but NONE carries a price bound in the ` +
|
|
595
|
+
`supplied binding; cannot select the intended line`
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
throw new FulfillIntakeError(
|
|
599
|
+
`event ${label} has ${bound.length} items whose prices are ALL bound; the intended ` +
|
|
600
|
+
`line is AMBIGUOUS — deliver one plan per event, or split the binding so only one ` +
|
|
601
|
+
`price maps`
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
const distinct = new Set(scanned.map((x) => x.priceId));
|
|
605
|
+
if (distinct.size === 1 && !distinct.has(null)) return scanned[0].it;
|
|
606
|
+
throw new FulfillIntakeError(
|
|
607
|
+
`event ${label} has ${items.length} items spanning ${distinct.size} distinct prices; ` +
|
|
608
|
+
`pass opts.binding so the intended (bound) line is selected unambiguously instead of ` +
|
|
609
|
+
`positionally`
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// The subscription billing-cycle end, preferring the current PER-ITEM
|
|
614
|
+
// `current_period_end` and falling back to the legacy top-level one.
|
|
615
|
+
function _subscriptionPeriodEnd(sub, item) {
|
|
616
|
+
const itemLevel = _own(item, "current_period_end");
|
|
617
|
+
if (typeof itemLevel === "number") return itemLevel;
|
|
618
|
+
const subLevel = _own(sub, "current_period_end");
|
|
619
|
+
if (typeof subLevel === "number") return subLevel;
|
|
620
|
+
throw new FulfillIntakeError(
|
|
621
|
+
"event subscription period end is missing: expected either " +
|
|
622
|
+
"`data.object.subscription.items.data[i].current_period_end` (current Stripe API) or " +
|
|
623
|
+
"`data.object.subscription.current_period_end` (legacy API), as a UNIX epoch (seconds) number"
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* parseEvidenceEvent(rawBody, opts?) — real Stripe body STRING -> normalized envelope.
|
|
629
|
+
*
|
|
630
|
+
* @param {string} rawBody the exact raw webhook body (the bytes the signature covers)
|
|
631
|
+
* @param {object} [opts]
|
|
632
|
+
* @param {number} [opts.maxBytes] reject a body larger than this (default 256 KiB)
|
|
633
|
+
* @param {object} [opts.binding] a VALIDATED evidence price binding, used ONLY to
|
|
634
|
+
* disambiguate a MULTI-item invoice/subscription (select the bound line instead of
|
|
635
|
+
* positional [0]). Optional: a single-item body never needs it.
|
|
636
|
+
* @returns {{ provider:'stripe', type:string, priceId:string, customer:string, periodEnd:number }}
|
|
637
|
+
*/
|
|
638
|
+
function parseEvidenceEvent(rawBody, opts) {
|
|
639
|
+
if (typeof rawBody !== "string") {
|
|
640
|
+
throw new FulfillIntakeError("parseEvidenceEvent requires the raw webhook body as a string");
|
|
641
|
+
}
|
|
642
|
+
let maxBytes = DEFAULT_MAX_BODY_BYTES;
|
|
643
|
+
let binding = null;
|
|
644
|
+
if (opts != null) {
|
|
645
|
+
if (typeof opts !== "object" || Array.isArray(opts)) {
|
|
646
|
+
throw new FulfillIntakeError("parseEvidenceEvent opts, when given, must be an object { maxBytes?, binding? }");
|
|
647
|
+
}
|
|
648
|
+
if (opts.maxBytes != null) {
|
|
649
|
+
if (typeof opts.maxBytes !== "number" || !Number.isInteger(opts.maxBytes) || opts.maxBytes <= 0) {
|
|
650
|
+
throw new FulfillIntakeError("parseEvidenceEvent opts.maxBytes must be a positive integer");
|
|
651
|
+
}
|
|
652
|
+
maxBytes = opts.maxBytes;
|
|
653
|
+
}
|
|
654
|
+
if (opts.binding != null) {
|
|
655
|
+
if (
|
|
656
|
+
typeof opts.binding !== "object" ||
|
|
657
|
+
Array.isArray(opts.binding) ||
|
|
658
|
+
opts.binding._byKey == null ||
|
|
659
|
+
typeof opts.binding._byKey !== "object"
|
|
660
|
+
) {
|
|
661
|
+
throw new FulfillIntakeError(
|
|
662
|
+
"parseEvidenceEvent opts.binding, when given, must be a validated evidence price binding (from validateEvidencePriceBinding)"
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
binding = opts.binding;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const bodyBytes = Buffer.byteLength(rawBody, "utf8");
|
|
670
|
+
if (bodyBytes > maxBytes) {
|
|
671
|
+
throw new FulfillIntakeError(
|
|
672
|
+
`webhook body is oversized: ${bodyBytes} bytes exceeds the ${maxBytes}-byte limit`
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const evt = _strictJsonParse(rawBody, MAX_JSON_DEPTH);
|
|
677
|
+
if (evt == null || typeof evt !== "object" || Array.isArray(evt)) {
|
|
678
|
+
throw new FulfillIntakeError("webhook body must be a JSON object");
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const type = _own(evt, "type");
|
|
682
|
+
if (typeof type !== "string" || type.length === 0) {
|
|
683
|
+
throw new FulfillIntakeError("event `type` must be a non-empty string");
|
|
684
|
+
}
|
|
685
|
+
if (!SUPPORTED_STRIPE_EVENT_TYPES.includes(type)) {
|
|
686
|
+
throw new FulfillIntakeError(
|
|
687
|
+
`unsupported event type ${JSON.stringify(type)}; this handler understands: ${SUPPORTED_STRIPE_EVENT_TYPES.join(", ")}`
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const data = _own(evt, "data");
|
|
692
|
+
const object = _own(data, "object");
|
|
693
|
+
if (object == null || typeof object !== "object" || Array.isArray(object)) {
|
|
694
|
+
throw new FulfillIntakeError("event `data.object` must be an object");
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const customer = _requireEventString(_own(object, "customer"), "data.object.customer");
|
|
698
|
+
|
|
699
|
+
let priceId;
|
|
700
|
+
let periodEnd;
|
|
701
|
+
if (type === "invoice.paid") {
|
|
702
|
+
const lineItems = _requireListData(_own(object, "lines"), "data.object.lines");
|
|
703
|
+
const line = _selectBoundListItem(lineItems, STRIPE_PROVIDER, binding, "data.object.lines");
|
|
704
|
+
priceId = _requirePriceId(line, "data.object.lines.data[0]");
|
|
705
|
+
const period = _own(line, "period");
|
|
706
|
+
periodEnd = _own(period, "end");
|
|
707
|
+
if (typeof periodEnd !== "number") {
|
|
708
|
+
throw new FulfillIntakeError(
|
|
709
|
+
"event `data.object.lines.data[0].period.end` must be a UNIX epoch (seconds) number"
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
} else {
|
|
713
|
+
// checkout.session.completed with the subscription expanded.
|
|
714
|
+
const sub = _own(object, "subscription");
|
|
715
|
+
if (sub == null || typeof sub !== "object" || Array.isArray(sub)) {
|
|
716
|
+
throw new FulfillIntakeError(
|
|
717
|
+
"event `data.object.subscription` must be an expanded subscription object " +
|
|
718
|
+
"(configure Stripe Checkout with expand:['subscription'])"
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
const subItems = _requireListData(_own(sub, "items"), "data.object.subscription.items");
|
|
722
|
+
const item = _selectBoundListItem(
|
|
723
|
+
subItems,
|
|
724
|
+
STRIPE_PROVIDER,
|
|
725
|
+
binding,
|
|
726
|
+
"data.object.subscription.items"
|
|
727
|
+
);
|
|
728
|
+
priceId = _requirePriceId(item, "data.object.subscription.items.data[0]");
|
|
729
|
+
periodEnd = _subscriptionPeriodEnd(sub, item);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Validate the epoch grammar EAGERLY here (a fractional/negative/absurd epoch is a
|
|
733
|
+
// NAMED reject at parse time, never carried forward to bite normalize).
|
|
734
|
+
if (!Number.isInteger(periodEnd) || periodEnd < 0 || periodEnd > MAX_EPOCH_SECONDS) {
|
|
735
|
+
throw new FulfillIntakeError(
|
|
736
|
+
`event periodEnd must be a non-negative INTEGER UNIX epoch in SECONDS (0..${MAX_EPOCH_SECONDS}), got: ${String(periodEnd)}`
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return { provider: STRIPE_PROVIDER, type, priceId, customer, periodEnd };
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ===========================================================================
|
|
744
|
+
// SEAM 2b — the EVIDENCE price binding: (provider, priceId) -> OUR planId.
|
|
745
|
+
// ===========================================================================
|
|
746
|
+
//
|
|
747
|
+
// Mirrors trustledger/plans.js's price binding EXACTLY but is bound to the EVIDENCE
|
|
748
|
+
// catalog: every mapping's planId is checked against the SUPPLIED (validated) EVIDENCE
|
|
749
|
+
// plan catalog via evidencePlans.getEvidencePlan, so a price can NEVER resolve to a
|
|
750
|
+
// plan the catalog does not define, and the binding's OWN kind is disjoint from every
|
|
751
|
+
// other payload in the tree.
|
|
752
|
+
|
|
753
|
+
const EVIDENCE_PRICE_BINDING_KIND = "vh-evidence-price-binding";
|
|
754
|
+
const EVIDENCE_PRICE_BINDING_SCHEMA_VERSION = 1;
|
|
755
|
+
const SUPPORTED_EVIDENCE_PRICE_BINDING_SCHEMA_VERSIONS = Object.freeze([1]);
|
|
756
|
+
|
|
757
|
+
class EvidencePriceBindingError extends Error {
|
|
758
|
+
constructor(message) {
|
|
759
|
+
super(message);
|
|
760
|
+
this.name = "EvidencePriceBindingError";
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const _BINDING_KEY_SEP = "\u0000"; // NUL — forbidden in a provider/priceId below.
|
|
765
|
+
function _bindingKey(provider, priceId) {
|
|
766
|
+
return `${provider}${_BINDING_KEY_SEP}${priceId}`;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Non-throwing predicate: is (provider, priceId) present in a VALIDATED binding?
|
|
770
|
+
// Used by multi-item line selection to pick the bound line. A malformed provider/
|
|
771
|
+
// priceId simply isn't bound (false); the binding must already carry `_byKey`.
|
|
772
|
+
function _isBound(binding, provider, priceId) {
|
|
773
|
+
if (
|
|
774
|
+
binding == null ||
|
|
775
|
+
typeof binding !== "object" ||
|
|
776
|
+
binding._byKey == null ||
|
|
777
|
+
typeof binding._byKey !== "object"
|
|
778
|
+
) {
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
if (typeof provider !== "string" || provider.length === 0) return false;
|
|
782
|
+
if (typeof priceId !== "string" || priceId.length === 0) return false;
|
|
783
|
+
return Object.prototype.hasOwnProperty.call(binding._byKey, _bindingKey(provider, priceId));
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function _validateEvidenceMapping(mapping, index, catalog) {
|
|
787
|
+
const at = `mapping[${index}]`;
|
|
788
|
+
if (mapping === null || typeof mapping !== "object" || Array.isArray(mapping)) {
|
|
789
|
+
throw new EvidencePriceBindingError(`${at} must be an object`);
|
|
790
|
+
}
|
|
791
|
+
if (typeof mapping.provider !== "string" || mapping.provider.trim() === "") {
|
|
792
|
+
throw new EvidencePriceBindingError(`${at}.provider must be a non-empty string`);
|
|
793
|
+
}
|
|
794
|
+
if (mapping.provider.includes(_BINDING_KEY_SEP)) {
|
|
795
|
+
throw new EvidencePriceBindingError(`${at}.provider must not contain a NUL character`);
|
|
796
|
+
}
|
|
797
|
+
const provider = mapping.provider;
|
|
798
|
+
|
|
799
|
+
if (typeof mapping.priceId !== "string" || mapping.priceId.trim() === "") {
|
|
800
|
+
throw new EvidencePriceBindingError(
|
|
801
|
+
`${at} (provider ${JSON.stringify(provider)}) priceId must be a non-empty string`
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
if (mapping.priceId.includes(_BINDING_KEY_SEP)) {
|
|
805
|
+
throw new EvidencePriceBindingError(
|
|
806
|
+
`${at} (provider ${JSON.stringify(provider)}) priceId must not contain a NUL character`
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
const priceId = mapping.priceId;
|
|
810
|
+
|
|
811
|
+
if (typeof mapping.planId !== "string" || mapping.planId.trim() === "") {
|
|
812
|
+
throw new EvidencePriceBindingError(
|
|
813
|
+
`${at} (provider ${JSON.stringify(provider)}, priceId ${JSON.stringify(priceId)}) ` +
|
|
814
|
+
`planId must be a non-empty string`
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
const planId = mapping.planId;
|
|
818
|
+
// The EVIDENCE catalog is the single authority for valid planIds.
|
|
819
|
+
try {
|
|
820
|
+
evidencePlans.getEvidencePlan(catalog, planId);
|
|
821
|
+
} catch (_e) {
|
|
822
|
+
const known = Object.keys(catalog.plansById).sort().join(", ");
|
|
823
|
+
throw new EvidencePriceBindingError(
|
|
824
|
+
`${at} (provider ${JSON.stringify(provider)}, priceId ${JSON.stringify(priceId)}) ` +
|
|
825
|
+
`points at planId ${JSON.stringify(planId)} which is NOT in the supplied evidence catalog; ` +
|
|
826
|
+
`known plans are: ${known}`
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
return Object.freeze({ provider, priceId, planId });
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* validateEvidencePriceBinding(obj, catalog) -> validated, deeply-FROZEN binding.
|
|
835
|
+
* `catalog` MUST be a validated EVIDENCE plan catalog (the authority for planIds).
|
|
836
|
+
* Throws EvidencePriceBindingError on the FIRST defect. Never mutates either input.
|
|
837
|
+
*/
|
|
838
|
+
function validateEvidencePriceBinding(obj, catalog) {
|
|
839
|
+
if (
|
|
840
|
+
catalog === null ||
|
|
841
|
+
typeof catalog !== "object" ||
|
|
842
|
+
catalog.plansById === null ||
|
|
843
|
+
typeof catalog.plansById !== "object"
|
|
844
|
+
) {
|
|
845
|
+
throw new EvidencePriceBindingError(
|
|
846
|
+
"validateEvidencePriceBinding requires a validated evidence plan catalog (the single source of valid planIds)"
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
|
|
850
|
+
throw new EvidencePriceBindingError("evidence price binding must be a JSON object");
|
|
851
|
+
}
|
|
852
|
+
if (obj.kind !== EVIDENCE_PRICE_BINDING_KIND) {
|
|
853
|
+
throw new EvidencePriceBindingError(
|
|
854
|
+
`evidence price binding has wrong kind ${JSON.stringify(obj.kind)}; ` +
|
|
855
|
+
`expected ${JSON.stringify(EVIDENCE_PRICE_BINDING_KIND)}`
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
if (!Object.prototype.hasOwnProperty.call(obj, "schemaVersion")) {
|
|
859
|
+
throw new EvidencePriceBindingError("evidence price binding is missing required field: schemaVersion");
|
|
860
|
+
}
|
|
861
|
+
if (!SUPPORTED_EVIDENCE_PRICE_BINDING_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
|
|
862
|
+
throw new EvidencePriceBindingError(
|
|
863
|
+
`unsupported evidence price binding schemaVersion ${JSON.stringify(obj.schemaVersion)}; ` +
|
|
864
|
+
`this build understands: ${SUPPORTED_EVIDENCE_PRICE_BINDING_SCHEMA_VERSIONS.join(", ")}`
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
if (!Object.prototype.hasOwnProperty.call(obj, "mappings")) {
|
|
868
|
+
throw new EvidencePriceBindingError("evidence price binding is missing required field: mappings");
|
|
869
|
+
}
|
|
870
|
+
if (!Array.isArray(obj.mappings)) {
|
|
871
|
+
throw new EvidencePriceBindingError("evidence price binding mappings must be an array");
|
|
872
|
+
}
|
|
873
|
+
if (obj.mappings.length === 0) {
|
|
874
|
+
throw new EvidencePriceBindingError("evidence price binding mappings must be a non-empty array");
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const byKey = new Map();
|
|
878
|
+
for (let idx = 0; idx < obj.mappings.length; idx++) {
|
|
879
|
+
const mapping = _validateEvidenceMapping(obj.mappings[idx], idx, catalog);
|
|
880
|
+
const key = _bindingKey(mapping.provider, mapping.priceId);
|
|
881
|
+
if (byKey.has(key)) {
|
|
882
|
+
throw new EvidencePriceBindingError(
|
|
883
|
+
`evidence price binding has duplicate (provider, priceId) ` +
|
|
884
|
+
`(${JSON.stringify(mapping.provider)}, ${JSON.stringify(mapping.priceId)})`
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
byKey.set(key, mapping);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const sortedKeys = [...byKey.keys()].sort();
|
|
891
|
+
const mappings = Object.freeze(sortedKeys.map((k) => byKey.get(k)));
|
|
892
|
+
const byKeyObj = Object.freeze(
|
|
893
|
+
sortedKeys.reduce((m, k) => {
|
|
894
|
+
m[k] = byKey.get(k);
|
|
895
|
+
return m;
|
|
896
|
+
}, Object.create(null))
|
|
897
|
+
);
|
|
898
|
+
|
|
899
|
+
const result = {
|
|
900
|
+
kind: EVIDENCE_PRICE_BINDING_KIND,
|
|
901
|
+
schemaVersion: obj.schemaVersion,
|
|
902
|
+
mappings,
|
|
903
|
+
};
|
|
904
|
+
// The NUL-keyed lookup index stays OFF the public/serialized surface.
|
|
905
|
+
Object.defineProperty(result, "_byKey", {
|
|
906
|
+
value: byKeyObj,
|
|
907
|
+
enumerable: false,
|
|
908
|
+
writable: false,
|
|
909
|
+
configurable: false,
|
|
910
|
+
});
|
|
911
|
+
return Object.freeze(result);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* resolveEvidencePlanId(binding, provider, priceId) -> the bound planId, or a NAMED
|
|
916
|
+
* reject for an unmapped pair. PURE lookup against a VALIDATED binding.
|
|
917
|
+
*/
|
|
918
|
+
function resolveEvidencePlanId(binding, provider, priceId) {
|
|
919
|
+
if (
|
|
920
|
+
binding === null ||
|
|
921
|
+
typeof binding !== "object" ||
|
|
922
|
+
binding._byKey === null ||
|
|
923
|
+
typeof binding._byKey !== "object"
|
|
924
|
+
) {
|
|
925
|
+
throw new EvidencePriceBindingError("resolveEvidencePlanId requires a validated evidence price binding");
|
|
926
|
+
}
|
|
927
|
+
if (typeof provider !== "string" || provider.trim() === "") {
|
|
928
|
+
throw new EvidencePriceBindingError("resolveEvidencePlanId requires a non-empty provider");
|
|
929
|
+
}
|
|
930
|
+
if (typeof priceId !== "string" || priceId.trim() === "") {
|
|
931
|
+
throw new EvidencePriceBindingError("resolveEvidencePlanId requires a non-empty priceId");
|
|
932
|
+
}
|
|
933
|
+
const key = _bindingKey(provider, priceId);
|
|
934
|
+
if (!Object.prototype.hasOwnProperty.call(binding._byKey, key)) {
|
|
935
|
+
throw new EvidencePriceBindingError(
|
|
936
|
+
`no evidence plan bound for (provider ${JSON.stringify(provider)}, ` +
|
|
937
|
+
`priceId ${JSON.stringify(priceId)}); the price binding has no such mapping`
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
return binding._byKey[key].planId;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// ===========================================================================
|
|
944
|
+
// SEAM 2c — normalizeEvidenceEvent + intakeDedupKey.
|
|
945
|
+
// ===========================================================================
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* normalizeEvidenceEvent(event, binding, opts) — PURE, DETERMINISTIC map of a parsed
|
|
949
|
+
* event envelope (parseEvidenceEvent output) onto the EXACT
|
|
950
|
+
* `{ plan, customer, paidThrough, issuedAt }` order `fulfillEvidenceOrder` consumes.
|
|
951
|
+
*
|
|
952
|
+
* `issuedAt` is INJECTED via `opts.issuedAt` (the caller — who knows the wall clock —
|
|
953
|
+
* supplies it); the core NEVER reads the system clock, so the same event + binding +
|
|
954
|
+
* opts yields a byte-identical order every time. `paidThrough` is the canonical ISO of
|
|
955
|
+
* the event's `periodEnd` (UNIX epoch seconds). An unmapped (provider, priceId) is a
|
|
956
|
+
* NAMED reject naming the pair.
|
|
957
|
+
*
|
|
958
|
+
* @param {object} event parseEvidenceEvent output { provider, priceId, customer, periodEnd, ... }
|
|
959
|
+
* @param {object} binding a VALIDATED evidence price binding
|
|
960
|
+
* @param {object} opts
|
|
961
|
+
* @param {string} opts.issuedAt REQUIRED canonical ISO instant the license is issued at
|
|
962
|
+
* @returns {{ plan:string, customer:string, paidThrough:string, issuedAt:string }}
|
|
963
|
+
*/
|
|
964
|
+
function normalizeEvidenceEvent(event, binding, opts) {
|
|
965
|
+
if (event == null || typeof event !== "object" || Array.isArray(event)) {
|
|
966
|
+
throw new FulfillIntakeError(
|
|
967
|
+
"normalizeEvidenceEvent requires a parsed event { provider, priceId, customer, periodEnd }"
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
if (opts == null || typeof opts !== "object" || Array.isArray(opts)) {
|
|
971
|
+
throw new FulfillIntakeError(
|
|
972
|
+
"normalizeEvidenceEvent requires an opts object { issuedAt } (the injected clock; the core never reads it)"
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
if (typeof event.provider !== "string" || event.provider.trim() === "") {
|
|
977
|
+
throw new FulfillIntakeError("event `provider` must be a non-empty string");
|
|
978
|
+
}
|
|
979
|
+
if (typeof event.priceId !== "string" || event.priceId.trim() === "") {
|
|
980
|
+
throw new FulfillIntakeError("event `priceId` must be a non-empty string");
|
|
981
|
+
}
|
|
982
|
+
let planId;
|
|
983
|
+
try {
|
|
984
|
+
planId = resolveEvidencePlanId(binding, event.provider, event.priceId);
|
|
985
|
+
} catch (e) {
|
|
986
|
+
// Surface the binding's NAMED reason, but as a FulfillIntakeError so a handler
|
|
987
|
+
// catches ONE error type across the normalize seam.
|
|
988
|
+
throw new FulfillIntakeError(
|
|
989
|
+
`cannot normalize event: ${e && e.message ? e.message : String(e)}`
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
if (typeof event.customer !== "string" || event.customer.length === 0) {
|
|
994
|
+
throw new FulfillIntakeError("event `customer` must be a non-empty string");
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const paidThrough = _epochSecondsToCanonicalISO("periodEnd", event.periodEnd);
|
|
998
|
+
|
|
999
|
+
if (opts.issuedAt == null) {
|
|
1000
|
+
throw new FulfillIntakeError(
|
|
1001
|
+
"normalizeEvidenceEvent requires opts.issuedAt (a canonical ISO instant); the core never reads the system clock"
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
_requireCanonicalInstant("issuedAt", opts.issuedAt);
|
|
1005
|
+
|
|
1006
|
+
// The EXACT order shape fulfillEvidenceOrder consumes; provider event `type` is
|
|
1007
|
+
// advisory and intentionally NOT carried into the (provider-agnostic) order.
|
|
1008
|
+
return {
|
|
1009
|
+
plan: planId,
|
|
1010
|
+
customer: event.customer,
|
|
1011
|
+
paidThrough,
|
|
1012
|
+
issuedAt: opts.issuedAt,
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* intakeDedupKey(event) — the retry-stable idempotency key for an at-least-once
|
|
1018
|
+
* delivery. Derived ONLY from the event's own retry-stable content (provider, type,
|
|
1019
|
+
* priceId, customer, periodEnd) — NOT from the injected issuedAt — so the SAME event
|
|
1020
|
+
* delivered twice yields the IDENTICAL key, while a different customer / price /
|
|
1021
|
+
* period yields a DISTINCT key. Fields are JSON-encoded before hashing so a value can
|
|
1022
|
+
* never smuggle a delimiter to collide with another event.
|
|
1023
|
+
*
|
|
1024
|
+
* @param {object} event parseEvidenceEvent output
|
|
1025
|
+
* @returns {string} `vh-ev-intake:sha256:<hex>`
|
|
1026
|
+
*/
|
|
1027
|
+
function intakeDedupKey(event) {
|
|
1028
|
+
if (event == null || typeof event !== "object" || Array.isArray(event)) {
|
|
1029
|
+
throw new FulfillIntakeError("intakeDedupKey requires a parsed event object");
|
|
1030
|
+
}
|
|
1031
|
+
const provider = event.provider;
|
|
1032
|
+
const type = event.type;
|
|
1033
|
+
const priceId = event.priceId;
|
|
1034
|
+
const customer = event.customer;
|
|
1035
|
+
const periodEnd = event.periodEnd;
|
|
1036
|
+
if (typeof provider !== "string" || provider.length === 0) {
|
|
1037
|
+
throw new FulfillIntakeError("intakeDedupKey: event `provider` must be a non-empty string");
|
|
1038
|
+
}
|
|
1039
|
+
if (typeof priceId !== "string" || priceId.length === 0) {
|
|
1040
|
+
throw new FulfillIntakeError("intakeDedupKey: event `priceId` must be a non-empty string");
|
|
1041
|
+
}
|
|
1042
|
+
if (typeof customer !== "string" || customer.length === 0) {
|
|
1043
|
+
throw new FulfillIntakeError("intakeDedupKey: event `customer` must be a non-empty string");
|
|
1044
|
+
}
|
|
1045
|
+
if (typeof periodEnd !== "number" || !Number.isInteger(periodEnd)) {
|
|
1046
|
+
throw new FulfillIntakeError("intakeDedupKey: event `periodEnd` must be an integer epoch");
|
|
1047
|
+
}
|
|
1048
|
+
// Deterministic, injection-safe canonical form (JSON-encoded, fixed field order).
|
|
1049
|
+
const canonical = JSON.stringify([
|
|
1050
|
+
provider,
|
|
1051
|
+
typeof type === "string" ? type : null,
|
|
1052
|
+
priceId,
|
|
1053
|
+
customer,
|
|
1054
|
+
periodEnd,
|
|
1055
|
+
]);
|
|
1056
|
+
const hex = crypto.createHash("sha256").update(canonical, "utf8").digest("hex");
|
|
1057
|
+
return `vh-ev-intake:sha256:${hex}`;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
module.exports = {
|
|
1061
|
+
// shared
|
|
1062
|
+
FulfillIntakeError,
|
|
1063
|
+
// seam 1
|
|
1064
|
+
DEFAULT_TOLERANCE_SEC,
|
|
1065
|
+
SIGNATURE_REASONS,
|
|
1066
|
+
verifyProviderSignature,
|
|
1067
|
+
// seam 2a
|
|
1068
|
+
STRIPE_PROVIDER,
|
|
1069
|
+
SUPPORTED_STRIPE_EVENT_TYPES,
|
|
1070
|
+
DEFAULT_MAX_BODY_BYTES,
|
|
1071
|
+
parseEvidenceEvent,
|
|
1072
|
+
// seam 2b
|
|
1073
|
+
EVIDENCE_PRICE_BINDING_KIND,
|
|
1074
|
+
EVIDENCE_PRICE_BINDING_SCHEMA_VERSION,
|
|
1075
|
+
SUPPORTED_EVIDENCE_PRICE_BINDING_SCHEMA_VERSIONS,
|
|
1076
|
+
EvidencePriceBindingError,
|
|
1077
|
+
validateEvidencePriceBinding,
|
|
1078
|
+
resolveEvidencePlanId,
|
|
1079
|
+
// seam 2c
|
|
1080
|
+
normalizeEvidenceEvent,
|
|
1081
|
+
intakeDedupKey,
|
|
1082
|
+
};
|