@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,173 @@
1
+ /**
2
+ * FOR-343 writer-grant statement assembly: deterministic Forestrie-Grant v0
3
+ * construction on top of `@forestrie/grant-builder` / `@forestrie/encoding`.
4
+ *
5
+ * This module is now WRITER-ONLY (ADR-0052 / plan-2607-21): it authorizes one
6
+ * statement signer on an existing data log, emitting an EXTEND-ONLY grant
7
+ * (byte 3 = `GF_EXTEND`, byte 7 = `GF_DATA_LOG`). Log creation and the
8
+ * bootstrap self-referential / auth-log shapes moved to `create-log`
9
+ * (`create-log-build.ts`).
10
+ *
11
+ * A grant binds exactly ONE signer (`grantData` = the signer's ES256 `x||y`);
12
+ * several signers on one data log means several grants, each naming that log.
13
+ * The grant leaf is sequenced into the OWNER (auth) log (`ownerLogId`); the
14
+ * target/data log holds only statements. Flag shape follows grants.md §5:
15
+ * writer grant → `GF_EXTEND` + `GF_DATA_LOG` (`dataLogExtendFlags`, the local
16
+ * bridge in `grant-flags-local.ts`).
17
+ *
18
+ * ES256 is the paved path (mirrors grant-builder's node-only
19
+ * `es256-pem-grant` profile: COSE Sign1 with a 32-byte digest payload and
20
+ * the full grant v0 CBOR in unprotected -65538).
21
+ *
22
+ * Node-only module (node:crypto PEM handling); no HTTP, no env.
23
+ */
24
+ import { createPublicKey } from "node:crypto";
25
+ import { parsePemResilient } from "./openssl-error-queue.js";
26
+ import {
27
+ bytesToForestrieGrantBase64,
28
+ signGrantPayloadWithEs256Pem,
29
+ } from "@forestrie/grant-builder";
30
+ import {
31
+ encodeGrantPayloadV0Canonical,
32
+ uuidToBytes,
33
+ type Grant,
34
+ } from "@forestrie/encoding";
35
+ import { dataLogExtendFlags } from "./grant-flags-local.js";
36
+
37
+ /** Grant construction failure (bad key material / inconsistent shape). */
38
+ export class RegisterGrantBuildError extends Error {
39
+ constructor(message: string) {
40
+ super(message);
41
+ this.name = "RegisterGrantBuildError";
42
+ }
43
+ }
44
+
45
+ const ES256_XY_BYTES = 64;
46
+
47
+ function base64UrlToBytes(b64url: string): Uint8Array {
48
+ return new Uint8Array(Buffer.from(b64url, "base64url"));
49
+ }
50
+
51
+ /**
52
+ * ES256 P-256 public key as uncompressed `x||y` (64 bytes) from a PEM.
53
+ * Accepts a public (SPKI) PEM or a private (SEC1/PKCS#8) PEM — the demo
54
+ * flow points `--signer-pem` at the same key file `sign-statement --key`
55
+ * uses, and the authorized signer's PUBLIC key is all the grant commits
56
+ * to (grant-builder only ships the private-PEM variant).
57
+ */
58
+ export function es256PublicKeyXyFromPem(pem: string): Uint8Array {
59
+ let jwk: { crv?: string; x?: unknown; y?: unknown };
60
+ try {
61
+ // `parsePemResilient` retries once so a valid key is never rejected
62
+ // because the OpenSSL error queue was poisoned elsewhere (FOR-343).
63
+ jwk = parsePemResilient(() =>
64
+ createPublicKey({ key: pem, format: "pem" }),
65
+ ).export({
66
+ format: "jwk",
67
+ });
68
+ } catch (err) {
69
+ throw new RegisterGrantBuildError(
70
+ `not an EC PEM (public or private): ${
71
+ err instanceof Error ? err.message : String(err)
72
+ }`,
73
+ );
74
+ }
75
+ if (jwk.crv !== "P-256" || typeof jwk.x !== "string" || typeof jwk.y !== "string") {
76
+ throw new RegisterGrantBuildError(
77
+ "signer key must be a P-256 (ES256) EC key",
78
+ );
79
+ }
80
+ const x = base64UrlToBytes(jwk.x);
81
+ const y = base64UrlToBytes(jwk.y);
82
+ if (x.length !== 32 || y.length !== 32) {
83
+ throw new RegisterGrantBuildError(
84
+ "ES256 public key coordinates must be 32 bytes each",
85
+ );
86
+ }
87
+ const out = new Uint8Array(ES256_XY_BYTES);
88
+ out.set(x, 0);
89
+ out.set(y, 32);
90
+ return out;
91
+ }
92
+
93
+ export type BuildGrantStatementParams = {
94
+ /** Target data log the grant authorizes (`grant.logId`, UUID). */
95
+ targetLog: string;
96
+ /** Owner (auth) log the grant leaf is sequenced into (`grant.ownerLogId`, UUID). */
97
+ ownerLog: string;
98
+ /** PKCS#8/SEC1 ES256 PEM that signs the grant envelope (the granting authority). */
99
+ signWithPem: string;
100
+ /** PEM of the ONE signer being authorized (`grantData` = ES256 x||y). */
101
+ signerPem: string;
102
+ };
103
+
104
+ export type BuiltGrantStatement = {
105
+ /** Signed grant transparent statement, Forestrie-Grant header base64. */
106
+ grantBase64: string;
107
+ /** Canonical grant v0 payload CBOR (keys 1-6) that was signed. */
108
+ grantPayloadBytes: Uint8Array;
109
+ /** The ONE authorized signer this grant binds (ES256 x||y, 64 bytes). */
110
+ grantData: Uint8Array;
111
+ /** 8-byte GF_* flags bitmap that went on the wire. */
112
+ flags: Uint8Array;
113
+ };
114
+
115
+ /**
116
+ * Build and sign the writer grant transparent statement:
117
+ * canonical grant v0 CBOR (`encodeGrantPayloadV0Canonical`) with EXTEND-ONLY
118
+ * data-log flags, signed via the Custodian ES256 PEM profile
119
+ * (`signGrantPayloadWithEs256Pem`), returned as `Authorization:
120
+ * Forestrie-Grant` base64.
121
+ */
122
+ export function buildGrantStatement(
123
+ params: BuildGrantStatementParams,
124
+ ): BuiltGrantStatement {
125
+ let logId: Uint8Array;
126
+ let ownerLogId: Uint8Array;
127
+ try {
128
+ logId = uuidToBytes(params.targetLog);
129
+ } catch {
130
+ throw new RegisterGrantBuildError(
131
+ `--data-log is not a UUID: ${params.targetLog}`,
132
+ );
133
+ }
134
+ try {
135
+ ownerLogId = uuidToBytes(params.ownerLog);
136
+ } catch {
137
+ throw new RegisterGrantBuildError(
138
+ `--owner-log is not a UUID: ${params.ownerLog}`,
139
+ );
140
+ }
141
+
142
+ // One grant, ONE signer: the authorized signer's public key is the
143
+ // committed grantData. Writer grants always name an explicit signer.
144
+ const grantData = es256PublicKeyXyFromPem(params.signerPem);
145
+
146
+ const flags = dataLogExtendFlags();
147
+
148
+ const grant: Grant = {
149
+ logId,
150
+ ownerLogId,
151
+ grant: flags,
152
+ maxHeight: 0,
153
+ minGrowth: 0,
154
+ grantData,
155
+ };
156
+ const grantPayloadBytes = encodeGrantPayloadV0Canonical(grant);
157
+ let sign1: Uint8Array;
158
+ try {
159
+ sign1 = signGrantPayloadWithEs256Pem(grantPayloadBytes, params.signWithPem);
160
+ } catch (err) {
161
+ throw new RegisterGrantBuildError(
162
+ `--sign-with key rejected: ${
163
+ err instanceof Error ? err.message : String(err)
164
+ }`,
165
+ );
166
+ }
167
+ return {
168
+ grantBase64: bytesToForestrieGrantBase64(sign1),
169
+ grantPayloadBytes,
170
+ grantData,
171
+ flags,
172
+ };
173
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * FOR-343 completed-grant assembly: once the grant leaf is sequenced and
3
+ * the receipt resolved, the *completed* grant is the original signed grant
4
+ * statement with the receipt (header 396) and the entry's idtimestamp
5
+ * (header -65537) merged into the UNPROTECTED headers — no re-signing
6
+ * (mirrors e2e-kit `buildCompletedGrantBase64`). The completed grant is
7
+ * the `Authorization: Forestrie-Grant` bearer used by `forestrie register`.
8
+ */
9
+ import {
10
+ HEADER_IDTIMESTAMP,
11
+ HEADER_RECEIPT,
12
+ base64ToBytes,
13
+ bytesToForestrieGrantBase64,
14
+ } from "@forestrie/grant-builder";
15
+ import { mergeUnprotectedIntoCoseSign1 } from "@forestrie/encoding";
16
+
17
+ const ENTRY_ID_HEX_RE = /^[0-9a-f]{32}$/i;
18
+
19
+ /**
20
+ * First half of the permanent 32-hex-char entry id (idtimestamp_be8 ||
21
+ * mmrIndex_be8): the 8 idtimestamp bytes for unprotected header -65537.
22
+ */
23
+ export function entryIdHexToIdtimestampBe8(entryIdHex: string): Uint8Array {
24
+ if (!ENTRY_ID_HEX_RE.test(entryIdHex)) {
25
+ throw new Error(`entryId must be 32 hex chars: ${entryIdHex}`);
26
+ }
27
+ return Uint8Array.from(Buffer.from(entryIdHex.slice(0, 16), "hex"));
28
+ }
29
+
30
+ /**
31
+ * Attach receipt + idtimestamp to the signed grant statement, returning
32
+ * the completed grant as Forestrie-Grant header base64.
33
+ */
34
+ export function completeGrantBase64(
35
+ grantBase64: string,
36
+ receiptBytes: Uint8Array,
37
+ entryIdHex: string,
38
+ ): string {
39
+ const completed = mergeUnprotectedIntoCoseSign1(
40
+ base64ToBytes(grantBase64),
41
+ new Map<number, unknown>([
42
+ [HEADER_RECEIPT, receiptBytes],
43
+ [HEADER_IDTIMESTAMP, entryIdHexToIdtimestampBe8(entryIdHex)],
44
+ ]),
45
+ );
46
+ return bytesToForestrieGrantBase64(completed);
47
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * FOR-343 grant registration flow: POST the signed grant statement to
3
+ * `POST /register/{bootstrapLogId}/grants` (`@forestrie/scrapi-client`
4
+ * `registerGrant` — the parent grant evidence travels in the CBOR body as
5
+ * `{ parentGrant: <bytes> }`, grants.md §11), then drive the same SCRAPI
6
+ * 303 receipt-redirect contract as `register-flow.ts` to completion.
7
+ *
8
+ * The pacing/timeout/error taxonomy deliberately MIRRORS
9
+ * `register-flow.ts` (F4, plan-2607-14 W1.4): scrapi-client ships
10
+ * poll-once primitives with NO sleep loops, this module owns the pacing,
11
+ * every request is bounded by an AbortSignal tied to the remaining
12
+ * `--timeout` budget, and fetch-level failures map into the shared
13
+ * {@link RegisterFlowError} stages instead of escaping as bare TypeErrors.
14
+ * `boundedFetch` is not exported from register-flow, so it is mirrored
15
+ * here (kept private there by design).
16
+ */
17
+ import {
18
+ ScrapiRegistrationError,
19
+ queryRegistrationOnce,
20
+ registerGrant,
21
+ resolveReceiptOnce,
22
+ toAbsoluteScrapiUrl,
23
+ } from "@forestrie/scrapi-client";
24
+ import { RegisterFlowError } from "./register-flow.js";
25
+
26
+ export { RegisterFlowError };
27
+
28
+ /** Progress callbacks so the CLI can narrate long polls on stderr. */
29
+ export type RegisterGrantProgress =
30
+ | { phase: "registered"; statusUrl: string }
31
+ | { phase: "status-pending"; statusUrl: string; attempt: number }
32
+ | { phase: "receipt-located"; receiptUrl: string; entryIdHex: string }
33
+ | { phase: "receipt-pending"; receiptUrl: string; attempt: number };
34
+
35
+ /** Injectable effects — production defaults are real fetch/sleep/clock. */
36
+ export type RegisterGrantFlowDeps = {
37
+ fetchImpl?: typeof fetch;
38
+ sleep?: (ms: number) => Promise<void>;
39
+ now?: () => number;
40
+ onProgress?: (progress: RegisterGrantProgress) => void;
41
+ };
42
+
43
+ export type RegisterGrantFlowParams = {
44
+ /** SCRAPI origin, no trailing slash. */
45
+ baseUrl: string;
46
+ /** Forest bootstrap/root log id — first `/register/` path segment. */
47
+ bootstrapLogId: string;
48
+ /** Signed grant transparent statement, Forestrie-Grant header base64. */
49
+ grantBase64: string;
50
+ /**
51
+ * Completed parent grant (base64) authorizing this registration; sent
52
+ * in the CBOR request body (grants.md §11). Absent for the
53
+ * self-referential bootstrap leaf.
54
+ */
55
+ parentGrantBase64?: string | undefined;
56
+ /** Overall budget covering both poll phases. */
57
+ timeoutMs: number;
58
+ /** Pacing between polls; a longer `Retry-After` wins. */
59
+ pollIntervalMs: number;
60
+ };
61
+
62
+ export type RegisterGrantFlowResult = {
63
+ /** Final query-registration-status URL (from the register 303). */
64
+ statusUrl: string;
65
+ /** Permanent resolve-receipt URL. */
66
+ receiptUrl: string;
67
+ /** Permanent entry id (32 hex chars) from the receipt Location. */
68
+ entryIdHex: string;
69
+ /** Receipt wire bytes (CBOR). */
70
+ receipt: Uint8Array;
71
+ contentType: string | undefined;
72
+ };
73
+
74
+ const defaultSleep = (ms: number): Promise<void> =>
75
+ new Promise((resolve) => setTimeout(resolve, ms));
76
+
77
+ /** One line for a transport failure: `code: message (cause)` where known. */
78
+ function describeFetchError(err: unknown): string {
79
+ if (!(err instanceof Error)) return String(err);
80
+ const code = (err as { code?: unknown }).code;
81
+ const cause =
82
+ err.cause instanceof Error && err.cause.message !== err.message
83
+ ? ` (${err.cause.message})`
84
+ : "";
85
+ return typeof code === "string" && code !== "" && !err.message.includes(code)
86
+ ? `${code}: ${err.message}${cause}`
87
+ : `${err.message}${cause}`;
88
+ }
89
+
90
+ function requestUrl(input: Parameters<typeof fetch>[0]): string {
91
+ if (typeof input === "string") return input;
92
+ if (input instanceof URL) return input.href;
93
+ return input.url;
94
+ }
95
+
96
+ /**
97
+ * Mirror of register-flow's F4 bounded fetch: every request carries an
98
+ * AbortSignal tied to the remaining `--timeout` budget, and fetch-level
99
+ * failures (ECONNREFUSED, DNS, resets) map into the RegisterFlowError
100
+ * taxonomy. Real wall-clock timer; `remaining` from the injectable `now`.
101
+ */
102
+ function boundedFetch(
103
+ baseFetch: typeof fetch,
104
+ now: () => number,
105
+ deadline: number,
106
+ timeoutMs: number,
107
+ ): typeof fetch {
108
+ return (async (
109
+ input: Parameters<typeof fetch>[0],
110
+ init?: RequestInit,
111
+ ): Promise<Response> => {
112
+ const remaining = deadline - now();
113
+ if (remaining <= 0) {
114
+ throw new RegisterFlowError(
115
+ `timed out after ${timeoutMs}ms waiting for the grant receipt`,
116
+ { stage: "timeout" },
117
+ );
118
+ }
119
+ const controller = new AbortController();
120
+ const timer = setTimeout(() => controller.abort(), remaining);
121
+ try {
122
+ return await baseFetch(input, { ...init, signal: controller.signal });
123
+ } catch (err) {
124
+ if (err instanceof RegisterFlowError) throw err;
125
+ const url = requestUrl(input);
126
+ if (controller.signal.aborted) {
127
+ throw new RegisterFlowError(
128
+ `timed out after ${timeoutMs}ms: no response from ${url} within the --timeout budget`,
129
+ { stage: "timeout", detail: url },
130
+ );
131
+ }
132
+ throw new RegisterFlowError(
133
+ `request to ${url} failed: ${describeFetchError(err)}`,
134
+ { stage: "network", detail: describeFetchError(err) },
135
+ );
136
+ } finally {
137
+ clearTimeout(timer);
138
+ }
139
+ }) as typeof fetch;
140
+ }
141
+
142
+ /**
143
+ * Register the grant and wait for its receipt: POST → 303 status URL →
144
+ * poll query-registration-status until the receipt redirect → poll
145
+ * resolve-receipt (404 = objects still writing) until 200.
146
+ */
147
+ export async function runRegisterGrantFlow(
148
+ params: RegisterGrantFlowParams,
149
+ deps: RegisterGrantFlowDeps = {},
150
+ ): Promise<RegisterGrantFlowResult> {
151
+ const sleep = deps.sleep ?? defaultSleep;
152
+ const now = deps.now ?? Date.now;
153
+ const onProgress = deps.onProgress ?? (() => {});
154
+ const deadline = now() + params.timeoutMs;
155
+ const fetchImpl = boundedFetch(
156
+ deps.fetchImpl ?? fetch,
157
+ now,
158
+ deadline,
159
+ params.timeoutMs,
160
+ );
161
+
162
+ const paceOrTimeout = async (
163
+ waitMs: number,
164
+ info: { statusUrl: string; receiptUrl?: string },
165
+ ): Promise<void> => {
166
+ if (now() + waitMs > deadline) {
167
+ throw new RegisterFlowError(
168
+ `timed out after ${params.timeoutMs}ms waiting for the grant receipt`,
169
+ { stage: "timeout", ...info },
170
+ );
171
+ }
172
+ await sleep(waitMs);
173
+ };
174
+
175
+ // 1. POST /register/{bootstrapLogId}/grants — only 303 + Location succeeds.
176
+ let statusUrl: string;
177
+ try {
178
+ ({ statusUrl } = await registerGrant({
179
+ baseUrl: params.baseUrl,
180
+ bootstrapLogId: params.bootstrapLogId,
181
+ grantBase64: params.grantBase64,
182
+ ...(params.parentGrantBase64 !== undefined
183
+ ? { parentGrantBase64: params.parentGrantBase64 }
184
+ : {}),
185
+ fetchImpl,
186
+ }));
187
+ } catch (err) {
188
+ if (err instanceof ScrapiRegistrationError) {
189
+ throw new RegisterFlowError(err.message, {
190
+ stage: "register",
191
+ httpStatus: err.httpStatus,
192
+ detail: err.detail,
193
+ ...(err.problem !== undefined ? { problem: err.problem } : {}),
194
+ });
195
+ }
196
+ throw err;
197
+ }
198
+ onProgress({ phase: "registered", statusUrl });
199
+
200
+ // 2. Poll query-registration-status until the receipt redirect.
201
+ let receiptUrl: string;
202
+ let entryIdHex: string;
203
+ for (let attempt = 1; ; attempt++) {
204
+ const status = await queryRegistrationOnce({
205
+ statusUrl,
206
+ baseUrl: params.baseUrl,
207
+ fetchImpl,
208
+ });
209
+ if (status.status === "receipt") {
210
+ ({ receiptUrl, entryIdHex } = status);
211
+ break;
212
+ }
213
+ if (status.status === "error") {
214
+ throw new RegisterFlowError(`registration status: ${status.detail}`, {
215
+ stage: "status",
216
+ httpStatus: status.httpStatus,
217
+ detail: status.detail,
218
+ ...(status.problem !== undefined ? { problem: status.problem } : {}),
219
+ statusUrl,
220
+ });
221
+ }
222
+ // pending — the worker may move the status Location and pace us.
223
+ statusUrl = toAbsoluteScrapiUrl(params.baseUrl, status.location);
224
+ onProgress({ phase: "status-pending", statusUrl, attempt });
225
+ await paceOrTimeout(
226
+ Math.max(params.pollIntervalMs, status.retryAfterMs ?? 0),
227
+ { statusUrl },
228
+ );
229
+ }
230
+ onProgress({ phase: "receipt-located", receiptUrl, entryIdHex });
231
+
232
+ // 3. Poll resolve-receipt: 404 = checkpoint/massif still writing.
233
+ for (let attempt = 1; ; attempt++) {
234
+ const resolution = await resolveReceiptOnce({ receiptUrl, fetchImpl });
235
+ if (resolution.status === "receipt") {
236
+ return {
237
+ statusUrl,
238
+ receiptUrl,
239
+ entryIdHex,
240
+ receipt: resolution.body,
241
+ contentType: resolution.headers["content-type"],
242
+ };
243
+ }
244
+ if (resolution.status === "error") {
245
+ throw new RegisterFlowError(
246
+ `resolve receipt: HTTP ${resolution.httpStatus}`,
247
+ {
248
+ stage: "receipt",
249
+ httpStatus: resolution.httpStatus,
250
+ statusUrl,
251
+ receiptUrl,
252
+ },
253
+ );
254
+ }
255
+ onProgress({ phase: "receipt-pending", receiptUrl, attempt });
256
+ await paceOrTimeout(params.pollIntervalMs, { statusUrl, receiptUrl });
257
+ }
258
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Tile-free freshen for `resolve-receipt` (FOR-418 Phase 3c, plan-2607-32 D1/D3).
3
+ *
4
+ * "Freshen" is `resolve-receipt --receipt <stale> + a tile-free source`: re-anchor
5
+ * a stale receipt to the CURRENT sealed state without massif tiles. The stale
6
+ * receipt supplies the leaf's MMR index and its old inclusion path (leaf → old
7
+ * peak); a checkpoint chain supplies the climb from that old peak to the current
8
+ * accumulator; the latest checkpoint supplies the emission signature. The result
9
+ * is a native receipt that verifies with the offline verifier.
10
+ *
11
+ * A freshened receipt is a NEW attestation of the CURRENT accumulator, so the
12
+ * only signature it can carry is one over a checkpoint at the current size — the
13
+ * latest checkpoint's signer, necessarily (anything else would be forgery). This
14
+ * is not a signer "downgrade": the log authorises a SET of sealers and the
15
+ * contract enforces membership at publish; ranking them is out-of-band verify-
16
+ * time policy, not a freshen concern. See the receipt trust model:
17
+ * https://github.com/forestrie/protocol/blob/main/spec/receipt-trust-model.md
18
+ *
19
+ * The leaf VALUE is not in the stale receipt (its payload is detached — verify
20
+ * recomputes it), so freshen recomputes it exactly as `verify` does:
21
+ * `univocityLeafHash(idtimestamp, inner)` (see the `inner` note below).
22
+ * `freshenReceipt` needs it for its fail-closed self-check (recomputed peak ==
23
+ * folded accumulator peak), so a bad chain/leaf throws here rather than minting
24
+ * a bad receipt.
25
+ *
26
+ * Two tile-free climb sources, both folding to `freshenReceipt`:
27
+ * - **`.sth` chain** (`freshenFromSthChain`): retained checkpoints; the chain's
28
+ * own tail is the emission checkpoint (self-contained, no separate artifact).
29
+ * - **chain calldata** (`freshenFromCalldataChain`): the `publishCheckpoint`
30
+ * transactions carry the consistency-proof chain (authority already checked
31
+ * on-chain at publish). Calldata carries no pre-signed peak receipts / cert, so
32
+ * emission still needs a latest `.sth` supplied separately.
33
+ */
34
+ import {
35
+ freshenReceipt,
36
+ parseReceipt,
37
+ } from "@forestrie/receipt-verify";
38
+ import {
39
+ calldataCheckpointChain,
40
+ sthCheckpointChain,
41
+ type CheckpointLink,
42
+ } from "./checkpoint-provider.js";
43
+ import { univocityLeafHash } from "./verify-anchored.js";
44
+ import { bytesEqual } from "./bytes.js";
45
+ import type { KnownAccumulator } from "./verify-known-accumulator.js";
46
+
47
+ function accumulatorsEqual(a: Uint8Array[], b: Uint8Array[]): boolean {
48
+ if (a.length !== b.length) return false;
49
+ for (let i = 0; i < a.length; i++) if (!bytesEqual(a[i]!, b[i]!)) return false;
50
+ return true;
51
+ }
52
+
53
+ /** Structured detail for the `--json` report and human narration. */
54
+ export type FreshenDetails = {
55
+ /** The stale receipt's leaf, addressed by MMR index. */
56
+ leafMmrIndex: bigint;
57
+ /** Sealed size the freshened receipt is anchored at (latest checkpoint). */
58
+ sealedSize: bigint;
59
+ /** Number of consistency-proof links folded from the chain. */
60
+ chainLinks: number;
61
+ /** Length of the emitted (extended) inclusion path. */
62
+ proofLength: number;
63
+ /** The chain sources, ascending (`.sth` filenames or tx hashes), for narration. */
64
+ sourceRefs: string[];
65
+ /** The folded latest accumulator matched a `--known-accumulator` snapshot
66
+ * (present only when one was supplied). */
67
+ knownAccumulatorMatched?: boolean;
68
+ };
69
+
70
+ export type FreshenResult = {
71
+ receiptCbor: Uint8Array;
72
+ details: FreshenDetails;
73
+ };
74
+
75
+ // The leaf value the freshened receipt must root to is recomputed exactly as
76
+ // `verify` does: `univocityLeafHash(idtimestamp, inner)`, where `inner` is the
77
+ // leaf ContentHash — `SHA-256(payload)` for a statement, or the grant commitment
78
+ // hash for a grant. The caller supplies `inner`, so freshen is source-agnostic.
79
+
80
+ /**
81
+ * Shared emission: take the provider's links + a latest checkpoint, extend the
82
+ * stale receipt's path, and re-emit. `freshenReceipt` cross-checks the chain
83
+ * against the latest checkpoint and self-checks the recomputed peak.
84
+ *
85
+ * When a `knownAccumulator` is supplied, the folded latest accumulator is bound
86
+ * to that trusted snapshot BEFORE the receipt is returned: the state we folded
87
+ * must equal the snapshot at the same size, or we fail closed. This anchors
88
+ * FRESHNESS (a chain-captured `logState`, `fetch-accumulator`) — it confirms the
89
+ * leaf roots into the genuine current state, orthogonal to signer provenance and
90
+ * needing no genesis walk. For the calldata source (whose fold is already cross-
91
+ * checked against the on-chain `CheckpointPublished` accumulator), it adds an
92
+ * independent, RPC-agnostic anchor that catches a lying/stale RPC.
93
+ */
94
+ async function emitFreshened(opts: {
95
+ oldReceiptBytes: Uint8Array;
96
+ leafValue: Uint8Array;
97
+ links: readonly CheckpointLink[];
98
+ latestCheckpointBytes: Uint8Array;
99
+ sourceRefs: string[];
100
+ knownAccumulator?: KnownAccumulator | undefined;
101
+ }): Promise<FreshenResult> {
102
+ const result = await freshenReceipt({
103
+ oldReceiptBytes: opts.oldReceiptBytes,
104
+ leafValue: opts.leafValue,
105
+ consistencyProofs: opts.links.map((l) => l.proof),
106
+ latestCheckpointBytes: opts.latestCheckpointBytes,
107
+ });
108
+
109
+ let knownAccumulatorMatched: boolean | undefined;
110
+ if (opts.knownAccumulator !== undefined) {
111
+ const snap = opts.knownAccumulator;
112
+ const aLatest = opts.links[opts.links.length - 1]!.accumulator;
113
+ if (snap.size !== result.sealedSize) {
114
+ throw new Error(
115
+ `--known-accumulator is size ${snap.size} but the freshened state is size ${result.sealedSize} — capture a snapshot at the current sealed size (freshen cannot extend tile-free)`,
116
+ );
117
+ }
118
+ if (!accumulatorsEqual(aLatest, snap.accumulator)) {
119
+ throw new Error(
120
+ "freshened accumulator does not match --known-accumulator — the chain/checkpoint disagrees with your trusted snapshot",
121
+ );
122
+ }
123
+ knownAccumulatorMatched = true;
124
+ }
125
+
126
+ const { proof } = parseReceipt(result.receipt);
127
+ return {
128
+ receiptCbor: result.receipt,
129
+ details: {
130
+ leafMmrIndex: proof.mmrIndex!,
131
+ sealedSize: result.sealedSize,
132
+ chainLinks: opts.links.length,
133
+ proofLength: proof.path.length,
134
+ sourceRefs: opts.sourceRefs,
135
+ ...(knownAccumulatorMatched !== undefined ? { knownAccumulatorMatched } : {}),
136
+ },
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Freshen a stale receipt against a retained `.sth` checkpoint chain.
142
+ *
143
+ * `checkpoints` are the raw `.sth` bytes in ascending massif order (a contiguous
144
+ * chain from the log's base); the last checkpoint is the emission checkpoint.
145
+ * `sourceRefs` optionally names them. Throws (with the underlying reason) if the
146
+ * chain is not a contiguous cover to the leaf, if the chain does not match the
147
+ * latest checkpoint, or if the recomputed peak does not match the fold.
148
+ */
149
+ export async function freshenFromSthChain(opts: {
150
+ oldReceiptBytes: Uint8Array;
151
+ /** Leaf ContentHash: `SHA-256(payload)` (statement) or the grant commitment. */
152
+ inner: Uint8Array;
153
+ idtimestampBe8: Uint8Array;
154
+ checkpoints: readonly Uint8Array[];
155
+ sourceRefs?: readonly string[];
156
+ knownAccumulator?: KnownAccumulator | undefined;
157
+ }): Promise<FreshenResult> {
158
+ if (opts.checkpoints.length === 0) {
159
+ throw new Error("--checkpoint-chain resolved to no checkpoints");
160
+ }
161
+ const leafValue = await univocityLeafHash(opts.idtimestampBe8, opts.inner);
162
+ const links = await sthCheckpointChain(
163
+ opts.checkpoints,
164
+ opts.sourceRefs !== undefined ? { sourceRefs: opts.sourceRefs } : {},
165
+ );
166
+ return emitFreshened({
167
+ oldReceiptBytes: opts.oldReceiptBytes,
168
+ leafValue,
169
+ links,
170
+ latestCheckpointBytes: opts.checkpoints[opts.checkpoints.length - 1]!,
171
+ sourceRefs: [...(opts.sourceRefs ?? [])],
172
+ knownAccumulator: opts.knownAccumulator,
173
+ });
174
+ }
175
+
176
+ /**
177
+ * Freshen a stale receipt against the on-chain `publishCheckpoint` calldata.
178
+ *
179
+ * Reads the log's checkpoint chain from the `CheckpointPublished` transactions
180
+ * (trustless climb material — the on-chain publish already discharged sealing +
181
+ * grant authority), then re-emits under `latestCheckpointBytes` — a latest `.sth`
182
+ * supplied by the caller, because the calldata carries no pre-signed peak
183
+ * receipts or delegation cert. The calldata chain's sealed size must equal the
184
+ * latest checkpoint's (enforced by `freshenReceipt`). Freshness is anchored by
185
+ * the fold (and, if supplied, `--known-accumulator`); signer provenance is a
186
+ * verify-time question. See the receipt trust model:
187
+ * https://github.com/forestrie/protocol/blob/main/spec/receipt-trust-model.md
188
+ */
189
+ export async function freshenFromCalldataChain(opts: {
190
+ oldReceiptBytes: Uint8Array;
191
+ /** Leaf ContentHash: `SHA-256(payload)` (statement) or the grant commitment. */
192
+ inner: Uint8Array;
193
+ idtimestampBe8: Uint8Array;
194
+ univocity: string;
195
+ logId: string;
196
+ rpcUrl: string;
197
+ latestCheckpointBytes: Uint8Array;
198
+ knownAccumulator?: KnownAccumulator | undefined;
199
+ fetchImpl?: typeof fetch;
200
+ }): Promise<FreshenResult> {
201
+ const leafValue = await univocityLeafHash(opts.idtimestampBe8, opts.inner);
202
+ const links = await calldataCheckpointChain({
203
+ univocity: opts.univocity,
204
+ logId: opts.logId,
205
+ rpcUrl: opts.rpcUrl,
206
+ ...(opts.fetchImpl !== undefined ? { fetchImpl: opts.fetchImpl } : {}),
207
+ });
208
+ if (links.length === 0) {
209
+ throw new Error(
210
+ "no CheckpointPublished history found on-chain for this log — nothing to freshen against",
211
+ );
212
+ }
213
+ return emitFreshened({
214
+ oldReceiptBytes: opts.oldReceiptBytes,
215
+ leafValue,
216
+ links,
217
+ latestCheckpointBytes: opts.latestCheckpointBytes,
218
+ sourceRefs: links.map((l) => l.sourceRef ?? ""),
219
+ knownAccumulator: opts.knownAccumulator,
220
+ });
221
+ }