@forestrie/forestrie-cli 0.8.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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +425 -0
  3. package/TRUST-MODEL.md +8 -0
  4. package/dist/cli.js +33811 -0
  5. package/dist/decode-receipt.d.ts +19 -0
  6. package/dist/decode-receipt.d.ts.map +1 -0
  7. package/dist/decode-receipt.js +19 -0
  8. package/dist/decode-receipt.js.map +1 -0
  9. package/dist/index.d.ts +11 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +11 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/lib/decode-receipt-cbor.d.ts +26 -0
  14. package/dist/lib/decode-receipt-cbor.d.ts.map +1 -0
  15. package/dist/lib/decode-receipt-cbor.js +182 -0
  16. package/dist/lib/decode-receipt-cbor.js.map +1 -0
  17. package/dist/lib/decode-receipt-decode.d.ts +103 -0
  18. package/dist/lib/decode-receipt-decode.d.ts.map +1 -0
  19. package/dist/lib/decode-receipt-decode.js +237 -0
  20. package/dist/lib/decode-receipt-decode.js.map +1 -0
  21. package/dist/lib/decode-receipt-labels.d.ts +54 -0
  22. package/dist/lib/decode-receipt-labels.d.ts.map +1 -0
  23. package/dist/lib/decode-receipt-labels.js +117 -0
  24. package/dist/lib/decode-receipt-labels.js.map +1 -0
  25. package/dist/lib/decode-receipt-render.d.ts +11 -0
  26. package/dist/lib/decode-receipt-render.d.ts.map +1 -0
  27. package/dist/lib/decode-receipt-render.js +242 -0
  28. package/dist/lib/decode-receipt-render.js.map +1 -0
  29. package/package.json +83 -0
  30. package/src/cli.ts +5 -0
  31. package/src/command.ts +51 -0
  32. package/src/commands/admin-onboard-token.ts +51 -0
  33. package/src/commands/admin.ts +20 -0
  34. package/src/commands/complete-grant.ts +41 -0
  35. package/src/commands/create-consistency-proof.ts +44 -0
  36. package/src/commands/create-log.ts +95 -0
  37. package/src/commands/decode-receipt.ts +20 -0
  38. package/src/commands/delegate.ts +55 -0
  39. package/src/commands/deploy.ts +72 -0
  40. package/src/commands/fetch-accumulator.ts +49 -0
  41. package/src/commands/onboard-genesis.ts +70 -0
  42. package/src/commands/onboard-request.ts +59 -0
  43. package/src/commands/register-grant.ts +74 -0
  44. package/src/commands/register.ts +54 -0
  45. package/src/commands/resolve-receipt.ts +101 -0
  46. package/src/commands/sign-statement.ts +53 -0
  47. package/src/commands/verify-grant.ts +89 -0
  48. package/src/commands/verify.ts +92 -0
  49. package/src/commoncli.ts +34 -0
  50. package/src/decode-receipt.ts +56 -0
  51. package/src/index.ts +10 -0
  52. package/src/lib/bytes.ts +7 -0
  53. package/src/lib/checkpoint-provider.ts +297 -0
  54. package/src/lib/complete-grant-derive.ts +237 -0
  55. package/src/lib/consistency-proof.ts +271 -0
  56. package/src/lib/create-log-build.ts +168 -0
  57. package/src/lib/create-log-prepare.ts +75 -0
  58. package/src/lib/create-receipt-chain-verify.ts +267 -0
  59. package/src/lib/create-receipt-derive.ts +208 -0
  60. package/src/lib/create-receipt-inputs.ts +79 -0
  61. package/src/lib/decode-checkpoint-calldata.ts +188 -0
  62. package/src/lib/decode-receipt-cbor.ts +202 -0
  63. package/src/lib/decode-receipt-decode.ts +333 -0
  64. package/src/lib/decode-receipt-labels.ts +132 -0
  65. package/src/lib/decode-receipt-render.ts +284 -0
  66. package/src/lib/delegate-flow.ts +289 -0
  67. package/src/lib/delegate-voucher.ts +122 -0
  68. package/src/lib/deploy-artifact.ts +79 -0
  69. package/src/lib/deploy-bootstrap.ts +100 -0
  70. package/src/lib/deploy-flow.ts +184 -0
  71. package/src/lib/deploy-genesis-log-id.ts +29 -0
  72. package/src/lib/deploy-rpc.ts +40 -0
  73. package/src/lib/deployment-record.ts +65 -0
  74. package/src/lib/fsio.ts +91 -0
  75. package/src/lib/grant-flags-local.ts +19 -0
  76. package/src/lib/onboard-attestation.ts +120 -0
  77. package/src/lib/onboard-genesis-body.ts +94 -0
  78. package/src/lib/openssl-error-queue.ts +56 -0
  79. package/src/lib/register-flow.ts +292 -0
  80. package/src/lib/register-grant-build.ts +173 -0
  81. package/src/lib/register-grant-complete.ts +47 -0
  82. package/src/lib/register-grant-flow.ts +258 -0
  83. package/src/lib/resolve-receipt-freshen.ts +221 -0
  84. package/src/lib/sign-statement-build.ts +117 -0
  85. package/src/lib/sign-statement-key.ts +145 -0
  86. package/src/lib/verify-anchored.ts +326 -0
  87. package/src/lib/verify-checkpoint-chain.ts +160 -0
  88. package/src/lib/verify-eventscan.ts +214 -0
  89. package/src/lib/verify-inputs.ts +143 -0
  90. package/src/lib/verify-known-accumulator.ts +161 -0
  91. package/src/lib/verify-report.ts +265 -0
  92. package/src/lib/x402-payment.ts +115 -0
  93. package/src/main/admin-onboard-token.ts +216 -0
  94. package/src/main/complete-grant.ts +265 -0
  95. package/src/main/create-consistency-proof.ts +83 -0
  96. package/src/main/create-log.ts +347 -0
  97. package/src/main/create-receipt.ts +763 -0
  98. package/src/main/decode-receipt.ts +72 -0
  99. package/src/main/delegate.ts +141 -0
  100. package/src/main/deploy.ts +273 -0
  101. package/src/main/fetch-accumulator.ts +181 -0
  102. package/src/main/not-implemented.ts +43 -0
  103. package/src/main/onboard-genesis.ts +192 -0
  104. package/src/main/onboard-request.ts +333 -0
  105. package/src/main/register-grant.ts +270 -0
  106. package/src/main/register.ts +243 -0
  107. package/src/main/sign-statement.ts +168 -0
  108. package/src/main/verify.ts +767 -0
  109. package/src/options/admin-onboard-token.ts +48 -0
  110. package/src/options/common.ts +69 -0
  111. package/src/options/complete-grant.ts +40 -0
  112. package/src/options/create-consistency-proof.ts +63 -0
  113. package/src/options/create-log.ts +99 -0
  114. package/src/options/create-receipt.ts +243 -0
  115. package/src/options/decode-receipt.ts +25 -0
  116. package/src/options/delegate.ts +93 -0
  117. package/src/options/deploy.ts +105 -0
  118. package/src/options/fetch-accumulator.ts +62 -0
  119. package/src/options/onboard-genesis.ts +74 -0
  120. package/src/options/onboard-request.ts +57 -0
  121. package/src/options/register-grant.ts +72 -0
  122. package/src/options/register.ts +71 -0
  123. package/src/options/sign-statement.ts +64 -0
  124. package/src/options/verify.ts +250 -0
