@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,271 @@
1
+ import { bytesEqual } from "./bytes.js";
2
+ import {
3
+ decodeCborDeterministic,
4
+ encodeCborDeterministic,
5
+ } from "@forestrie/encoding";
6
+ import {
7
+ createSyncHasher,
8
+ indexConsistencyProof,
9
+ openMassifNodeStore,
10
+ peakMMRIndexes,
11
+ verifyConsistency,
12
+ type NodeGetter,
13
+ } from "@forestrie/merklelog";
14
+
15
+ /**
16
+ * Portable consistency-proof artifact (FOR-368 Phase 3, plan-2607-29):
17
+ * the "top-up" a holder of an old receipt obtains from ANY party with the
18
+ * public massif tiles — after which the holder verifies tile-free against
19
+ * a trusted newer state (`--known-accumulator`).
20
+ *
21
+ * Trust model: the artifact is UNSIGNED and untrusted. Soundness comes
22
+ * from recomputation — the receipt's peak must appear in the artifact's
23
+ * base accumulator, and folding the artifact's paths from that base
24
+ * (draft-bryce `consistent_roots`) must land every proven root inside the
25
+ * TRUSTED target accumulator. A fabricated artifact can only fail; a
26
+ * passing one proves the old state is a committed prefix of the trusted
27
+ * state (SCRAPI §2.4's Resolve Receipt provision, made third-party).
28
+ *
29
+ * Encoding is strict RFC 8949 §4.2 deterministic CBOR (hard policy).
30
+ */
31
+
32
+ /** CBOR map labels for the artifact (strict RFC 8949 §4.2). */
33
+ const LABEL_VERSION = 1;
34
+ const LABEL_FROM_SIZE = 2;
35
+ const LABEL_TO_SIZE = 3;
36
+ const LABEL_ACCUMULATOR_FROM = 4;
37
+ const LABEL_PATHS = 5;
38
+
39
+ const ARTIFACT_VERSION = 1;
40
+
41
+ export type ConsistencyProofArtifact = {
42
+ version: number;
43
+ /** MMR size the proof extends FROM (the old receipt's era). */
44
+ fromSize: bigint;
45
+ /** MMR size the proof lands on — must equal the trusted state's size. */
46
+ toSize: bigint;
47
+ /** Full accumulator at `fromSize` (untrusted; proven by the fold). */
48
+ accumulatorFrom: Uint8Array[];
49
+ /** One inclusion path per `fromSize` peak, proven at `toSize`. */
50
+ paths: Uint8Array[][];
51
+ };
52
+
53
+ export function encodeConsistencyProofArtifact(
54
+ artifact: ConsistencyProofArtifact,
55
+ ): Uint8Array {
56
+ return encodeCborDeterministic(
57
+ new Map<number, unknown>([
58
+ [LABEL_VERSION, artifact.version],
59
+ [LABEL_FROM_SIZE, artifact.fromSize],
60
+ [LABEL_TO_SIZE, artifact.toSize],
61
+ [LABEL_ACCUMULATOR_FROM, artifact.accumulatorFrom],
62
+ [LABEL_PATHS, artifact.paths],
63
+ ]),
64
+ );
65
+ }
66
+
67
+ function asBigint(v: unknown, what: string): bigint {
68
+ if (typeof v === "bigint") return v;
69
+ if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0)
70
+ return BigInt(v);
71
+ throw new Error(`consistency proof: ${what} must be an unsigned integer`);
72
+ }
73
+
74
+ /** Strict decode + structural validation of a top-up artifact. */
75
+ export function decodeConsistencyProofArtifact(
76
+ bytes: Uint8Array,
77
+ ): ConsistencyProofArtifact {
78
+ let decoded: unknown;
79
+ try {
80
+ decoded = decodeCborDeterministic(bytes);
81
+ } catch (err) {
82
+ const message = err instanceof Error ? err.message : String(err);
83
+ throw new Error(`consistency proof is not canonical CBOR: ${message}`);
84
+ }
85
+ if (!(decoded instanceof Map)) {
86
+ throw new Error("consistency proof must be a CBOR map");
87
+ }
88
+ const version = Number(asBigint(decoded.get(LABEL_VERSION), "version"));
89
+ if (version !== ARTIFACT_VERSION) {
90
+ throw new Error(`consistency proof version ${version} not supported`);
91
+ }
92
+ const fromSize = asBigint(decoded.get(LABEL_FROM_SIZE), "fromSize");
93
+ const toSize = asBigint(decoded.get(LABEL_TO_SIZE), "toSize");
94
+ if (fromSize === 0n || toSize <= fromSize) {
95
+ throw new Error(
96
+ `consistency proof sizes invalid: from ${fromSize} to ${toSize}`,
97
+ );
98
+ }
99
+ const accRaw = decoded.get(LABEL_ACCUMULATOR_FROM);
100
+ const pathsRaw = decoded.get(LABEL_PATHS);
101
+ if (
102
+ !Array.isArray(accRaw) ||
103
+ accRaw.some((p) => !(p instanceof Uint8Array) || p.length !== 32)
104
+ ) {
105
+ throw new Error(
106
+ "consistency proof: accumulatorFrom must be 32-byte peaks",
107
+ );
108
+ }
109
+ if (
110
+ !Array.isArray(pathsRaw) ||
111
+ pathsRaw.some(
112
+ (p) => !Array.isArray(p) || p.some((n) => !(n instanceof Uint8Array)),
113
+ )
114
+ ) {
115
+ throw new Error(
116
+ "consistency proof: paths must be arrays of byte strings",
117
+ );
118
+ }
119
+ const wanted = peakMMRIndexes(fromSize - 1n).length;
120
+ if (accRaw.length !== wanted || pathsRaw.length !== wanted) {
121
+ throw new Error(
122
+ `consistency proof: size ${fromSize} has ${wanted} peaks; got ${accRaw.length} accumulator values and ${pathsRaw.length} paths`,
123
+ );
124
+ }
125
+ return {
126
+ version,
127
+ fromSize,
128
+ toSize,
129
+ accumulatorFrom: accRaw as Uint8Array[],
130
+ paths: pathsRaw as Uint8Array[][],
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Node access across one or more massif blobs (a proof spanning massifs
136
+ * needs nodes from each). First blob covering the index wins; ancestor
137
+ * peak-stack reads resolve through whichever blob carries them.
138
+ */
139
+ export function multiMassifNodeGetter(blobs: Uint8Array[]): NodeGetter {
140
+ const stores = blobs.map((b) => openMassifNodeStore(b));
141
+ return (i: bigint) => {
142
+ let lastErr: unknown;
143
+ for (const store of stores) {
144
+ try {
145
+ return store.get(i);
146
+ } catch (err) {
147
+ lastErr = err;
148
+ }
149
+ }
150
+ throw new Error(
151
+ `node ${i} is not covered by the supplied massifs: ${
152
+ lastErr instanceof Error ? lastErr.message : String(lastErr)
153
+ }`,
154
+ );
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Build (and self-verify) a top-up artifact from massif node data. The
160
+ * builder needs tiles; consumers of the artifact never do.
161
+ */
162
+ export async function buildConsistencyProofArtifact(opts: {
163
+ get: NodeGetter;
164
+ fromSize: bigint;
165
+ toSize: bigint;
166
+ }): Promise<ConsistencyProofArtifact> {
167
+ if (opts.fromSize === 0n || opts.toSize <= opts.fromSize) {
168
+ throw new Error(
169
+ `--from-size must be > 0 and < --to-size (got ${opts.fromSize} -> ${opts.toSize})`,
170
+ );
171
+ }
172
+ const accumulatorFrom = peakMMRIndexes(opts.fromSize - 1n).map(opts.get);
173
+ const proof = indexConsistencyProof(
174
+ opts.get,
175
+ opts.fromSize - 1n,
176
+ opts.toSize - 1n,
177
+ );
178
+ // Self-check before emitting: the artifact must verify against the
179
+ // accumulator the same nodes derive at toSize — a coverage gap in the
180
+ // supplied massifs fails HERE, not at the holder.
181
+ const accumulatorTo = peakMMRIndexes(opts.toSize - 1n).map(opts.get);
182
+ const hasher = await createSyncHasher();
183
+ const check = await verifyConsistency(
184
+ hasher,
185
+ proof,
186
+ accumulatorFrom,
187
+ accumulatorTo,
188
+ );
189
+ if (!check.ok) {
190
+ throw new Error(
191
+ "self-verification failed: supplied massifs do not consistently cover the requested range",
192
+ );
193
+ }
194
+ return {
195
+ version: ARTIFACT_VERSION,
196
+ fromSize: opts.fromSize,
197
+ toSize: opts.toSize,
198
+ accumulatorFrom,
199
+ paths: proof.paths,
200
+ };
201
+ }
202
+
203
+ export type ConsistencyProofAnchorCheck = {
204
+ anchored: boolean;
205
+ /** Index of the receipt peak within the artifact's base accumulator. */
206
+ matchedPeak: number | null;
207
+ reason?: string;
208
+ };
209
+
210
+ /**
211
+ * Tile-free extension of an old receipt to a trusted newer accumulator:
212
+ * peak ∈ artifact base, then fold the artifact into the trusted target.
213
+ */
214
+ export async function checkReceiptAnchoredViaConsistencyProof(opts: {
215
+ artifact: ConsistencyProofArtifact;
216
+ recomputedPeak: Uint8Array;
217
+ leafMmrIndex: bigint;
218
+ /** Trusted target state (e.g. a known-accumulator snapshot). */
219
+ trustedSize: bigint;
220
+ trustedAccumulator: Uint8Array[];
221
+ }): Promise<ConsistencyProofAnchorCheck> {
222
+ const { artifact } = opts;
223
+ if (artifact.toSize !== opts.trustedSize) {
224
+ return {
225
+ anchored: false,
226
+ matchedPeak: null,
227
+ reason: "consistency_proof_target_mismatch",
228
+ };
229
+ }
230
+ if (opts.leafMmrIndex >= artifact.fromSize) {
231
+ return {
232
+ anchored: false,
233
+ matchedPeak: null,
234
+ reason: "receipt_newer_than_consistency_proof_base",
235
+ };
236
+ }
237
+ let matchedPeak: number | null = null;
238
+ for (let i = 0; i < artifact.accumulatorFrom.length; i++) {
239
+ if (bytesEqual(opts.recomputedPeak, artifact.accumulatorFrom[i]!)) {
240
+ matchedPeak = i;
241
+ break;
242
+ }
243
+ }
244
+ if (matchedPeak === null) {
245
+ return {
246
+ anchored: false,
247
+ matchedPeak: null,
248
+ reason: "peak_not_in_consistency_proof_base",
249
+ };
250
+ }
251
+ const hasher = await createSyncHasher();
252
+ const check = await verifyConsistency(
253
+ hasher,
254
+ {
255
+ mmrSizeA: artifact.fromSize,
256
+ mmrSizeB: artifact.toSize,
257
+ paths: artifact.paths,
258
+ },
259
+ artifact.accumulatorFrom,
260
+ opts.trustedAccumulator,
261
+ );
262
+ if (!check.ok) {
263
+ return {
264
+ anchored: false,
265
+ matchedPeak: null,
266
+ reason: "consistency_proof_invalid",
267
+ };
268
+ }
269
+ return { anchored: true, matchedPeak };
270
+ }
271
+
@@ -0,0 +1,168 @@
1
+ /**
2
+ * FOR-390 / ADR-0052 create-log grant assembly: build the grant that CREATES
3
+ * a log and sets its owner (K(L)). Lifted out of the old register-grant-build
4
+ * (plan-2607-21) — register-grant is now writer-only.
5
+ *
6
+ * Three shapes, all `GF_CREATE|GF_EXTEND` in byte 3 (grants.md §5):
7
+ *
8
+ * - `--self-referential` — root bootstrap leaf (`logId == ownerLogId`); the
9
+ * grant is its own authority, so grantData IS the envelope signer and
10
+ * `--signer-pem` is forbidden. Auth-log shaped (`authLogBootstrapShapedFlags`).
11
+ * - `--auth-log` — create a child auth log; grantData = `--signer-pem` (the new
12
+ * log's owner). Auth-log shaped (`authLogBootstrapShapedFlags`).
13
+ * - default — create a data log; grantData = `--signer-pem`. Data-log shaped
14
+ * (`dataLogCreateExtendFlags`).
15
+ *
16
+ * Auth-shaped grants may additionally carry
17
+ * `GF_DERIVED | GF_CHILD_PAYMENT_REQUIRED` (`--child-payment-required`,
18
+ * adr-0062/plan-2608-09): child grants registered under the new log's
19
+ * authority are then x402 payment-gated when a lane enables admission.
20
+ *
21
+ * grantData is the new log owner's ES256 `x||y`. Reuses the register-grant
22
+ * ES256 PEM parser and error class. Node-only (node:crypto PEM handling); no
23
+ * HTTP, no env.
24
+ */
25
+ import {
26
+ authLogBootstrapShapedFlags,
27
+ bytesToForestrieGrantBase64,
28
+ dataLogCreateExtendFlags,
29
+ signGrantPayloadWithEs256Pem,
30
+ withChildPaymentRequired,
31
+ } from "@forestrie/grant-builder";
32
+ import {
33
+ encodeGrantPayloadV0Canonical,
34
+ uuidToBytes,
35
+ type Grant,
36
+ } from "@forestrie/encoding";
37
+ import {
38
+ RegisterGrantBuildError,
39
+ es256PublicKeyXyFromPem,
40
+ } from "./register-grant-build.js";
41
+
42
+ export { RegisterGrantBuildError };
43
+
44
+ export type BuildCreateLogGrantParams = {
45
+ /** The log being created (`grant.logId`, UUID). */
46
+ newLog: string;
47
+ /** Parent/auth log the create grant is sequenced into (`grant.ownerLogId`, UUID). */
48
+ ownerLog: string;
49
+ /** PKCS#8/SEC1 ES256 PEM that signs the grant envelope (the granting authority, K(L)). */
50
+ signWithPem: string;
51
+ /**
52
+ * PEM of the new log's owner (`grantData` = ES256 x||y). Required unless
53
+ * `--self-referential` (which binds the `--sign-with` key itself).
54
+ */
55
+ signerPem?: string | undefined;
56
+ /** Root bootstrap: first leaf of the root log, `logId == ownerLogId`. */
57
+ selfReferential: boolean;
58
+ /** Create a child auth log (auth-log flag class) rather than a data log. */
59
+ authLog: boolean;
60
+ /**
61
+ * Set `GF_DERIVED | GF_CHILD_PAYMENT_REQUIRED` (univocity bits 34/35) on the
62
+ * creation grant, declaring that child grants registered under this log's
63
+ * authority require an x402 payment (adr-0062, plan-2608-09). Auth-shaped
64
+ * grants only — a data log is never the parent authority of a child grant.
65
+ */
66
+ childPaymentRequired?: boolean | undefined;
67
+ };
68
+
69
+ export type BuiltCreateLogGrant = {
70
+ /** Signed grant transparent statement, Forestrie-Grant header base64. */
71
+ grantBase64: string;
72
+ /** Canonical grant v0 payload CBOR (keys 1-6) that was signed. */
73
+ grantPayloadBytes: Uint8Array;
74
+ /** The new log owner this grant commits (ES256 x||y, 64 bytes). */
75
+ grantData: Uint8Array;
76
+ /** 8-byte GF_* flags bitmap that went on the wire. */
77
+ flags: Uint8Array;
78
+ };
79
+
80
+ /**
81
+ * Build and sign the create-log grant transparent statement: canonical grant
82
+ * v0 CBOR (`encodeGrantPayloadV0Canonical`) with `GF_CREATE|GF_EXTEND` flags,
83
+ * signed via the Custodian ES256 PEM profile (`signGrantPayloadWithEs256Pem`),
84
+ * returned as `Authorization: Forestrie-Grant` base64.
85
+ */
86
+ export function buildCreateLogGrant(
87
+ params: BuildCreateLogGrantParams,
88
+ ): BuiltCreateLogGrant {
89
+ let logId: Uint8Array;
90
+ let ownerLogId: Uint8Array;
91
+ try {
92
+ logId = uuidToBytes(params.newLog);
93
+ } catch {
94
+ throw new RegisterGrantBuildError(`--new-log is not a UUID: ${params.newLog}`);
95
+ }
96
+ try {
97
+ ownerLogId = uuidToBytes(params.ownerLog);
98
+ } catch {
99
+ throw new RegisterGrantBuildError(
100
+ `--owner-log is not a UUID: ${params.ownerLog}`,
101
+ );
102
+ }
103
+
104
+ if (params.selfReferential) {
105
+ // Root bootstrap shape: the grant is its own authority (first leaf of the
106
+ // root log), so the new log IS the owner and grantData IS the envelope
107
+ // signer (arbor's root-grant check compares them).
108
+ if (params.newLog.toLowerCase() !== params.ownerLog.toLowerCase()) {
109
+ throw new RegisterGrantBuildError(
110
+ "--self-referential requires --new-log and --owner-log to be the same log (logId == ownerLogId)",
111
+ );
112
+ }
113
+ if (params.signerPem !== undefined) {
114
+ throw new RegisterGrantBuildError(
115
+ "--self-referential grants bind the --sign-with key itself; omit --signer-pem",
116
+ );
117
+ }
118
+ } else if (params.signerPem === undefined) {
119
+ throw new RegisterGrantBuildError(
120
+ "--signer-pem is required (the new log's owner) unless --self-referential",
121
+ );
122
+ }
123
+
124
+ // The new log owner's public key is the committed grantData; the
125
+ // self-referential bootstrap binds the granting key itself.
126
+ const grantData = es256PublicKeyXyFromPem(
127
+ params.signerPem ?? params.signWithPem,
128
+ );
129
+
130
+ let flags =
131
+ params.authLog || params.selfReferential
132
+ ? authLogBootstrapShapedFlags()
133
+ : dataLogCreateExtendFlags();
134
+ if (params.childPaymentRequired === true) {
135
+ if (!params.authLog && !params.selfReferential) {
136
+ throw new RegisterGrantBuildError(
137
+ "--child-payment-required applies to auth logs (--auth-log or --self-referential); a data log is never the parent authority of a child grant",
138
+ );
139
+ }
140
+ flags = withChildPaymentRequired(flags);
141
+ }
142
+
143
+ const grant: Grant = {
144
+ logId,
145
+ ownerLogId,
146
+ grant: flags,
147
+ maxHeight: 0,
148
+ minGrowth: 0,
149
+ grantData,
150
+ };
151
+ const grantPayloadBytes = encodeGrantPayloadV0Canonical(grant);
152
+ let sign1: Uint8Array;
153
+ try {
154
+ sign1 = signGrantPayloadWithEs256Pem(grantPayloadBytes, params.signWithPem);
155
+ } catch (err) {
156
+ throw new RegisterGrantBuildError(
157
+ `--sign-with key rejected: ${
158
+ err instanceof Error ? err.message : String(err)
159
+ }`,
160
+ );
161
+ }
162
+ return {
163
+ grantBase64: bytesToForestrieGrantBase64(sign1),
164
+ grantPayloadBytes,
165
+ grantData,
166
+ flags,
167
+ };
168
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * FOR-390 / ADR-0053: pre-register a child log's public root with the delegation
3
+ * coordinator under PARENT authority, WITHOUT sequencing the create leaf.
4
+ *
5
+ * The parent-signed create grant travels as the `Authorization: Forestrie-Grant`
6
+ * credential; canopy-api verifies it against the parent log's registered root key
7
+ * and forwards `{childLogId -> owner key}` to the coordinator. This lets an owner
8
+ * pre-sign an advance delegation (`forestrie delegate`) for a child logId BEFORE
9
+ * the log exists — no operator onboard token, authority flows down the log
10
+ * hierarchy (ADR-0053). Node/Bun HTTP only.
11
+ */
12
+
13
+ /** Prepare (child public-root pre-registration) failure. */
14
+ export class PrepareLogError extends Error {
15
+ readonly httpStatus?: number;
16
+ constructor(message: string, httpStatus?: number) {
17
+ super(message);
18
+ this.name = "PrepareLogError";
19
+ if (httpStatus !== undefined) this.httpStatus = httpStatus;
20
+ }
21
+ }
22
+
23
+ /** Coordinator forward outcome (best-effort parse of the prepare response). */
24
+ export type PrepareResult = {
25
+ /** "ok" when the child public root was registered with the coordinator. */
26
+ publicRoot: string;
27
+ /** "ok" | "skipped" | "error" when a signing-route webhook was set. */
28
+ webhook?: string | undefined;
29
+ detail?: string | undefined;
30
+ };
31
+
32
+ export type PrepareDeps = { fetchImpl?: typeof fetch | undefined };
33
+
34
+ /**
35
+ * POST the parent-signed create grant to
36
+ * `POST /api/forest/{childLogId}/prepare` (Authorization: Forestrie-Grant), which
37
+ * registers the child's public root with the coordinator under parent authority.
38
+ * Returns the coordinator forward status; throws {@link PrepareLogError} on non-2xx.
39
+ */
40
+ export async function prepareChildLog(
41
+ params: { baseUrl: string; childLogId: string; grantBase64: string },
42
+ deps: PrepareDeps = {},
43
+ ): Promise<PrepareResult> {
44
+ const fetchImpl = deps.fetchImpl ?? fetch;
45
+ const url = `${params.baseUrl.replace(/\/$/, "")}/api/forest/${params.childLogId}/prepare`;
46
+ const res = await fetchImpl(url, {
47
+ method: "POST",
48
+ headers: { Authorization: `Forestrie-Grant ${params.grantBase64}` },
49
+ });
50
+ if (!res.ok) {
51
+ const preview = (await res.text().catch(() => "")).slice(0, 300);
52
+ throw new PrepareLogError(
53
+ `prepare failed: HTTP ${res.status} ${preview}`,
54
+ res.status,
55
+ );
56
+ }
57
+ // Lenient: the endpoint returns the coordinator forward status (JSON or CBOR).
58
+ // A 2xx alone means the public root was registered; extract fields when the
59
+ // body is JSON-parseable, else report a generic success.
60
+ let publicRoot = "ok";
61
+ let webhook: string | undefined;
62
+ let detail: string | undefined;
63
+ try {
64
+ const body = (await res.json()) as Record<string, unknown> & {
65
+ coordinator?: Record<string, unknown>;
66
+ };
67
+ const src = (body.coordinator ?? body) as Record<string, unknown>;
68
+ if (typeof src.publicRoot === "string") publicRoot = src.publicRoot;
69
+ if (typeof src.webhook === "string") webhook = src.webhook;
70
+ if (typeof src.detail === "string") detail = src.detail;
71
+ } catch {
72
+ // non-JSON (e.g. CBOR) — 2xx is success.
73
+ }
74
+ return { publicRoot, webhook, detail };
75
+ }