@forestrie/mcp-register 0.0.0-stage → 0.1.1

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 (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +178 -2
  3. package/bin/mcp-register.mjs +2 -0
  4. package/dist/core/base64.d.ts +7 -0
  5. package/dist/core/base64.d.ts.map +1 -0
  6. package/dist/core/base64.js +20 -0
  7. package/dist/core/base64.js.map +1 -0
  8. package/dist/core/delegation.d.ts +56 -0
  9. package/dist/core/delegation.d.ts.map +1 -0
  10. package/dist/core/delegation.js +130 -0
  11. package/dist/core/delegation.js.map +1 -0
  12. package/dist/core/grant.d.ts +41 -0
  13. package/dist/core/grant.d.ts.map +1 -0
  14. package/dist/core/grant.js +78 -0
  15. package/dist/core/grant.js.map +1 -0
  16. package/dist/core/hex.d.ts +4 -0
  17. package/dist/core/hex.d.ts.map +1 -0
  18. package/dist/core/hex.js +18 -0
  19. package/dist/core/hex.js.map +1 -0
  20. package/dist/core/index.d.ts +28 -0
  21. package/dist/core/index.d.ts.map +1 -0
  22. package/dist/core/index.js +21 -0
  23. package/dist/core/index.js.map +1 -0
  24. package/dist/core/issue.d.ts +38 -0
  25. package/dist/core/issue.d.ts.map +1 -0
  26. package/dist/core/issue.js +135 -0
  27. package/dist/core/issue.js.map +1 -0
  28. package/dist/core/limits.d.ts +9 -0
  29. package/dist/core/limits.d.ts.map +1 -0
  30. package/dist/core/limits.js +9 -0
  31. package/dist/core/limits.js.map +1 -0
  32. package/dist/core/scrapi.d.ts +66 -0
  33. package/dist/core/scrapi.d.ts.map +1 -0
  34. package/dist/core/scrapi.js +174 -0
  35. package/dist/core/scrapi.js.map +1 -0
  36. package/dist/core/signer.d.ts +24 -0
  37. package/dist/core/signer.d.ts.map +1 -0
  38. package/dist/core/signer.js +2 -0
  39. package/dist/core/signer.js.map +1 -0
  40. package/dist/core/statement.d.ts +26 -0
  41. package/dist/core/statement.d.ts.map +1 -0
  42. package/dist/core/statement.js +70 -0
  43. package/dist/core/statement.js.map +1 -0
  44. package/dist/core/version.d.ts +23 -0
  45. package/dist/core/version.d.ts.map +1 -0
  46. package/dist/core/version.js +23 -0
  47. package/dist/core/version.js.map +1 -0
  48. package/dist/core/x402.d.ts +99 -0
  49. package/dist/core/x402.d.ts.map +1 -0
  50. package/dist/core/x402.js +202 -0
  51. package/dist/core/x402.js.map +1 -0
  52. package/dist/net/delegate.d.ts +56 -0
  53. package/dist/net/delegate.d.ts.map +1 -0
  54. package/dist/net/delegate.js +182 -0
  55. package/dist/net/delegate.js.map +1 -0
  56. package/dist/net/http.d.ts +17 -0
  57. package/dist/net/http.d.ts.map +1 -0
  58. package/dist/net/http.js +52 -0
  59. package/dist/net/http.js.map +1 -0
  60. package/dist/net/index.d.ts +14 -0
  61. package/dist/net/index.d.ts.map +1 -0
  62. package/dist/net/index.js +13 -0
  63. package/dist/net/index.js.map +1 -0
  64. package/dist/net/write.d.ts +55 -0
  65. package/dist/net/write.d.ts.map +1 -0
  66. package/dist/net/write.js +46 -0
  67. package/dist/net/write.js.map +1 -0
  68. package/dist/node/cli.d.ts +8 -0
  69. package/dist/node/cli.d.ts.map +1 -0
  70. package/dist/node/cli.js +143 -0
  71. package/dist/node/cli.js.map +1 -0
  72. package/dist/node/grant-store.d.ts +49 -0
  73. package/dist/node/grant-store.d.ts.map +1 -0
  74. package/dist/node/grant-store.js +95 -0
  75. package/dist/node/grant-store.js.map +1 -0
  76. package/dist/node/grant-tools.d.ts +29 -0
  77. package/dist/node/grant-tools.d.ts.map +1 -0
  78. package/dist/node/grant-tools.js +498 -0
  79. package/dist/node/grant-tools.js.map +1 -0
  80. package/dist/node/ledger.d.ts +26 -0
  81. package/dist/node/ledger.d.ts.map +1 -0
  82. package/dist/node/ledger.js +78 -0
  83. package/dist/node/ledger.js.map +1 -0
  84. package/dist/node/payer.d.ts +48 -0
  85. package/dist/node/payer.d.ts.map +1 -0
  86. package/dist/node/payer.js +170 -0
  87. package/dist/node/payer.js.map +1 -0
  88. package/dist/node/resolve-input.d.ts +19 -0
  89. package/dist/node/resolve-input.d.ts.map +1 -0
  90. package/dist/node/resolve-input.js +76 -0
  91. package/dist/node/resolve-input.js.map +1 -0
  92. package/dist/node/server.d.ts +47 -0
  93. package/dist/node/server.d.ts.map +1 -0
  94. package/dist/node/server.js +212 -0
  95. package/dist/node/server.js.map +1 -0
  96. package/dist/node/signer-file.d.ts +34 -0
  97. package/dist/node/signer-file.d.ts.map +1 -0
  98. package/dist/node/signer-file.js +183 -0
  99. package/dist/node/signer-file.js.map +1 -0
  100. package/dist/node/store.d.ts +40 -0
  101. package/dist/node/store.d.ts.map +1 -0
  102. package/dist/node/store.js +117 -0
  103. package/dist/node/store.js.map +1 -0
  104. package/dist/node/text.d.ts +29 -0
  105. package/dist/node/text.d.ts.map +1 -0
  106. package/dist/node/text.js +124 -0
  107. package/dist/node/text.js.map +1 -0
  108. package/dist/node/tools.d.ts +118 -0
  109. package/dist/node/tools.d.ts.map +1 -0
  110. package/dist/node/tools.js +548 -0
  111. package/dist/node/tools.js.map +1 -0
  112. package/package.json +108 -3
  113. package/src/core/base64.ts +18 -0
  114. package/src/core/delegation.ts +239 -0
  115. package/src/core/grant.ts +127 -0
  116. package/src/core/hex.ts +18 -0
  117. package/src/core/index.ts +84 -0
  118. package/src/core/issue.ts +209 -0
  119. package/src/core/limits.ts +8 -0
  120. package/src/core/scrapi.ts +236 -0
  121. package/src/core/signer.ts +23 -0
  122. package/src/core/statement.ts +133 -0
  123. package/src/core/version.ts +25 -0
  124. package/src/core/x402.ts +314 -0
  125. package/src/net/delegate.ts +274 -0
  126. package/src/net/http.ts +71 -0
  127. package/src/net/index.ts +26 -0
  128. package/src/net/write.ts +124 -0
  129. package/src/node/cli.ts +170 -0
  130. package/src/node/grant-store.ts +154 -0
  131. package/src/node/grant-tools.ts +707 -0
  132. package/src/node/ledger.ts +98 -0
  133. package/src/node/payer.ts +241 -0
  134. package/src/node/resolve-input.ts +96 -0
  135. package/src/node/server.ts +332 -0
  136. package/src/node/signer-file.ts +251 -0
  137. package/src/node/store.ts +175 -0
  138. package/src/node/text.ts +153 -0
  139. package/src/node/tools.ts +810 -0
@@ -0,0 +1,314 @@
1
+ /**
2
+ * x402 over already-decoded JSON: read canopy's payment requirements, pick
3
+ * the one option this server may pay, and refuse anything outside its caps.
4
+ *
5
+ * canopy's register-grant gate (canopy `scrapi/register-grant-payment.ts`)
6
+ * answers a payment-gated child grant with 402 and an `X-PAYMENT-REQUIRED`
7
+ * header: base64 of x402 v2 `PaymentRequirements`
8
+ * (`{x402Version: 2, accepts: [{scheme: "exact", network, amount, asset,
9
+ * payTo, maxTimeoutSeconds, extra}], resource: {url, …}}`), priced as a
10
+ * unit price × the child grant's `maxHeight`. The retry carries
11
+ * `X-PAYMENT`: base64 of the x402 v2 `PaymentPayload` (an EIP-3009
12
+ * authorisation for the exact amount).
13
+ *
14
+ * The caps are the server's configuration, never a tool argument: a model
15
+ * (or whatever prompt-injected it) cannot raise them. Every amount is in the
16
+ * asset's atomic units.
17
+ */
18
+
19
+ export class PaymentError extends Error {
20
+ constructor(
21
+ readonly code:
22
+ "invalid_payment_requirements" | "invalid_payment" | CapRefusalCode,
23
+ message: string,
24
+ ) {
25
+ super(message);
26
+ }
27
+ }
28
+
29
+ export type CapRefusalCode =
30
+ | "cap_network"
31
+ | "cap_asset"
32
+ | "cap_testnet_only"
33
+ | "cap_payee"
34
+ | "cap_resource"
35
+ | "cap_per_call"
36
+ | "cap_per_day";
37
+
38
+ export interface PaymentOption {
39
+ scheme: "exact";
40
+ network: string;
41
+ amount: string;
42
+ asset: string;
43
+ payTo: string;
44
+ maxTimeoutSeconds?: number;
45
+ extra?: Record<string, unknown>;
46
+ }
47
+
48
+ export interface PaymentRequired {
49
+ x402Version: 2;
50
+ accepts: PaymentOption[];
51
+ resource?: { url: string; description?: string; mimeType?: string };
52
+ error?: string;
53
+ }
54
+
55
+ /** Hard limits from the server's configuration. */
56
+ export interface PaymentCaps {
57
+ /** The one CAIP-2 network this server pays on, e.g. `eip155:84532`. */
58
+ network: string;
59
+ /** The one asset contract it pays in, lowercase `0x…`. */
60
+ asset: string;
61
+ maxPerCallAtomic: bigint;
62
+ maxPerDayAtomic: bigint;
63
+ /** When set, the only payee it pays. */
64
+ payTo?: string;
65
+ /** Pay on a network outside {@link TESTNETS}. Off by default. */
66
+ allowMainnet: boolean;
67
+ }
68
+
69
+ /** EVM testnets x402 facilitators settle on. Anything else is mainnet money. */
70
+ export const TESTNETS: ReadonlySet<string> = new Set([
71
+ "eip155:84532", // Base Sepolia
72
+ "eip155:11155111", // Ethereum Sepolia
73
+ "eip155:421614", // Arbitrum Sepolia
74
+ "eip155:11155420", // OP Sepolia
75
+ "eip155:80002", // Polygon Amoy
76
+ "eip155:43113", // Avalanche Fuji
77
+ ]);
78
+
79
+ /** Base Sepolia, and Circle's USDC on it: the network and asset canopy prices in. */
80
+ export const DEFAULT_PAY_NETWORK = "eip155:84532";
81
+ export const DEFAULT_PAY_ASSET = "0x036cbd53842c5426634e7929541ec2318f3dcf7e";
82
+
83
+ const ATOMIC = /^[0-9]{1,78}$/;
84
+ const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
85
+
86
+ function isObject(v: unknown): v is Record<string, unknown> {
87
+ return typeof v === "object" && v !== null && !Array.isArray(v);
88
+ }
89
+
90
+ /** Validate decoded `X-PAYMENT-REQUIRED` JSON. Throws `PaymentError`. */
91
+ export function readPaymentRequired(json: unknown): PaymentRequired {
92
+ const bad = (why: string) =>
93
+ new PaymentError(
94
+ "invalid_payment_requirements",
95
+ `canopy's X-PAYMENT-REQUIRED is not x402 v2 payment requirements: ${why}`,
96
+ );
97
+ if (!isObject(json)) throw bad("not a JSON object");
98
+ if (json["x402Version"] !== 2) throw bad("x402Version is not 2");
99
+ const accepts = json["accepts"];
100
+ if (!Array.isArray(accepts) || accepts.length === 0)
101
+ throw bad("accepts is empty");
102
+ const options: PaymentOption[] = [];
103
+ for (const a of accepts) {
104
+ if (!isObject(a)) throw bad("an accepts entry is not an object");
105
+ const { scheme, network, amount, asset, payTo } = a;
106
+ if (
107
+ typeof scheme !== "string" ||
108
+ typeof network !== "string" ||
109
+ typeof amount !== "string" ||
110
+ typeof asset !== "string" ||
111
+ typeof payTo !== "string"
112
+ )
113
+ throw bad(
114
+ "an accepts entry lacks scheme, network, amount, asset or payTo",
115
+ );
116
+ if (!ATOMIC.test(amount))
117
+ throw bad(`amount ${amount} is not an atomic-unit integer`);
118
+ if (!ADDRESS.test(asset) || !ADDRESS.test(payTo))
119
+ throw bad("asset and payTo must be 0x-prefixed 20-byte addresses");
120
+ if (scheme !== "exact") continue; // the only scheme canopy offers
121
+ options.push({
122
+ scheme,
123
+ network,
124
+ amount,
125
+ asset,
126
+ payTo,
127
+ ...(typeof a["maxTimeoutSeconds"] === "number"
128
+ ? { maxTimeoutSeconds: a["maxTimeoutSeconds"] }
129
+ : {}),
130
+ ...(isObject(a["extra"]) ? { extra: a["extra"] } : {}),
131
+ });
132
+ }
133
+ if (options.length === 0) throw bad('no accepts entry has scheme "exact"');
134
+ const resource = json["resource"];
135
+ return {
136
+ x402Version: 2,
137
+ accepts: options,
138
+ ...(isObject(resource) && typeof resource["url"] === "string"
139
+ ? {
140
+ resource: {
141
+ url: resource["url"],
142
+ ...(typeof resource["description"] === "string"
143
+ ? { description: resource["description"] }
144
+ : {}),
145
+ ...(typeof resource["mimeType"] === "string"
146
+ ? { mimeType: resource["mimeType"] }
147
+ : {}),
148
+ },
149
+ }
150
+ : {}),
151
+ ...(typeof json["error"] === "string" ? { error: json["error"] } : {}),
152
+ };
153
+ }
154
+
155
+ export interface CapContext {
156
+ /** The URL the payment is for: the register-grant URL this server POSTed. */
157
+ resourceUrl: string;
158
+ /** Atomic units already committed today (UTC), on this network and asset. */
159
+ spentTodayAtomic: bigint;
160
+ }
161
+
162
+ /**
163
+ * The one option this server may pay, or a `PaymentError` naming the cap
164
+ * that refuses it. Checked in the order a reader would ask: where, in what,
165
+ * to whom, for what, how much now, how much today.
166
+ */
167
+ export function selectWithinCaps(
168
+ required: PaymentRequired,
169
+ caps: PaymentCaps,
170
+ ctx: CapContext,
171
+ ): PaymentOption {
172
+ const offered = (f: (o: PaymentOption) => string) =>
173
+ [...new Set(required.accepts.map(f))].join(", ");
174
+ if (!TESTNETS.has(caps.network) && !caps.allowMainnet)
175
+ throw new PaymentError(
176
+ "cap_testnet_only",
177
+ `the configured network ${caps.network} is not a testnet, and paying on a mainnet is off (set MCP_REGISTER_PAY_ALLOW_MAINNET=1 to allow it)`,
178
+ );
179
+ const onNetwork = required.accepts.filter((o) => o.network === caps.network);
180
+ if (onNetwork.length === 0)
181
+ throw new PaymentError(
182
+ "cap_network",
183
+ `canopy asks for payment on ${offered((o) => o.network)}; this server pays only on ${caps.network}`,
184
+ );
185
+ const option = onNetwork.find((o) => o.asset.toLowerCase() === caps.asset);
186
+ if (option === undefined)
187
+ throw new PaymentError(
188
+ "cap_asset",
189
+ `canopy asks for payment in ${offered((o) => o.asset)}; this server pays only in ${caps.asset}`,
190
+ );
191
+ if (
192
+ caps.payTo !== undefined &&
193
+ option.payTo.toLowerCase() !== caps.payTo.toLowerCase()
194
+ )
195
+ throw new PaymentError(
196
+ "cap_payee",
197
+ `canopy asks for payment to ${option.payTo}; this server pays only ${caps.payTo}`,
198
+ );
199
+ if (
200
+ required.resource !== undefined &&
201
+ stripQuery(required.resource.url) !== stripQuery(ctx.resourceUrl)
202
+ )
203
+ throw new PaymentError(
204
+ "cap_resource",
205
+ `the payment requirements are for ${required.resource.url}, not the request this server made (${ctx.resourceUrl})`,
206
+ );
207
+ const amount = BigInt(option.amount);
208
+ if (amount > caps.maxPerCallAtomic)
209
+ throw new PaymentError(
210
+ "cap_per_call",
211
+ `canopy asks ${option.amount} atomic units; this server's per-call cap is ${caps.maxPerCallAtomic}. Issue a grant with a smaller maxHeight, or pay in relay mode from a wallet you control`,
212
+ );
213
+ if (ctx.spentTodayAtomic + amount > caps.maxPerDayAtomic)
214
+ throw new PaymentError(
215
+ "cap_per_day",
216
+ `paying ${option.amount} would bring today's (UTC) spend to ${ctx.spentTodayAtomic + amount} atomic units; this server's daily cap is ${caps.maxPerDayAtomic}`,
217
+ );
218
+ return option;
219
+ }
220
+
221
+ function stripQuery(url: string): string {
222
+ try {
223
+ const u = new URL(url);
224
+ return `${u.origin}${u.pathname}`;
225
+ } catch {
226
+ return url;
227
+ }
228
+ }
229
+
230
+ /** What an `X-PAYMENT` authorises, for reporting. Never includes the signature. */
231
+ export interface PaymentSummary {
232
+ network: string;
233
+ asset?: string;
234
+ from: string;
235
+ to: string;
236
+ value: string;
237
+ nonce: string;
238
+ validBefore: string;
239
+ }
240
+
241
+ /** Read decoded `X-PAYMENT` JSON (x402 v2 exact EVM). Throws `PaymentError`. */
242
+ export function readPaymentPayload(json: unknown): PaymentSummary {
243
+ const bad = (why: string) =>
244
+ new PaymentError(
245
+ "invalid_payment",
246
+ `the payment is not an x402 v2 exact EVM payment payload: ${why}`,
247
+ );
248
+ if (!isObject(json)) throw bad("not a JSON object");
249
+ if (json["x402Version"] !== 2 && json["x402Version"] !== 1)
250
+ throw bad("x402Version is not 1 or 2");
251
+ const accepted = isObject(json["accepted"]) ? json["accepted"] : json;
252
+ const payload = json["payload"];
253
+ if (!isObject(payload)) throw bad("payload is missing");
254
+ const auth = payload["authorization"];
255
+ if (!isObject(auth)) throw bad("payload.authorization is missing");
256
+ const field = (k: string): string => {
257
+ const v = auth[k];
258
+ if (typeof v !== "string") throw bad(`authorization.${k} is missing`);
259
+ return v;
260
+ };
261
+ const network = accepted["network"];
262
+ if (typeof network !== "string") throw bad("network is missing");
263
+ return {
264
+ network,
265
+ ...(typeof accepted["asset"] === "string"
266
+ ? { asset: accepted["asset"] }
267
+ : {}),
268
+ from: field("from"),
269
+ to: field("to"),
270
+ value: field("value"),
271
+ nonce: field("nonce"),
272
+ validBefore: field("validBefore"),
273
+ };
274
+ }
275
+
276
+ const NONCE_TAG = new TextEncoder().encode(
277
+ "forestrie/mcp-register x402 grant payment nonce v1",
278
+ );
279
+
280
+ /**
281
+ * The EIP-3009 nonce for paying for one grant: SHA-256 over a domain tag,
282
+ * the grant's content (its grant v0 CBOR, so a re-signed copy of the same
283
+ * grant gets the same nonce) and the payer's 20-byte address.
284
+ *
285
+ * EIP-3009 lets a token contract execute at most one authorisation per
286
+ * (payer, nonce), and canopy's claim and its settlement de-duplication are
287
+ * keyed by the nonce too. So every authorisation this payer ever signs for
288
+ * this grant shares one nonce, and at most one of them can settle: two
289
+ * overlapping calls, a retry after a lost answer, or a second process
290
+ * sharing the same payer cannot pay for the grant twice.
291
+ */
292
+ export async function grantPaymentNonce(
293
+ grantContent: Uint8Array,
294
+ payerAddress: Uint8Array,
295
+ ): Promise<string> {
296
+ if (payerAddress.length !== 20)
297
+ throw new PaymentError(
298
+ "invalid_payment",
299
+ `the payer address must be 20 bytes (got ${payerAddress.length})`,
300
+ );
301
+ const content = new Uint8Array(
302
+ await crypto.subtle.digest("SHA-256", grantContent as BufferSource),
303
+ );
304
+ const material = new Uint8Array(NONCE_TAG.length + 1 + 32 + 20);
305
+ material.set(NONCE_TAG, 0);
306
+ material.set(content, NONCE_TAG.length + 1);
307
+ material.set(payerAddress, NONCE_TAG.length + 33);
308
+ const digest = new Uint8Array(
309
+ await crypto.subtle.digest("SHA-256", material as BufferSource),
310
+ );
311
+ let hex = "0x";
312
+ for (const b of digest) hex += b.toString(16).padStart(2, "0");
313
+ return hex;
314
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Delegate sealing for the caller's data log, falling back to `prepare`.
3
+ *
4
+ * 1. GET pending-delegation. A standing entry means the coordinator knows
5
+ * the log's public root and has a vouched sealer key for it.
6
+ * 2. No standing entry: the coordinator never learned the root. canopy
7
+ * forwards it at register-grant, best effort; `POST
8
+ * /api/forest/{child}/prepare` under the same creation grant does it
9
+ * explicitly. Then poll pending-delegation again, a bounded number of
10
+ * times.
11
+ * 3. Verify the registrar voucher against the pinned sealer key, build the
12
+ * certificate and on-chain proof with the log key, and submit them.
13
+ *
14
+ * Each failure names its stage, so the caller can tell "prepare refused the
15
+ * grant" from "the coordinator rejected the certificate".
16
+ */
17
+ import type { FetchOptions } from "@forestrie/mcp-resolve/net";
18
+ import {
19
+ buildCertificateSubmission,
20
+ checkVoucher,
21
+ type CertificateSubmission,
22
+ DelegationError,
23
+ findStandingEntry,
24
+ type StandingEntry,
25
+ } from "../core/delegation.js";
26
+ import { prepareGrantProblems, type GrantView } from "../core/grant.js";
27
+ import { readProblem, type Problem } from "../core/scrapi.js";
28
+ import type { Es256Signer } from "../core/signer.js";
29
+ import {
30
+ getPendingDelegation,
31
+ postDelegationCertificate,
32
+ prepareChildLog,
33
+ } from "./write.js";
34
+
35
+ export type DelegateStage =
36
+ "pending_delegation" | "prepare" | "voucher" | "certificate";
37
+
38
+ export interface DelegateSealingInput {
39
+ baseUrl: string;
40
+ coordinatorUrl: string;
41
+ /** The registrar's voucher key, 64 bytes x‖y, pinned out of band. */
42
+ knownSealerKeyXY: Uint8Array;
43
+ grantB64: string;
44
+ grant: GrantView;
45
+ mmrEnd?: number;
46
+ ttlSeconds?: number;
47
+ /** Polls of pending-delegation after a successful prepare. Default 5. */
48
+ pollsAfterPrepare?: number;
49
+ /** Wait between those polls, ms. Default 2000. */
50
+ pollIntervalMs?: number;
51
+ sleep?: (ms: number) => Promise<void>;
52
+ }
53
+
54
+ export type DelegateSealingOutcome =
55
+ | {
56
+ kind: "delegated";
57
+ logId: string;
58
+ sealerId: string;
59
+ epoch: number;
60
+ mmrEnd: number;
61
+ issuedAt: number;
62
+ expiresAt: number;
63
+ /** True when this call had to run prepare first. */
64
+ prepared: boolean;
65
+ certificateKey?: string;
66
+ }
67
+ | {
68
+ kind: "problem";
69
+ stage: DelegateStage;
70
+ message: string;
71
+ problem?: Problem;
72
+ prepared: boolean;
73
+ };
74
+
75
+ const defaultSleep = (ms: number): Promise<void> =>
76
+ new Promise((resolve) => setTimeout(resolve, ms));
77
+
78
+ export async function delegateSealing(
79
+ input: DelegateSealingInput,
80
+ signer: Es256Signer,
81
+ opts?: FetchOptions,
82
+ ): Promise<DelegateSealingOutcome> {
83
+ const logId = input.grant.logId;
84
+ let prepared = false;
85
+
86
+ const readStanding = async (): Promise<
87
+ | { ok: true; standing: StandingEntry | undefined }
88
+ | { ok: false; outcome: DelegateSealingOutcome }
89
+ > => {
90
+ const res = await getPendingDelegation(
91
+ { coordinatorUrl: input.coordinatorUrl, logId },
92
+ opts,
93
+ );
94
+ if (res.status !== 200)
95
+ return {
96
+ ok: false,
97
+ outcome: {
98
+ kind: "problem",
99
+ stage: "pending_delegation",
100
+ message: `pending-delegation answered HTTP ${res.status}`,
101
+ problem: readJsonProblem(res.status, res.body),
102
+ prepared,
103
+ },
104
+ };
105
+ let body: unknown;
106
+ try {
107
+ body = JSON.parse(new TextDecoder().decode(res.body));
108
+ } catch {
109
+ return {
110
+ ok: false,
111
+ outcome: {
112
+ kind: "problem",
113
+ stage: "pending_delegation",
114
+ message: "pending-delegation did not return JSON",
115
+ prepared,
116
+ },
117
+ };
118
+ }
119
+ try {
120
+ return { ok: true, standing: findStandingEntry(body) };
121
+ } catch (err) {
122
+ return {
123
+ ok: false,
124
+ outcome: {
125
+ kind: "problem",
126
+ stage:
127
+ err instanceof DelegationError && err.code === "voucher_rejected"
128
+ ? "voucher"
129
+ : "pending_delegation",
130
+ message: (err as Error).message,
131
+ prepared,
132
+ },
133
+ };
134
+ }
135
+ };
136
+
137
+ let read = await readStanding();
138
+ if (!read.ok) return read.outcome;
139
+
140
+ if (read.standing === undefined) {
141
+ const refusals = prepareGrantProblems(input.grant);
142
+ if (refusals.length > 0)
143
+ return {
144
+ kind: "problem",
145
+ stage: "prepare",
146
+ message: `the coordinator has no standing sealer key for log ${logId} (its public root was never forwarded), and prepare cannot fix that with this grant: ${refusals.join("; ")}`,
147
+ prepared,
148
+ };
149
+ const res = await prepareChildLog(
150
+ { baseUrl: input.baseUrl, logId, grantB64: input.grantB64 },
151
+ opts,
152
+ );
153
+ if (res.status < 200 || res.status >= 300)
154
+ return {
155
+ kind: "problem",
156
+ stage: "prepare",
157
+ message: `prepare answered HTTP ${res.status}`,
158
+ problem: readProblem(res),
159
+ prepared,
160
+ };
161
+ prepared = true;
162
+ const sleep = input.sleep ?? defaultSleep;
163
+ const polls = input.pollsAfterPrepare ?? 5;
164
+ for (let i = 0; i < polls; i++) {
165
+ await sleep(input.pollIntervalMs ?? 2000);
166
+ read = await readStanding();
167
+ if (!read.ok) return read.outcome;
168
+ if (read.standing !== undefined) break;
169
+ }
170
+ if (read.standing === undefined)
171
+ return {
172
+ kind: "problem",
173
+ stage: "pending_delegation",
174
+ message: `prepare succeeded, but after ${polls} polls the coordinator still offers no standing sealer key for log ${logId}; try delegate_sealing again shortly`,
175
+ prepared,
176
+ };
177
+ }
178
+ const standing = read.standing;
179
+
180
+ try {
181
+ await checkVoucher(standing, input.knownSealerKeyXY);
182
+ } catch (err) {
183
+ return {
184
+ kind: "problem",
185
+ stage: "voucher",
186
+ message: (err as Error).message,
187
+ prepared,
188
+ };
189
+ }
190
+
191
+ let submission: CertificateSubmission;
192
+ try {
193
+ submission = await buildCertificateSubmission(
194
+ {
195
+ logId,
196
+ standing,
197
+ ...(input.mmrEnd !== undefined ? { mmrEnd: input.mmrEnd } : {}),
198
+ ...(input.ttlSeconds !== undefined
199
+ ? { ttlSeconds: input.ttlSeconds }
200
+ : {}),
201
+ },
202
+ signer,
203
+ );
204
+ } catch (err) {
205
+ // A malformed standing key, a bad ttl or a signer failure: report it
206
+ // at its stage rather than as a raw throw.
207
+ return {
208
+ kind: "problem",
209
+ stage: "certificate",
210
+ message: `could not build the delegation certificate: ${err instanceof Error ? err.message : String(err)}`,
211
+ prepared,
212
+ };
213
+ }
214
+ const res = await postDelegationCertificate(
215
+ { coordinatorUrl: input.coordinatorUrl, submission },
216
+ opts,
217
+ );
218
+ if (res.status < 200 || res.status >= 300)
219
+ return {
220
+ kind: "problem",
221
+ stage: "certificate",
222
+ message: `the coordinator rejected the delegation certificate: HTTP ${res.status}`,
223
+ problem: readJsonProblem(res.status, res.body),
224
+ prepared,
225
+ };
226
+ let certificateKey: string | undefined;
227
+ try {
228
+ const body = JSON.parse(new TextDecoder().decode(res.body)) as {
229
+ certificateKey?: unknown;
230
+ };
231
+ if (typeof body.certificateKey === "string")
232
+ certificateKey = body.certificateKey;
233
+ } catch {
234
+ // A 2xx without a JSON body still means accepted.
235
+ }
236
+ return {
237
+ kind: "delegated",
238
+ logId,
239
+ sealerId: standing.sealerId,
240
+ epoch: standing.epoch,
241
+ mmrEnd: submission.mmrEnd,
242
+ issuedAt: submission.issuedAt,
243
+ expiresAt: submission.expiresAt,
244
+ prepared,
245
+ ...(certificateKey !== undefined ? { certificateKey } : {}),
246
+ };
247
+ }
248
+
249
+ function readJsonProblem(status: number, body: Uint8Array): Problem {
250
+ const text = new TextDecoder().decode(body);
251
+ try {
252
+ const parsed = JSON.parse(text) as Record<string, unknown>;
253
+ const title =
254
+ typeof parsed.title === "string"
255
+ ? parsed.title
256
+ : typeof parsed.error === "string"
257
+ ? parsed.error
258
+ : `HTTP ${status}`;
259
+ const detail =
260
+ typeof parsed.detail === "string"
261
+ ? parsed.detail
262
+ : typeof parsed.message === "string"
263
+ ? parsed.message
264
+ : undefined;
265
+ return { status, title, ...(detail !== undefined ? { detail } : {}) };
266
+ } catch {
267
+ const printable = text.replace(/[^\x20-\x7e]+/g, " ").trim();
268
+ return {
269
+ status,
270
+ title: `HTTP ${status}`,
271
+ ...(printable ? { detail: printable.slice(0, 500) } : {}),
272
+ };
273
+ }
274
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The one request helper the write path uses. Same contract as
3
+ * `@forestrie/mcp-resolve/net`'s readers, whose `NetError` and
4
+ * `RawResponse` it reuses: `redirect: "manual"` (a 303 is the answer, not
5
+ * something to follow), every non-2xx returned rather than thrown, and
6
+ * `NetError` only when no response was obtained.
7
+ */
8
+ import {
9
+ NetError,
10
+ type FetchOptions,
11
+ type RawResponse,
12
+ } from "@forestrie/mcp-resolve/net";
13
+
14
+ export const DEFAULT_TIMEOUT_MS = 30_000;
15
+
16
+ export interface RequestInit {
17
+ method: "GET" | "POST";
18
+ headers?: Record<string, string>;
19
+ body?: Uint8Array | string;
20
+ }
21
+
22
+ export async function rawRequest(
23
+ url: string,
24
+ init: RequestInit,
25
+ opts?: FetchOptions,
26
+ ): Promise<RawResponse> {
27
+ const fetchImpl = opts?.fetchImpl ?? globalThis.fetch;
28
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
29
+ const at = new Date().toISOString();
30
+ const controller = new AbortController();
31
+ let timer: ReturnType<typeof setTimeout> | undefined;
32
+ const timeout = new Promise<never>((_resolve, reject) => {
33
+ timer = setTimeout(() => {
34
+ controller.abort();
35
+ reject(new NetError("timeout", `timed out after ${timeoutMs}ms`));
36
+ }, timeoutMs);
37
+ });
38
+ try {
39
+ const res = await Promise.race([
40
+ fetchImpl(url, {
41
+ method: init.method,
42
+ redirect: "manual",
43
+ signal: controller.signal,
44
+ ...(init.headers ? { headers: init.headers } : {}),
45
+ ...(init.body !== undefined ? { body: init.body as BodyInit } : {}),
46
+ }),
47
+ timeout,
48
+ ]);
49
+ const body = new Uint8Array(
50
+ await Promise.race([res.arrayBuffer(), timeout]),
51
+ );
52
+ const headers: Record<string, string> = {};
53
+ res.headers.forEach((value, key) => {
54
+ headers[key.toLowerCase()] = value;
55
+ });
56
+ return { url, status: res.status, headers, body, at };
57
+ } catch (err) {
58
+ if (err instanceof NetError) throw err;
59
+ throw new NetError(
60
+ "network",
61
+ `network error on ${init.method} ${url}: ${err instanceof Error ? err.message : String(err)}`,
62
+ { cause: err },
63
+ );
64
+ } finally {
65
+ clearTimeout(timer);
66
+ }
67
+ }
68
+
69
+ export function trimBase(url: string): string {
70
+ return url.replace(/\/+$/, "");
71
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `@forestrie/mcp-register/net` — the ONLY place `fetch` is called.
3
+ *
4
+ * Every function here takes `opts.fetchImpl` (defaulting to
5
+ * `globalThis.fetch`) and never calls a bare `fetch`, so unit tests inject a
6
+ * fake under the forbidden-fetch global. Reads that mcp-resolve already
7
+ * does (receipts, genesis) are imported from `@forestrie/mcp-resolve/net`,
8
+ * not reimplemented.
9
+ */
10
+
11
+ export { DEFAULT_TIMEOUT_MS, rawRequest } from "./http.js";
12
+ export {
13
+ getPendingDelegation,
14
+ getRegistrationStatus,
15
+ postDelegationCertificate,
16
+ postGrant,
17
+ postStatement,
18
+ registerGrantUrl,
19
+ prepareChildLog,
20
+ } from "./write.js";
21
+ export { delegateSealing } from "./delegate.js";
22
+ export type {
23
+ DelegateSealingInput,
24
+ DelegateSealingOutcome,
25
+ DelegateStage,
26
+ } from "./delegate.js";