@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,265 @@
1
+ import type {
2
+ ReceiptVerifyResult,
3
+ ReceiptVerifyStage,
4
+ } from "@forestrie/receipt-verify";
5
+ import type { AnchorCheck } from "./verify-anchored.js";
6
+
7
+ /**
8
+ * Result → report shaping for `forestrie verify` (FOR-347). Pure — the
9
+ * `--json` shape emitted from here is a contract for demo scripting.
10
+ */
11
+
12
+ /** Verification pipeline in narration order (ADR-0045 layers A–C). */
13
+ export const VERIFY_STAGES: readonly ReceiptVerifyStage[] = [
14
+ "parse",
15
+ "signature",
16
+ "inclusion",
17
+ "binding",
18
+ ];
19
+
20
+ /** One line per stage: what the audience sees at every demo step. */
21
+ export const STAGE_NARRATION: Record<ReceiptVerifyStage, string> = {
22
+ parse: "receipt COSE decodes; genesis trust root loads (ES256)",
23
+ signature: "checkpoint signature verifies under the genesis trust key",
24
+ inclusion: "proof path recomputes the checkpoint peak",
25
+ binding: "leaf commits the payload (SHA-256) at the receipt idtimestamp",
26
+ };
27
+
28
+ export type StageStatus = "ok" | "failed" | "skipped";
29
+
30
+ export type StageRow = {
31
+ stage: ReceiptVerifyStage;
32
+ status: StageStatus;
33
+ reason?: string;
34
+ };
35
+
36
+ /**
37
+ * Anchor-only chain verification (FOR-297 approach C): the checkpoint was
38
+ * sealed under a delegation the offline verifier cannot resolve (a child
39
+ * log's per-log delegation does not chain to the root genesis). The
40
+ * signature stage still reports **ok** — not because the COSE check re-ran
41
+ * locally, but because a match against the on-chain accumulator *implies*
42
+ * it: univocity only accepts a checkpoint whose signature verifies under
43
+ * the log's live delegation, whose publish grant's inclusion is re-proven
44
+ * against the parent's on-chain state on EVERY publish, and whose
45
+ * accumulator is consistency-gated against the previously published state.
46
+ * So if the locally-recomputed peak (from `leaf(payload, idtimestamp)` +
47
+ * the receipt's proof path) appears in the accumulator, the publishing
48
+ * signature was valid and a fresh grant chain to the bootstrap existed at
49
+ * publish time — the anchor match combines inclusion, binding, signature
50
+ * validity AND a split-view check (status-2607-09 D2).
51
+ *
52
+ * Enforcement cited from `_Univocity.sol publishCheckpoint` @ univocity
53
+ * ea410d5a90e4c2337fc1dec288d660551daf36ab:
54
+ * - signature under the live delegation: `_verifyCheckpointSignature`,
55
+ * src/contracts/_Univocity.sol#L199-L208 (reverts on failure)
56
+ * - grant inclusion re-verified each publish (rules 1–3):
57
+ * `_applyInclusionGrant`, src/contracts/_Univocity.sol#L211-L219
58
+ * - grant bounds (rule 4): src/contracts/_Univocity.sol#L181-L188
59
+ * - consistency gating: `verifyConsistencyProofChain`,
60
+ * src/contracts/_Univocity.sol#L189-L193
61
+ */
62
+ export const SIGNATURE_ANCHORED_REASON =
63
+ "verified against accumulator from chain — signature enforced by univocity at publish";
64
+
65
+ export function anchorOnlyStageRows(): StageRow[] {
66
+ return [
67
+ { stage: "parse", status: "ok" },
68
+ {
69
+ stage: "signature",
70
+ status: "ok",
71
+ reason: SIGNATURE_ANCHORED_REASON,
72
+ },
73
+ { stage: "inclusion", status: "ok" },
74
+ { stage: "binding", status: "ok" },
75
+ ];
76
+ }
77
+
78
+ /**
79
+ * Expand the library's `{ok, stage, reason}` into one row per stage:
80
+ * on success all four pass; on failure the reported stage failed, earlier
81
+ * stages passed, later stages were not reached.
82
+ *
83
+ * A stage this CLI does not know (a future `@forestrie/receipt-verify`
84
+ * addition) still renders explicitly as the failed row — degrading it to
85
+ * four silent "skipped" rows would hide the failure (F3, plan-2607-14
86
+ * W1.3). We cannot order an unknown stage among the known ones, so the
87
+ * known stages read "skipped" (not evaluated by this CLI's knowledge) and
88
+ * the unknown stage carries the failure.
89
+ */
90
+ /**
91
+ * Caller-known trust anchor (FOR-297 D1): the claim is verified under a key
92
+ * the CALLER supplied out of band, not one derived from genesis — the output
93
+ * must name that anchor so the two claims are never confusable. The known key
94
+ * asserts the key↔log binding (its provenance is the only defence), gives no
95
+ * grant-lifecycle visibility and no split-view protection.
96
+ */
97
+ export const KNOWN_KEY_PARSE_REASON =
98
+ "receipt COSE decodes; caller-known log key loads (ES256)";
99
+ export const KNOWN_KEY_SIGNATURE_REASON =
100
+ "verifies under caller-known log key (not genesis-derived)";
101
+
102
+ /** Stage rows for a verify run anchored on `--known-log-key` (FOR-297 D1). */
103
+ export function knownKeyStageRows(result: ReceiptVerifyResult): StageRow[] {
104
+ return stageRows(result).map((row) => {
105
+ if (row.status !== "ok") return row;
106
+ if (row.stage === "parse") return { ...row, reason: KNOWN_KEY_PARSE_REASON };
107
+ if (row.stage === "signature")
108
+ return { ...row, reason: KNOWN_KEY_SIGNATURE_REASON };
109
+ return row;
110
+ });
111
+ }
112
+
113
+ export function stageRows(result: ReceiptVerifyResult): StageRow[] {
114
+ if (result.ok) {
115
+ return VERIFY_STAGES.map((stage) => ({ stage, status: "ok" }));
116
+ }
117
+ const failedAt = VERIFY_STAGES.indexOf(result.stage);
118
+ if (failedAt === -1) {
119
+ return [
120
+ ...VERIFY_STAGES.map((stage) => ({
121
+ stage,
122
+ status: "skipped" as const,
123
+ })),
124
+ {
125
+ stage: result.stage,
126
+ status: "failed" as const,
127
+ reason: result.reason ?? "unknown",
128
+ },
129
+ ];
130
+ }
131
+ return VERIFY_STAGES.map((stage, i) => {
132
+ if (i < failedAt) return { stage, status: "ok" as const };
133
+ if (i === failedAt) {
134
+ return {
135
+ stage,
136
+ status: "failed" as const,
137
+ reason: result.reason ?? "unknown",
138
+ };
139
+ }
140
+ return { stage, status: "skipped" as const };
141
+ });
142
+ }
143
+
144
+ /** `--json` anchor block (chain / known-accumulator modes). Sizes are
145
+ * decimal strings. */
146
+ export type AnchorReport = {
147
+ univocity: string;
148
+ logId: string;
149
+ anchored: boolean;
150
+ anchoredSize: string;
151
+ peakCount: number;
152
+ matchedPeak: number | null;
153
+ reason?: string;
154
+ /** known-accumulator anchors only: the audited chain read (FOR-297 D5). */
155
+ blockNumber?: string;
156
+ blockHash?: string;
157
+ /** True when the match required proof-path extension via massif nodes. */
158
+ extended?: boolean;
159
+ /** Historical event-scan anchor (FOR-368): the peak matched a prior
160
+ * CheckpointPublished accumulator; consistency-gated forward. */
161
+ historical?: boolean;
162
+ anchoredAtBlock?: string;
163
+ txHash?: string;
164
+ /** Checkpoint-chain anchors only (FOR-368 Phase 3): links folded and the
165
+ * sealed size of the link that held the peak. */
166
+ linkCount?: number;
167
+ matchedLinkSize?: string;
168
+ /** Consistency-proof top-up (FOR-368 Phase 3): base size the untrusted
169
+ * artifact extended the receipt from into the trusted snapshot. */
170
+ topUpBaseSize?: string;
171
+ };
172
+
173
+ export type VerifyMode =
174
+ | "offline"
175
+ | "chain-anchored"
176
+ | "accumulator-anchored"
177
+ | "checkpoint-chain-anchored";
178
+
179
+ /** `--json` report on stdout — stable shape for demo scripting. */
180
+ export type VerifyReport = {
181
+ command: "verify";
182
+ mode: VerifyMode;
183
+ ok: boolean;
184
+ stage: ReceiptVerifyStage;
185
+ reason?: string;
186
+ stages: StageRow[];
187
+ anchor?: AnchorReport;
188
+ };
189
+
190
+ /** Extra anchor facts present only for known-accumulator checks. */
191
+ type SnapshotAnchorFacts = {
192
+ blockNumber?: bigint;
193
+ blockHashHex?: string;
194
+ extended?: boolean;
195
+ };
196
+
197
+ export function buildVerifyReport(opts: {
198
+ ok: boolean;
199
+ result: ReceiptVerifyResult;
200
+ mode: VerifyMode;
201
+ anchor?: (AnchorCheck & SnapshotAnchorFacts) | undefined;
202
+ univocity?: string | undefined;
203
+ logId?: string | undefined;
204
+ /** Anchor-only mode: rows that reflect the skipped signature stage. */
205
+ stagesOverride?: StageRow[] | undefined;
206
+ }): VerifyReport {
207
+ const report: VerifyReport = {
208
+ command: "verify",
209
+ mode: opts.mode,
210
+ ok: opts.ok,
211
+ stage: opts.result.stage,
212
+ stages: opts.stagesOverride ?? stageRows(opts.result),
213
+ };
214
+ if (opts.result.reason !== undefined) {
215
+ report.reason = opts.result.reason;
216
+ }
217
+ if (opts.anchor !== undefined) {
218
+ const anchor: AnchorReport = {
219
+ univocity: opts.univocity ?? "",
220
+ logId: opts.logId ?? "",
221
+ anchored: opts.anchor.anchored,
222
+ anchoredSize: opts.anchor.size.toString(),
223
+ peakCount: opts.anchor.accumulator.length,
224
+ matchedPeak: opts.anchor.matchedPeak,
225
+ };
226
+ if (opts.anchor.reason !== undefined) {
227
+ anchor.reason = opts.anchor.reason;
228
+ }
229
+ if (opts.anchor.blockNumber !== undefined) {
230
+ anchor.blockNumber = opts.anchor.blockNumber.toString();
231
+ anchor.blockHash = `0x${opts.anchor.blockHashHex ?? ""}`;
232
+ anchor.extended = opts.anchor.extended === true;
233
+ }
234
+ const historical = opts.anchor as {
235
+ historical?: boolean;
236
+ anchoredAtBlock?: bigint;
237
+ txHash?: string;
238
+ };
239
+ if (historical.historical === true) {
240
+ anchor.historical = true;
241
+ if (historical.anchoredAtBlock !== undefined) {
242
+ anchor.anchoredAtBlock = historical.anchoredAtBlock.toString();
243
+ }
244
+ if (historical.txHash !== undefined) {
245
+ anchor.txHash = historical.txHash;
246
+ }
247
+ }
248
+ const chained = opts.anchor as {
249
+ linkCount?: number;
250
+ matchedLinkSize?: bigint | null;
251
+ };
252
+ if (chained.linkCount !== undefined) {
253
+ anchor.linkCount = chained.linkCount;
254
+ if (chained.matchedLinkSize !== undefined && chained.matchedLinkSize !== null) {
255
+ anchor.matchedLinkSize = chained.matchedLinkSize.toString();
256
+ }
257
+ }
258
+ const topUp = opts.anchor as { topUpBaseSize?: bigint };
259
+ if (topUp.topUpBaseSize !== undefined) {
260
+ anchor.topUpBaseSize = topUp.topUpBaseSize.toString();
261
+ }
262
+ report.anchor = anchor;
263
+ }
264
+ return report;
265
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * x402 `exact` payer: sign an EIP-3009 `transferWithAuthorization` against a
3
+ * decoded `X-PAYMENT-REQUIRED` challenge (base64 header value in, base64
4
+ * `X-PAYMENT` header value out). Matches the payload shape canopy's
5
+ * `parsePaymentHeader` expects and the facilitator settles.
6
+ *
7
+ * REAL money moves when a signed payment settles (testnet USDC on dev). The
8
+ * payer key never leaves this process and is never logged.
9
+ */
10
+
11
+ import { privateKeyToAccount } from "viem/accounts";
12
+
13
+ interface X402Option {
14
+ scheme: string;
15
+ network: string;
16
+ payTo: string;
17
+ asset: string;
18
+ amount: string;
19
+ maxTimeoutSeconds?: number;
20
+ extra?: { name?: string; version?: string };
21
+ }
22
+
23
+ function b64ToUtf8(b64: string): string {
24
+ return Buffer.from(b64, "base64").toString("utf8");
25
+ }
26
+
27
+ function randomNonceHex(): `0x${string}` {
28
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
29
+ return `0x${Array.from(bytes)
30
+ .map((b) => b.toString(16).padStart(2, "0"))
31
+ .join("")}` as `0x${string}`;
32
+ }
33
+
34
+ export async function signX402Payment(
35
+ paymentRequiredB64: string,
36
+ payerKeyHex: string,
37
+ nowSec = Math.floor(Date.now() / 1000),
38
+ ): Promise<string> {
39
+ const decoded = JSON.parse(b64ToUtf8(paymentRequiredB64)) as {
40
+ accepts?: X402Option[];
41
+ };
42
+ const chosen = (decoded.accepts ?? []).find((o) => o.scheme === "exact");
43
+ if (!chosen) {
44
+ throw new Error("X-PAYMENT-REQUIRED has no 'exact' scheme option");
45
+ }
46
+ if (!chosen.extra?.name || !chosen.extra?.version) {
47
+ throw new Error("challenge lacks EIP-712 domain name/version in extra");
48
+ }
49
+
50
+ const key = payerKeyHex.startsWith("0x")
51
+ ? (payerKeyHex as `0x${string}`)
52
+ : (`0x${payerKeyHex}` as `0x${string}`);
53
+ const account = privateKeyToAccount(key);
54
+ const nonce = randomNonceHex();
55
+ const validAfter = BigInt(nowSec - 600);
56
+ const validBefore = BigInt(nowSec + (chosen.maxTimeoutSeconds ?? 300));
57
+
58
+ const signature = await account.signTypedData({
59
+ domain: {
60
+ name: chosen.extra.name,
61
+ version: chosen.extra.version,
62
+ chainId: Number(chosen.network.split(":")[1]),
63
+ verifyingContract: chosen.asset as `0x${string}`,
64
+ },
65
+ types: {
66
+ TransferWithAuthorization: [
67
+ { name: "from", type: "address" },
68
+ { name: "to", type: "address" },
69
+ { name: "value", type: "uint256" },
70
+ { name: "validAfter", type: "uint256" },
71
+ { name: "validBefore", type: "uint256" },
72
+ { name: "nonce", type: "bytes32" },
73
+ ],
74
+ },
75
+ primaryType: "TransferWithAuthorization",
76
+ message: {
77
+ from: account.address,
78
+ to: chosen.payTo as `0x${string}`,
79
+ value: BigInt(chosen.amount),
80
+ validAfter,
81
+ validBefore,
82
+ nonce,
83
+ },
84
+ });
85
+
86
+ const payload = {
87
+ x402Version: 2,
88
+ payload: {
89
+ authorization: {
90
+ from: account.address,
91
+ to: chosen.payTo,
92
+ value: chosen.amount,
93
+ validAfter: validAfter.toString(),
94
+ validBefore: validBefore.toString(),
95
+ nonce,
96
+ },
97
+ signature,
98
+ },
99
+ resource: {
100
+ url: "",
101
+ description: "forestrie onboard request",
102
+ mimeType: "application/cbor",
103
+ },
104
+ accepted: {
105
+ scheme: "exact",
106
+ network: chosen.network,
107
+ asset: chosen.asset,
108
+ amount: chosen.amount,
109
+ payTo: chosen.payTo,
110
+ maxTimeoutSeconds: chosen.maxTimeoutSeconds ?? 300,
111
+ extra: chosen.extra,
112
+ },
113
+ };
114
+ return Buffer.from(JSON.stringify(payload)).toString("base64");
115
+ }
@@ -0,0 +1,216 @@
1
+ import type { Out } from "@forestrie/cli-kit/reporting";
2
+ import { writeOutputFile } from "../lib/fsio.js";
3
+ import {
4
+ decodeCborDeterministic,
5
+ encodeCborDeterministic,
6
+ } from "@forestrie/encoding";
7
+ import type { AdminOnboardTokenOptions } from "../options/admin-onboard-token.js";
8
+ import {
9
+ readDeploymentRecord,
10
+ univocityAddrHex,
11
+ } from "../lib/deployment-record.js";
12
+
13
+ /**
14
+ * FOR-406 (plan-2607-27 W1): mint a forest onboard token from
15
+ * `POST /api/payments/onboard-tokens` under the operator credential.
16
+ *
17
+ * The minted token is the ONLY product on stdout (or `--out`) so the
18
+ * command composes: `ONBOARD_TOKEN=$(forestrie admin onboard-token …)`.
19
+ * The operator credential is never logged or echoed, including in errors.
20
+ * Public onboarding is x402 (ARC-0015) — a future sibling token source
21
+ * feeding the same `onboard-genesis --onboard-token` input.
22
+ */
23
+
24
+ /** Mint request body keys (canopy payments onboard-tokens contract). */
25
+ const MINT_LABEL_KEY = 1;
26
+ const MINT_CHAIN_ID_KEY = 3;
27
+ const MINT_UNIVOCITY_ADDR_KEY = 4;
28
+
29
+ /** `--json` success shape (the token itself; cref for operator records). */
30
+ export type AdminOnboardTokenReport = {
31
+ command: "admin onboard-token";
32
+ status: "minted";
33
+ label: string;
34
+ token: string;
35
+ cref?: string;
36
+ /** Present when `--out` was given (token then omitted from stdout). */
37
+ out?: string;
38
+ };
39
+
40
+ export type AdminOnboardTokenErrorReport = {
41
+ error: "input_failed" | "mint_failed" | "network_failed" | "response_malformed";
42
+ command: "admin onboard-token";
43
+ message: string;
44
+ httpStatus?: number;
45
+ };
46
+
47
+ /** Test seam. */
48
+ export type AdminOnboardTokenDeps = {
49
+ fetchImpl?: typeof fetch;
50
+ };
51
+
52
+ function mintUrl(baseUrl: string): string {
53
+ return `${baseUrl.replace(/\/$/, "")}/api/payments/onboard-tokens`;
54
+ }
55
+
56
+ /** Extract a required text field from the decoded CBOR response. */
57
+ function responseText(
58
+ decoded: unknown,
59
+ field: string,
60
+ ): string | undefined {
61
+ const value =
62
+ decoded instanceof Map
63
+ ? decoded.get(field)
64
+ : decoded && typeof decoded === "object"
65
+ ? (decoded as Record<string, unknown>)[field]
66
+ : undefined;
67
+ return typeof value === "string" && value.length > 0 ? value : undefined;
68
+ }
69
+
70
+ function reportError(
71
+ out: Out,
72
+ options: AdminOnboardTokenOptions,
73
+ report: AdminOnboardTokenErrorReport,
74
+ ): void {
75
+ if (options.json) {
76
+ out.out(JSON.stringify(report, null, 2));
77
+ } else {
78
+ out.warn("forestrie admin onboard-token: %s", report.message);
79
+ if (report.httpStatus !== undefined) {
80
+ out.warn(" httpStatus: %d", report.httpStatus);
81
+ }
82
+ }
83
+ process.exitCode = 1;
84
+ }
85
+
86
+ export async function runAdminOnboardToken(
87
+ out: Out,
88
+ options: AdminOnboardTokenOptions,
89
+ deps: AdminOnboardTokenDeps = {},
90
+ ): Promise<void> {
91
+ const fetchImpl = deps.fetchImpl ?? fetch;
92
+
93
+ // Bindings are mandatory on every token (ADR-0059 D7): the mint reserves
94
+ // the named univocity instance, and canopy 400s an unbound mint.
95
+ let chainId: string;
96
+ let univocityAddr: string;
97
+ try {
98
+ if (options.deployment !== undefined) {
99
+ const record = readDeploymentRecord(options.deployment);
100
+ if (record.chainId === undefined) {
101
+ throw new Error(
102
+ `${options.deployment}: deployment carries no chainId; pass --chain-id`,
103
+ );
104
+ }
105
+ chainId = record.chainId;
106
+ univocityAddr = univocityAddrHex(record);
107
+ } else if (options.chainId !== undefined && options.univocityAddr !== undefined) {
108
+ chainId = options.chainId.trim();
109
+ univocityAddr = univocityAddrHex({
110
+ imutableUnivocity: options.univocityAddr,
111
+ });
112
+ } else {
113
+ throw new Error(
114
+ "chain binding required: pass --deployment, or --chain-id with --univocity",
115
+ );
116
+ }
117
+ } catch (err) {
118
+ reportError(out, options, {
119
+ error: "input_failed",
120
+ command: "admin onboard-token",
121
+ message: err instanceof Error ? err.message : String(err),
122
+ });
123
+ return;
124
+ }
125
+
126
+ const body = encodeCborDeterministic(
127
+ new Map<number, string>([
128
+ [MINT_LABEL_KEY, options.label],
129
+ [MINT_CHAIN_ID_KEY, chainId],
130
+ [MINT_UNIVOCITY_ADDR_KEY, univocityAddr],
131
+ ]),
132
+ );
133
+
134
+ let res: Response;
135
+ try {
136
+ res = await fetchImpl(mintUrl(options.baseUrl), {
137
+ method: "POST",
138
+ headers: {
139
+ Authorization: `Bearer ${options.opsToken}`,
140
+ "Content-Type": "application/cbor",
141
+ Accept: "application/cbor",
142
+ },
143
+ body: body.buffer.slice(
144
+ body.byteOffset,
145
+ body.byteOffset + body.byteLength,
146
+ ) as ArrayBuffer,
147
+ });
148
+ } catch (err) {
149
+ reportError(out, options, {
150
+ error: "network_failed",
151
+ command: "admin onboard-token",
152
+ // The ops token never appears in fetch errors; message is URL-level.
153
+ message: err instanceof Error ? err.message : String(err),
154
+ });
155
+ return;
156
+ }
157
+
158
+ if (!res.ok) {
159
+ reportError(out, options, {
160
+ error: "mint_failed",
161
+ command: "admin onboard-token",
162
+ message: `mint rejected: HTTP ${res.status}`,
163
+ httpStatus: res.status,
164
+ });
165
+ return;
166
+ }
167
+
168
+ let token: string | undefined;
169
+ let cref: string | undefined;
170
+ try {
171
+ const decoded = decodeCborDeterministic(
172
+ new Uint8Array(await res.arrayBuffer()),
173
+ );
174
+ token = responseText(decoded, "token");
175
+ cref = responseText(decoded, "cref");
176
+ } catch (err) {
177
+ reportError(out, options, {
178
+ error: "response_malformed",
179
+ command: "admin onboard-token",
180
+ message: `mint response is not valid CBOR: ${
181
+ err instanceof Error ? err.message : String(err)
182
+ }`,
183
+ });
184
+ return;
185
+ }
186
+ if (token === undefined) {
187
+ reportError(out, options, {
188
+ error: "response_malformed",
189
+ command: "admin onboard-token",
190
+ message: "mint response carried no token field",
191
+ });
192
+ return;
193
+ }
194
+
195
+ if (options.out !== undefined) {
196
+ await writeOutputFile(options.out, token);
197
+ }
198
+ if (options.json) {
199
+ const report: AdminOnboardTokenReport = {
200
+ command: "admin onboard-token",
201
+ status: "minted",
202
+ label: options.label,
203
+ token,
204
+ };
205
+ if (cref !== undefined) report.cref = cref;
206
+ if (options.out !== undefined) report.out = options.out;
207
+ out.out(JSON.stringify(report, null, 2));
208
+ return;
209
+ }
210
+ if (options.out !== undefined) {
211
+ out.print("wrote onboard token (label %s) to %s", options.label, options.out);
212
+ return;
213
+ }
214
+ // The token is the pipeable product: nothing else on stdout.
215
+ out.out("%s", token);
216
+ }