@@ -0,0 +1,763 @@
1
+ import { basename } from "node:path";
2
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import type { Out } from "@forestrie/cli-kit/reporting";
4
+ import type { CreateReceiptOptions } from "../options/create-receipt.js";
5
+ import {
6
+ CreateReceiptFailure,
7
+ deriveCheckpointReceipt,
8
+ type CreateReceiptReason,
9
+ type DerivedReceipt,
10
+ } from "../lib/create-receipt-derive.js";
11
+ import {
12
+ loadCreateReceiptArtifacts,
13
+ type CreateReceiptArtifacts,
14
+ } from "../lib/create-receipt-inputs.js";
15
+ import { grantCommitmentHashFromGrant } from "@forestrie/receipt-verify";
16
+ import {
17
+ loadPayloadVerifyArtifacts,
18
+ loadVerifyArtifacts,
19
+ } from "../lib/verify-inputs.js";
20
+ import { loadCheckpointChainFiles } from "../lib/verify-checkpoint-chain.js";
21
+ import {
22
+ assertSnapshotBinding,
23
+ decodeKnownAccumulator,
24
+ type KnownAccumulator,
25
+ } from "../lib/verify-known-accumulator.js";
26
+ import {
27
+ freshenFromCalldataChain,
28
+ freshenFromSthChain,
29
+ type FreshenResult,
30
+ } from "../lib/resolve-receipt-freshen.js";
31
+ import {
32
+ ChainVerifyFailure,
33
+ verifyChainAnchored,
34
+ type ChainVerifyOutcome,
35
+ type ChainVerifyReason,
36
+ type ChainVerifyResult,
37
+ } from "../lib/create-receipt-chain-verify.js";
38
+
39
+ /** Where an operational error broke the run. */
40
+ export type CreateReceiptErrorStage = "input" | "parse" | "derive";
41
+
42
+ /**
43
+ * `--json` operational-error shape on stdout — stable stage + reason
44
+ * tokens (input load/decode; artefact parse; proof/receipt derivation,
45
+ * including `leaf_not_in_massif` / `checkpoint_does_not_cover_leaf`).
46
+ */
47
+ export type CreateReceiptErrorReport = {
48
+ error:
49
+ | "create_receipt_input_failed"
50
+ | "create_receipt_parse_failed"
51
+ | "create_receipt_derive_failed";
52
+ command: "create-receipt";
53
+ stage: CreateReceiptErrorStage;
54
+ reason?: CreateReceiptReason;
55
+ message: string;
56
+ };
57
+
58
+ /**
59
+ * Exit codes for chain-anchored (`--univocity`) mode — one code per
60
+ * verification outcome so scripts can branch without parsing text. Operational
61
+ * errors (bad input / RPC) stay on the shared exit 1.
62
+ */
63
+ export const CHAIN_EXIT_CODE: Record<ChainVerifyOutcome, number> = {
64
+ verified: 0,
65
+ not_yet_anchored: 2,
66
+ coverage: 3,
67
+ peak_mismatch: 4,
68
+ // plan-2607-18 W3: `wrong_massif` (V2/V3) and `accumulator_short` (V4) split
69
+ // out of the coverage / peak_mismatch buckets; next free codes in the 2/3/4
70
+ // scheme. No collision with `verify`, which only uses 0/1.
71
+ wrong_massif: 5,
72
+ accumulator_short: 6,
73
+ };
74
+
75
+ /**
76
+ * `--json` chain-mode success/verdict shape (report-only — NO signed
77
+ * receipt; the selling point is receipt-free, on-chain verification). The
78
+ * shape is a contract: `outcome` + the on-chain size, computed peak, matched
79
+ * slot, and PASS/FAIL.
80
+ */
81
+ export type CreateReceiptChainReport = {
82
+ command: "create-receipt";
83
+ anchor: "chain";
84
+ /** True only when `outcome === "verified"`. */
85
+ ok: boolean;
86
+ outcome: ChainVerifyOutcome;
87
+ univocity: string;
88
+ logId: string;
89
+ leaf: {
90
+ mmrIndex: string;
91
+ source: "mmr-index" | "entry-id";
92
+ entryId?: string;
93
+ idtimestamp?: string;
94
+ };
95
+ massif: {
96
+ massifIndex: string;
97
+ massifHeight: number;
98
+ firstIndex: string;
99
+ lastIndex: string;
100
+ };
101
+ onchain: {
102
+ size: string;
103
+ peakCount: number;
104
+ };
105
+ peakCheck?: {
106
+ proofLength: number;
107
+ peakIndex: number;
108
+ peakMMRIndex: string;
109
+ computedPeakHex: string;
110
+ onchainPeakHex: string;
111
+ matched: boolean;
112
+ };
113
+ };
114
+
115
+ /** `--json` chain-mode operational-error shape (bad massif / RPC). */
116
+ export type CreateReceiptChainErrorReport = {
117
+ error: "create_receipt_chain_failed";
118
+ command: "create-receipt";
119
+ anchor: "chain";
120
+ stage: "parse" | "chain";
121
+ reason?: ChainVerifyReason;
122
+ message: string;
123
+ };
124
+
125
+ /** Structured `--json` success report — the shape is a contract. */
126
+ export type CreateReceiptReport = {
127
+ command: "create-receipt";
128
+ anchor: "checkpoint";
129
+ leaf: {
130
+ mmrIndex: string;
131
+ source: "mmr-index" | "entry-id";
132
+ entryId?: string;
133
+ idtimestamp?: string;
134
+ };
135
+ massif: {
136
+ massifIndex: string;
137
+ massifHeight: number;
138
+ firstIndex: string;
139
+ lastIndex: string;
140
+ };
141
+ checkpoint: {
142
+ sealedSize: string;
143
+ peakCount: number;
144
+ };
145
+ proof: {
146
+ length: number;
147
+ peakIndex: number;
148
+ peakMMRIndex: string;
149
+ };
150
+ certCopied: boolean;
151
+ receiptBytes: number;
152
+ /** Output path, when `--out` was given. */
153
+ out?: string;
154
+ /** Base64 receipt bytes, when no `--out` (JSON owns stdout). */
155
+ receiptB64?: string;
156
+ };
157
+
158
+ const ERROR_CODES: Record<
159
+ CreateReceiptErrorStage,
160
+ CreateReceiptErrorReport["error"]
161
+ > = {
162
+ input: "create_receipt_input_failed",
163
+ parse: "create_receipt_parse_failed",
164
+ derive: "create_receipt_derive_failed",
165
+ };
166
+
167
+ /** Structured envelope under `--json`; one clean line on stderr otherwise. */
168
+ function reportRunError(
169
+ out: Out,
170
+ options: CreateReceiptOptions,
171
+ stage: CreateReceiptErrorStage,
172
+ err: unknown,
173
+ ): void {
174
+ const message = err instanceof Error ? err.message : String(err);
175
+ const reason = err instanceof CreateReceiptFailure ? err.reason : undefined;
176
+ if (options.json) {
177
+ const report: CreateReceiptErrorReport = {
178
+ error: ERROR_CODES[stage],
179
+ command: "create-receipt",
180
+ stage,
181
+ ...(reason !== undefined ? { reason } : {}),
182
+ message,
183
+ };
184
+ out.out(JSON.stringify(report, null, 2));
185
+ } else {
186
+ out.warn("forestrie create-receipt: %s: %s", stage, message);
187
+ }
188
+ process.exitCode = 1;
189
+ }
190
+
191
+ /** One-line human explanation for each non-verified outcome. */
192
+ const CHAIN_OUTCOME_NARRATION: Record<ChainVerifyOutcome, string> = {
193
+ verified: "computed peak matches the on-chain accumulator",
194
+ not_yet_anchored:
195
+ "leaf postdates the last on-chain anchor (mmrIndex >= on-chain size) — anchor lag",
196
+ wrong_massif:
197
+ "the massif blob is for a different massif than the leaf",
198
+ coverage:
199
+ "local massif blob does not hold nodes up to the on-chain size — fetch the covering massif",
200
+ accumulator_short:
201
+ "the on-chain accumulator holds fewer peaks than the leaf's proof selects — chain state is behind or truncated",
202
+ peak_mismatch:
203
+ "computed peak differs from the anchored peak — local node data does not match the chain",
204
+ };
205
+
206
+ /**
207
+ * FOR-345 (phase 2, plan-2607-15 §3/§6): chain-anchored, REPORT-ONLY
208
+ * verification. No `.sth`, no signed receipt — the selling point is
209
+ * receipt-free verification, because the checkpoint signature was already
210
+ * enforced on-chain at publish. Reads `logState(bytes32)`, rebuilds the
211
+ * leaf's inclusion path at the on-chain size, and compares the computed peak
212
+ * with `accumulator[peakIndex]`. There is NO exact-size constraint: any
213
+ * `mmrIndex < size` works given local node data to that size.
214
+ */
215
+ async function runChainAnchored(
216
+ out: Out,
217
+ options: CreateReceiptOptions,
218
+ artifacts: CreateReceiptArtifacts,
219
+ ): Promise<void> {
220
+ let result: ChainVerifyResult;
221
+ try {
222
+ result = await verifyChainAnchored({
223
+ massifBytes: artifacts.massifBytes,
224
+ mmrIndex: artifacts.leaf.mmrIndex,
225
+ // Options parsing guarantees these in chain mode.
226
+ univocity: options.univocity!,
227
+ logId: options.logId!,
228
+ rpcUrl: options.rpcUrl!,
229
+ });
230
+ } catch (err) {
231
+ reportChainRunError(out, options, err);
232
+ return;
233
+ }
234
+
235
+ const leaf = artifacts.leaf;
236
+ const ok = result.outcome === "verified";
237
+
238
+ if (options.json) {
239
+ const report: CreateReceiptChainReport = {
240
+ command: "create-receipt",
241
+ anchor: "chain",
242
+ ok,
243
+ outcome: result.outcome,
244
+ univocity: options.univocity!,
245
+ logId: options.logId!,
246
+ leaf: {
247
+ mmrIndex: leaf.mmrIndex.toString(10),
248
+ source: leaf.source,
249
+ ...(leaf.entryId !== undefined ? { entryId: leaf.entryId } : {}),
250
+ ...(leaf.idtimestamp !== undefined
251
+ ? { idtimestamp: leaf.idtimestamp.toString(10) }
252
+ : {}),
253
+ },
254
+ massif: {
255
+ massifIndex: result.massif.massifIndex.toString(10),
256
+ massifHeight: result.massif.massifHeight,
257
+ firstIndex: result.massif.firstIndex.toString(10),
258
+ lastIndex: result.massif.lastIndex.toString(10),
259
+ },
260
+ onchain: {
261
+ size: result.onchain.size.toString(10),
262
+ peakCount: result.onchain.peakCount,
263
+ },
264
+ ...(result.peakCheck !== undefined
265
+ ? {
266
+ peakCheck: {
267
+ proofLength: result.peakCheck.proofLength,
268
+ peakIndex: result.peakCheck.peakIndex,
269
+ peakMMRIndex: result.peakCheck.peakMMRIndex.toString(10),
270
+ computedPeakHex: result.peakCheck.computedPeakHex,
271
+ onchainPeakHex: result.peakCheck.onchainPeakHex,
272
+ matched: result.peakCheck.matched,
273
+ },
274
+ }
275
+ : {}),
276
+ };
277
+ out.out(JSON.stringify(report, null, 2));
278
+ process.exitCode = CHAIN_EXIT_CODE[result.outcome];
279
+ return;
280
+ }
281
+
282
+ // Human narration: on-chain size, computed peak, matched slot, PASS/FAIL.
283
+ out.print(
284
+ "create-receipt: on-chain — %s @ log %s: size %s, %d peak(s)",
285
+ options.univocity,
286
+ options.logId,
287
+ result.onchain.size.toString(10),
288
+ result.onchain.peakCount,
289
+ );
290
+ out.print(
291
+ "create-receipt: leaf — mmrIndex %s (from --%s)",
292
+ leaf.mmrIndex.toString(10),
293
+ leaf.source,
294
+ );
295
+ if (result.peakCheck !== undefined) {
296
+ out.print(
297
+ "create-receipt: proof — %d node(s) to peak %d/%d (mmrIndex %s)",
298
+ result.peakCheck.proofLength,
299
+ result.peakCheck.peakIndex + 1,
300
+ result.onchain.peakCount,
301
+ result.peakCheck.peakMMRIndex.toString(10),
302
+ );
303
+ out.print(
304
+ "create-receipt: computed — %s",
305
+ result.peakCheck.computedPeakHex,
306
+ );
307
+ out.print(
308
+ "create-receipt: on-chain — %s (slot %d)",
309
+ result.peakCheck.onchainPeakHex,
310
+ result.peakCheck.peakIndex,
311
+ );
312
+ }
313
+ if (ok) {
314
+ out.out(
315
+ `PASS: leaf verified against the on-chain accumulator (size ${result.onchain.size}, peak slot ${result.peakCheck!.peakIndex}) — no receipt needed`,
316
+ );
317
+ } else {
318
+ // `wrong_massif` names the concrete mmrIndex the caller must supply the
319
+ // massif for (plan-2607-18 W3: "supply the massif containing mmrIndex N").
320
+ const detail =
321
+ result.outcome === "wrong_massif"
322
+ ? `${CHAIN_OUTCOME_NARRATION.wrong_massif} (supply the massif containing mmrIndex ${leaf.mmrIndex})`
323
+ : CHAIN_OUTCOME_NARRATION[result.outcome];
324
+ out.out(`FAIL: ${result.outcome} — ${detail}`);
325
+ }
326
+ process.exitCode = CHAIN_EXIT_CODE[result.outcome];
327
+ }
328
+
329
+ /** Structured envelope for chain-mode operational errors (input / RPC). */
330
+ function reportChainRunError(
331
+ out: Out,
332
+ options: CreateReceiptOptions,
333
+ err: unknown,
334
+ ): void {
335
+ const message = err instanceof Error ? err.message : String(err);
336
+ const stage = err instanceof ChainVerifyFailure ? err.stage : "chain";
337
+ const reason = err instanceof ChainVerifyFailure ? err.reason : undefined;
338
+ if (options.json) {
339
+ const report: CreateReceiptChainErrorReport = {
340
+ error: "create_receipt_chain_failed",
341
+ command: "create-receipt",
342
+ anchor: "chain",
343
+ stage,
344
+ ...(reason !== undefined ? { reason } : {}),
345
+ message,
346
+ };
347
+ out.out(JSON.stringify(report, null, 2));
348
+ } else {
349
+ out.warn("forestrie create-receipt: %s: %s", stage, message);
350
+ }
351
+ process.exitCode = 1;
352
+ }
353
+
354
+ /**
355
+ * FOR-345 self-serve receipt derivation, two anchor modes:
356
+ *
357
+ * - `checkpoint` (phase 1, plan-2607-15 §6): rebuild the leaf→peak inclusion
358
+ * path from the massif blob and attach it to the checkpoint's pre-signed
359
+ * peak receipt (`buildReceiptOffline`). No network, no key, no operator
360
+ * API call; the result is verify-equivalent with an API-issued receipt.
361
+ * Receipt bytes go to `--out`, or raw to stdout — except under `--json`
362
+ * without `--out`, where they ride base64 inside the report.
363
+ * - `chain` (phase 2, plan-2607-15 §3): REPORT-ONLY verification of the
364
+ * computed peak against the on-chain accumulator (`--univocity`). No
365
+ * `.sth`, no signed receipt — its selling point is receipt-free
366
+ * verification. Distinct exit codes per outcome (see `CHAIN_EXIT_CODE`).
367
+ */
368
+ export async function runCreateReceipt(
369
+ out: Out,
370
+ options: CreateReceiptOptions,
371
+ ): Promise<void> {
372
+ if (
373
+ options.anchor === "freshen-sth" ||
374
+ options.anchor === "freshen-calldata"
375
+ ) {
376
+ await runFreshen(out, options);
377
+ return;
378
+ }
379
+
380
+ let artifacts: CreateReceiptArtifacts;
381
+ try {
382
+ artifacts = loadCreateReceiptArtifacts(options);
383
+ } catch (err) {
384
+ reportRunError(out, options, "input", err);
385
+ return;
386
+ }
387
+
388
+ if (options.anchor === "chain") {
389
+ await runChainAnchored(out, options, artifacts);
390
+ return;
391
+ }
392
+
393
+ let derived: DerivedReceipt;
394
+ try {
395
+ derived = await deriveCheckpointReceipt({
396
+ massifBytes: artifacts.massifBytes,
397
+ // Checkpoint mode: options parsing guarantees --checkpoint.
398
+ checkpointBytes: artifacts.checkpointBytes!,
399
+ mmrIndex: artifacts.leaf.mmrIndex,
400
+ });
401
+ } catch (err) {
402
+ const stage = err instanceof CreateReceiptFailure ? err.stage : "derive";
403
+ reportRunError(out, options, stage, err);
404
+ return;
405
+ }
406
+
407
+ const { receiptCbor, details } = derived;
408
+ try {
409
+ if (options.out !== undefined) {
410
+ writeFileSync(options.out, receiptCbor);
411
+ }
412
+ } catch (err) {
413
+ reportRunError(out, options, "input", err);
414
+ return;
415
+ }
416
+
417
+ const leaf = artifacts.leaf;
418
+ if (options.json) {
419
+ const report: CreateReceiptReport = {
420
+ command: "create-receipt",
421
+ anchor: "checkpoint",
422
+ leaf: {
423
+ mmrIndex: leaf.mmrIndex.toString(10),
424
+ source: leaf.source,
425
+ ...(leaf.entryId !== undefined ? { entryId: leaf.entryId } : {}),
426
+ ...(leaf.idtimestamp !== undefined
427
+ ? { idtimestamp: leaf.idtimestamp.toString(10) }
428
+ : {}),
429
+ },
430
+ massif: {
431
+ massifIndex: details.massifIndex.toString(10),
432
+ massifHeight: details.massifHeight,
433
+ firstIndex: details.firstIndex.toString(10),
434
+ lastIndex: details.lastIndex.toString(10),
435
+ },
436
+ checkpoint: {
437
+ sealedSize: details.sealedSize.toString(10),
438
+ peakCount: details.peakCount,
439
+ },
440
+ proof: {
441
+ length: details.proofLength,
442
+ peakIndex: details.peakIndex,
443
+ peakMMRIndex: details.peakMMRIndex.toString(10),
444
+ },
445
+ certCopied: details.certCopied,
446
+ receiptBytes: receiptCbor.length,
447
+ ...(options.out !== undefined
448
+ ? { out: options.out }
449
+ : { receiptB64: Buffer.from(receiptCbor).toString("base64") }),
450
+ };
451
+ out.out(JSON.stringify(report, null, 2));
452
+ return;
453
+ }
454
+
455
+ if (options.out === undefined) {
456
+ // Raw CBOR to stdout (pipeable); the narration stays on stderr.
457
+ writeFileSync(1, receiptCbor);
458
+ }
459
+ out.print(
460
+ "create-receipt: massif — index %s (height %d, mmr indexes %s..%s)",
461
+ details.massifIndex.toString(10),
462
+ details.massifHeight,
463
+ details.firstIndex.toString(10),
464
+ details.lastIndex.toString(10),
465
+ );
466
+ out.print(
467
+ "create-receipt: leaf — mmrIndex %s (from --%s)",
468
+ leaf.mmrIndex.toString(10),
469
+ leaf.source,
470
+ );
471
+ out.print(
472
+ "create-receipt: checkpoint — sealed size %s, %d peak(s)",
473
+ details.sealedSize.toString(10),
474
+ details.peakCount,
475
+ );
476
+ out.print(
477
+ "create-receipt: proof — %d node(s) to peak %d/%d (mmrIndex %s)",
478
+ details.proofLength,
479
+ details.peakIndex + 1,
480
+ details.peakCount,
481
+ details.peakMMRIndex.toString(10),
482
+ );
483
+ out.print(
484
+ "create-receipt: cert — delegation cert copied: %s",
485
+ details.certCopied ? "yes" : "no",
486
+ );
487
+ out.print(
488
+ "create-receipt: receipt — %d bytes -> %s",
489
+ receiptCbor.length,
490
+ options.out ?? "stdout",
491
+ );
492
+ }
493
+
494
+ // ---------------------------------------------------------------------------
495
+ // Freshen source (FOR-418 Phase 3c): tile-free re-anchor of a stale --receipt
496
+ // against a retained .sth checkpoint chain. New surface, so its report uses the
497
+ // forward name `resolve-receipt`; the tile-mode reports keep `create-receipt`
498
+ // for contract stability.
499
+ // ---------------------------------------------------------------------------
500
+
501
+ /** Freshen source, for the report + narration. */
502
+ type FreshenSource = "checkpoint-chain" | "calldata";
503
+
504
+ /** `--json` freshen success report — the shape is a contract. */
505
+ export type ResolveReceiptFreshenReport = {
506
+ command: "resolve-receipt";
507
+ source: FreshenSource;
508
+ leaf: { mmrIndex: string; entryId?: string };
509
+ chain: { links: number; sealedSize: string; sources: string[] };
510
+ proof: { length: number };
511
+ receiptBytes: number;
512
+ out?: string;
513
+ receiptB64?: string;
514
+ /** True when the `--receipt` file was rewritten in place. */
515
+ inPlace?: boolean;
516
+ /** Present when `--known-accumulator` bound the freshened state. */
517
+ knownAccumulator?: { matched: boolean };
518
+ };
519
+
520
+ /** `--json` freshen operational-error shape. */
521
+ export type ResolveReceiptFreshenErrorReport = {
522
+ error: "resolve_receipt_freshen_failed";
523
+ command: "resolve-receipt";
524
+ source: FreshenSource;
525
+ stage: "input" | "freshen";
526
+ message: string;
527
+ };
528
+
529
+ function reportFreshenError(
530
+ out: Out,
531
+ options: CreateReceiptOptions,
532
+ source: FreshenSource,
533
+ stage: "input" | "freshen",
534
+ err: unknown,
535
+ ): void {
536
+ const message = err instanceof Error ? err.message : String(err);
537
+ if (options.json) {
538
+ const report: ResolveReceiptFreshenErrorReport = {
539
+ error: "resolve_receipt_freshen_failed",
540
+ command: "resolve-receipt",
541
+ source,
542
+ stage,
543
+ message,
544
+ };
545
+ out.out(JSON.stringify(report, null, 2));
546
+ } else {
547
+ out.warn("forestrie resolve-receipt: %s: %s", stage, message);
548
+ }
549
+ process.exitCode = 1;
550
+ }
551
+
552
+ /** Load + bind the optional `--known-accumulator` snapshot for the freshen run. */
553
+ function loadFreshenSnapshot(
554
+ options: CreateReceiptOptions,
555
+ ): KnownAccumulator | undefined {
556
+ if (options.knownAccumulator === undefined) return undefined;
557
+ const snapshot = decodeKnownAccumulator(
558
+ new Uint8Array(readFileSync(options.knownAccumulator)),
559
+ );
560
+ // Reject a snapshot for the wrong log/contract before it can anchor anything.
561
+ // (Only calldata freshen supplies --univocity/--log-id; the `.sth` path skips
562
+ // the binding it has no target for.)
563
+ assertSnapshotBinding(snapshot, {
564
+ univocity: options.univocity,
565
+ logId: options.logId,
566
+ });
567
+ return snapshot;
568
+ }
569
+
570
+ /** The stale receipt + its leaf inputs, from whichever leaf source was given. */
571
+ type FreshenLeaf = {
572
+ receiptCbor: Uint8Array;
573
+ /** Leaf ContentHash: `SHA-256(payload)` (statement) or the grant commitment. */
574
+ inner: Uint8Array;
575
+ idtimestampBe8: Uint8Array;
576
+ };
577
+
578
+ /**
579
+ * Load the stale receipt + recompute the leaf ContentHash the same way `verify`
580
+ * does — from `--payload` (statement: `SHA-256(payload)`) or the committed grant
581
+ * (`--committed-grant`/`-file`). Options parsing guarantees exactly one.
582
+ */
583
+ async function loadFreshenLeaf(
584
+ options: CreateReceiptOptions,
585
+ ): Promise<FreshenLeaf> {
586
+ if (options.payload !== undefined) {
587
+ const a = loadPayloadVerifyArtifacts({
588
+ genesis: undefined,
589
+ receipt: options.receipt!,
590
+ payload: options.payload,
591
+ entryId: options.entryId!,
592
+ });
593
+ const inner = new Uint8Array(
594
+ await crypto.subtle.digest("SHA-256", new Uint8Array(a.payload)),
595
+ );
596
+ return { receiptCbor: a.receiptCbor, inner, idtimestampBe8: a.idtimestampBe8 };
597
+ }
598
+ const a = loadVerifyArtifacts({
599
+ genesis: undefined,
600
+ receipt: options.receipt!,
601
+ committedGrant: options.committedGrant,
602
+ committedGrantFile: options.committedGrantFile,
603
+ entryId: options.entryId,
604
+ });
605
+ const inner = await grantCommitmentHashFromGrant(a.grant);
606
+ return { receiptCbor: a.receiptCbor, inner, idtimestampBe8: a.idtimestampBe8 };
607
+ }
608
+
609
+ /** Build the FreshenResult from whichever tile-free source the flags selected. */
610
+ async function freshenFromSource(
611
+ options: CreateReceiptOptions,
612
+ leaf: FreshenLeaf,
613
+ ): Promise<FreshenResult> {
614
+ const knownAccumulator = loadFreshenSnapshot(options);
615
+ if (options.anchor === "freshen-calldata") {
616
+ // Options parsing guarantees these in calldata-freshen mode.
617
+ const latestCheckpointBytes = new Uint8Array(
618
+ readFileSync(options.checkpoint!),
619
+ );
620
+ return freshenFromCalldataChain({
621
+ oldReceiptBytes: leaf.receiptCbor,
622
+ inner: leaf.inner,
623
+ idtimestampBe8: leaf.idtimestampBe8,
624
+ univocity: options.univocity!,
625
+ logId: options.logId!,
626
+ rpcUrl: options.rpcUrl!,
627
+ latestCheckpointBytes,
628
+ knownAccumulator,
629
+ });
630
+ }
631
+ const chain = loadCheckpointChainFiles(options.checkpointChain!);
632
+ return freshenFromSthChain({
633
+ oldReceiptBytes: leaf.receiptCbor,
634
+ inner: leaf.inner,
635
+ idtimestampBe8: leaf.idtimestampBe8,
636
+ checkpoints: chain.checkpoints,
637
+ sourceRefs: chain.files.map((f) => basename(f)),
638
+ knownAccumulator,
639
+ });
640
+ }
641
+
642
+ /**
643
+ * Freshen a stale `--receipt` tile-free. Reuses `verify`'s leaf-value loaders
644
+ * (recomputed identically from `--payload` or the committed grant), reads the
645
+ * climb material from the selected source — a retained `.sth` chain
646
+ * (`--checkpoint-chain`; emitted under the chain's own tail checkpoint) or the
647
+ * on-chain `publishCheckpoint` calldata (`--rpc-url`/`--univocity`/`--log-id` +
648
+ * a separately-supplied latest `--checkpoint` for emission) — extends the
649
+ * receipt's old path to the current peak, and re-emits. Both sources produce a
650
+ * receipt re-anchored to the current accumulator; the trust posture (freshness
651
+ * vs signer provenance) is chosen at verify. See the receipt trust model:
652
+ * https://github.com/forestrie/protocol/blob/main/spec/receipt-trust-model.md
653
+ */
654
+ async function runFreshen(
655
+ out: Out,
656
+ options: CreateReceiptOptions,
657
+ ): Promise<void> {
658
+ const source: FreshenSource =
659
+ options.anchor === "freshen-calldata" ? "calldata" : "checkpoint-chain";
660
+
661
+ let leaf: FreshenLeaf;
662
+ try {
663
+ leaf = await loadFreshenLeaf(options);
664
+ } catch (err) {
665
+ reportFreshenError(out, options, source, "input", err);
666
+ return;
667
+ }
668
+
669
+ let result: FreshenResult;
670
+ try {
671
+ result = await freshenFromSource(options, leaf);
672
+ } catch (err) {
673
+ // Chain read / fold / self-check failures are the freshen stage; a bad
674
+ // --checkpoint / --checkpoint-chain path surfaces here too but reads as
675
+ // input — keep the single "freshen" stage token, the message names it.
676
+ reportFreshenError(out, options, source, "freshen", err);
677
+ return;
678
+ }
679
+
680
+ const { receiptCbor, details } = result;
681
+ // --in-place rewrites the stale receipt file; otherwise --out, else stdout.
682
+ const writeTarget = options.inPlace ? options.receipt! : options.out;
683
+ try {
684
+ if (options.inPlace) {
685
+ // Crash-safe overwrite of the source receipt: write a sibling temp then
686
+ // atomically rename, so a failed write never truncates the original.
687
+ const tmp = `${writeTarget}.freshen.tmp`;
688
+ writeFileSync(tmp, receiptCbor);
689
+ renameSync(tmp, writeTarget!);
690
+ } else if (writeTarget !== undefined) {
691
+ writeFileSync(writeTarget, receiptCbor);
692
+ }
693
+ } catch (err) {
694
+ reportFreshenError(out, options, source, "input", err);
695
+ return;
696
+ }
697
+
698
+ if (options.json) {
699
+ const report: ResolveReceiptFreshenReport = {
700
+ command: "resolve-receipt",
701
+ source,
702
+ leaf: {
703
+ mmrIndex: details.leafMmrIndex.toString(10),
704
+ ...(options.entryId !== undefined ? { entryId: options.entryId } : {}),
705
+ },
706
+ chain: {
707
+ links: details.chainLinks,
708
+ sealedSize: details.sealedSize.toString(10),
709
+ sources: details.sourceRefs,
710
+ },
711
+ proof: { length: details.proofLength },
712
+ receiptBytes: receiptCbor.length,
713
+ ...(writeTarget !== undefined
714
+ ? { out: writeTarget }
715
+ : { receiptB64: Buffer.from(receiptCbor).toString("base64") }),
716
+ ...(options.inPlace ? { inPlace: true } : {}),
717
+ ...(details.knownAccumulatorMatched !== undefined
718
+ ? { knownAccumulator: { matched: details.knownAccumulatorMatched } }
719
+ : {}),
720
+ };
721
+ out.out(JSON.stringify(report, null, 2));
722
+ return;
723
+ }
724
+
725
+ if (writeTarget === undefined) {
726
+ // Raw CBOR to stdout (pipeable); narration stays on stderr.
727
+ writeFileSync(1, receiptCbor);
728
+ }
729
+ const trust =
730
+ source === "calldata"
731
+ ? "climb from on-chain publishCheckpoint calldata (authority checked on-chain at publish), emitted under the supplied latest .sth"
732
+ : "climb from the retained .sth chain, emitted under its own tail checkpoint";
733
+ const unit = source === "calldata" ? "calldata link(s)" : ".sth link(s)";
734
+ out.print(
735
+ "resolve-receipt: freshen — re-anchored to the current accumulator, no tiles; %s",
736
+ trust,
737
+ );
738
+ out.print(
739
+ "resolve-receipt: leaf — mmrIndex %s",
740
+ details.leafMmrIndex.toString(10),
741
+ );
742
+ out.print(
743
+ "resolve-receipt: chain — %d %s -> sealed size %s",
744
+ details.chainLinks,
745
+ unit,
746
+ details.sealedSize.toString(10),
747
+ );
748
+ if (details.knownAccumulatorMatched) {
749
+ out.print(
750
+ "resolve-receipt: anchor — folded state matches --known-accumulator (trusted)",
751
+ );
752
+ }
753
+ out.print(
754
+ "resolve-receipt: proof — %d node(s) in the extended path",
755
+ details.proofLength,
756
+ );
757
+ out.print(
758
+ "resolve-receipt: receipt — %d bytes -> %s%s",
759
+ receiptCbor.length,
760
+ writeTarget ?? "stdout",
761
+ options.inPlace ? " (in place)" : "",
762
+ );
763
+ }