@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,707 @@
1
+ /**
2
+ * The requester's tools: issue_grant signs a child grant with the authority
3
+ * key, and register_grant registers it with canopy, paying canopy's 402
4
+ * within the configured caps or relaying the payment requirements, then
5
+ * follows it to its receipt and completes it.
6
+ *
7
+ * register_grant is resumable by the child log id. Each call advances the
8
+ * stored record as far as it can (issued → payment_required → accepted →
9
+ * complete) and stops at the first thing it has to wait for. Every payment
10
+ * it signs for a grant carries a nonce derived from that grant, so one grant
11
+ * can never be paid for twice.
12
+ *
13
+ * Same contract as `tools.ts`: a refusal, an HTTP failure or bad input is a
14
+ * `structuredContent.problem` with a `code`, never a throw.
15
+ */
16
+ import { randomUUID } from "node:crypto";
17
+ import { z } from "zod";
18
+ import { NetError, fetchReceipt } from "@forestrie/mcp-resolve/net";
19
+ import { base64ToBytes, bytesToBase64 } from "../core/base64.js";
20
+ import { inspectGrant, type GrantView } from "../core/grant.js";
21
+ import { toHex } from "../core/hex.js";
22
+
23
+ function hexToBytes(hex: string): Uint8Array {
24
+ const h = hex.replace(/^0x/, "");
25
+ const out = new Uint8Array(h.length / 2);
26
+ for (let i = 0; i < out.length; i++)
27
+ out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
28
+ return out;
29
+ }
30
+ import {
31
+ MAX_PARENT_GRANT_BYTES,
32
+ completeGrant,
33
+ grantContent,
34
+ issueChildGrant,
35
+ parentGrantProblems,
36
+ registerGrantBody,
37
+ } from "../core/issue.js";
38
+ import {
39
+ originOf,
40
+ readReceiptResponse,
41
+ readRegisterGrantResponse,
42
+ readStatusResponse,
43
+ type RegisterGrantOutcome,
44
+ } from "../core/scrapi.js";
45
+ import {
46
+ PaymentError,
47
+ grantPaymentNonce,
48
+ readPaymentPayload,
49
+ readPaymentRequired,
50
+ selectWithinCaps,
51
+ type PaymentRequired,
52
+ } from "../core/x402.js";
53
+ import {
54
+ getRegistrationStatus,
55
+ postGrant,
56
+ registerGrantUrl,
57
+ } from "../net/write.js";
58
+ import type { GrantRecord } from "./grant-store.js";
59
+ import { readPaymentCaps } from "./payer.js";
60
+ import { InputError } from "./resolve-input.js";
61
+ import {
62
+ BaseUrlSchema,
63
+ GrantSchema,
64
+ decodeBase64Text,
65
+ guardHandler,
66
+ httpProblem,
67
+ httpProblemValue,
68
+ missingInput,
69
+ ok,
70
+ problemResult,
71
+ resolveGrant,
72
+ type Args,
73
+ type ResolvedDeps,
74
+ type ToolResult,
75
+ } from "./tools.js";
76
+
77
+ const ParentGrantSchema = z
78
+ .string()
79
+ .min(1)
80
+ .describe(
81
+ "your authority grant: the completed grant that created your authority log and names this server's authority key, as base64 or a file path; falls back to FORESTRIE_AUTHORITY_GRANT",
82
+ );
83
+
84
+ const LogIdSchema = z
85
+ .string()
86
+ .regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
87
+ .describe("a child log id: a lowercase UUID");
88
+
89
+ export const issueGrantInputShape = {
90
+ maxHeight: z
91
+ .number()
92
+ .int()
93
+ .positive()
94
+ .describe(
95
+ "the grant's batch ceiling. canopy prices a payment-gated child grant as its unit price × maxHeight, so size it to the work it is for",
96
+ ),
97
+ parentGrant: ParentGrantSchema.optional(),
98
+ grantee: z
99
+ .string()
100
+ .min(1)
101
+ .optional()
102
+ .describe(
103
+ "base64 of the 64-byte x‖y the grant names (the performer's log key); defaults to this server's own log key, for a self-purchase",
104
+ ),
105
+ logId: LogIdSchema.optional().describe(
106
+ "the child data log to create; a fresh random UUID by default",
107
+ ),
108
+ };
109
+
110
+ export const registerGrantInputShape = {
111
+ logId: LogIdSchema.optional().describe(
112
+ "the child log id issue_grant returned: register_grant resumes that grant from wherever the last call reached",
113
+ ),
114
+ grant: GrantSchema.optional().describe(
115
+ "a child grant signed elsewhere (base64 or a file path), instead of logId",
116
+ ),
117
+ parentGrant: ParentGrantSchema.optional(),
118
+ payment: z
119
+ .string()
120
+ .min(1)
121
+ .optional()
122
+ .describe(
123
+ "relay mode: the X-PAYMENT header value your own x402 wallet produced for the payment requirements register_grant returned",
124
+ ),
125
+ baseUrl: BaseUrlSchema.optional(),
126
+ rootLogId: z
127
+ .string()
128
+ .min(1)
129
+ .optional()
130
+ .describe(
131
+ "the forest's root log id (R in /register/{R}/grants); falls back to FORESTRIE_ROOT_LOG_ID",
132
+ ),
133
+ };
134
+
135
+ /* ------------------------------ issue_grant ----------------------------- */
136
+
137
+ export function makeIssueGrantTool(deps: ResolvedDeps) {
138
+ return (args: Args<typeof issueGrantInputShape>) =>
139
+ guardHandler("issue_grant", async () => {
140
+ const parent = resolveGrant(
141
+ args.parentGrant,
142
+ deps,
143
+ "FORESTRIE_AUTHORITY_GRANT",
144
+ );
145
+ if (!parent)
146
+ return missingInput(
147
+ "issue_grant",
148
+ "parentGrant",
149
+ "FORESTRIE_AUTHORITY_GRANT",
150
+ );
151
+ const authority = await deps.authoritySigner();
152
+ const refusals = parentGrantProblems(
153
+ parent.view,
154
+ await authority.publicKeyXY(),
155
+ );
156
+ if (refusals.length > 0)
157
+ return problemResult("issue_grant", {
158
+ code: "parent_unusable",
159
+ message: refusals.join("; "),
160
+ ownerLogId: parent.view.logId,
161
+ });
162
+
163
+ let grantee: Uint8Array;
164
+ let granteeIs: string;
165
+ if (args.grantee !== undefined) {
166
+ grantee = decodeBase64Text(args.grantee, "grantee");
167
+ if (grantee.length !== 64)
168
+ throw new InputError(
169
+ `grantee must decode to 64 bytes x‖y (got ${grantee.length})`,
170
+ );
171
+ granteeIs = "the key supplied";
172
+ } else {
173
+ grantee = await (await deps.signer()).publicKeyXY();
174
+ granteeIs = "this server's log key";
175
+ }
176
+
177
+ const logId = args.logId ?? randomUUID();
178
+ if (deps.grants.has(logId))
179
+ return problemResult("issue_grant", {
180
+ code: "already_issued",
181
+ message: `a grant for log ${logId} is already recorded here; call register_grant with that logId, or issue for a new log`,
182
+ logId,
183
+ });
184
+
185
+ const issued = await issueChildGrant(
186
+ {
187
+ logId,
188
+ ownerLogId: parent.view.logId,
189
+ grantData: grantee,
190
+ maxHeight: args.maxHeight,
191
+ },
192
+ authority,
193
+ );
194
+ const record: GrantRecord = {
195
+ logId,
196
+ ownerLogId: parent.view.logId,
197
+ state: "issued",
198
+ issuedHere: true,
199
+ granteeHex: toHex(grantee),
200
+ maxHeight: args.maxHeight,
201
+ flagsHex: toHex(issued.flags),
202
+ parentRequiresPayment: parent.view.requiresChildPayment,
203
+ issuedAt: deps.now().toISOString(),
204
+ };
205
+ deps.grants.save(record, {
206
+ "grant.cose": issued.grant,
207
+ "parent.cose": base64ToBytes(parent.b64),
208
+ });
209
+ return ok(
210
+ `issued: a data-log creation grant for log ${logId} under authority log ${record.ownerLogId}, naming ${granteeIs}, maxHeight ${args.maxHeight}. Not yet registered${record.parentRequiresPayment ? "; your authority grant makes its registration payment-gated" : ""}: call register_grant with this logId.`,
211
+ {
212
+ ...record,
213
+ grantee: granteeIs,
214
+ grantBase64: bytesToBase64(issued.grant),
215
+ },
216
+ );
217
+ });
218
+ }
219
+
220
+ /* ----------------------------- register_grant --------------------------- */
221
+
222
+ interface Located {
223
+ record: GrantRecord;
224
+ grant: Uint8Array;
225
+ view: GrantView;
226
+ }
227
+
228
+ /** The grant this call is about: a stored one by logId, or one supplied. */
229
+ function locate(
230
+ args: Args<typeof registerGrantInputShape>,
231
+ deps: ResolvedDeps,
232
+ ): Located | ToolResult {
233
+ if (args.grant !== undefined) {
234
+ const supplied = resolveGrant(args.grant, deps)!;
235
+ const bytes = base64ToBytes(supplied.b64);
236
+ const logId = supplied.view.logId;
237
+ if (args.logId !== undefined && args.logId !== logId)
238
+ throw new InputError(
239
+ `the grant is for log ${logId}, not the logId given (${args.logId})`,
240
+ );
241
+ if (deps.grants.has(logId)) {
242
+ const stored = deps.grants.read(logId, "grant.cose");
243
+ if (stored === undefined || toHex(stored) !== toHex(bytes))
244
+ return problemResult("register_grant", {
245
+ code: "grant_conflict",
246
+ message: `a different grant for log ${logId} is already recorded here; pass logId alone to resume it`,
247
+ logId,
248
+ });
249
+ return {
250
+ record: deps.grants.load(logId),
251
+ grant: bytes,
252
+ view: supplied.view,
253
+ };
254
+ }
255
+ const record: GrantRecord = {
256
+ logId,
257
+ ownerLogId: supplied.view.ownerLogId,
258
+ state: "issued",
259
+ issuedHere: false,
260
+ granteeHex: toHex(supplied.view.grantData),
261
+ ...(supplied.view.maxHeight !== undefined
262
+ ? { maxHeight: supplied.view.maxHeight }
263
+ : {}),
264
+ flagsHex: supplied.view.flagsHex,
265
+ issuedAt: deps.now().toISOString(),
266
+ };
267
+ deps.grants.save(record, { "grant.cose": bytes });
268
+ return { record, grant: bytes, view: supplied.view };
269
+ }
270
+ if (args.logId === undefined)
271
+ return problemResult("register_grant", {
272
+ code: "missing_input",
273
+ message:
274
+ "pass logId (a grant issue_grant made) or grant (one signed elsewhere)",
275
+ });
276
+ const record = deps.grants.load(args.logId);
277
+ const grant = deps.grants.read(args.logId, "grant.cose");
278
+ if (grant === undefined)
279
+ return problemResult("register_grant", {
280
+ code: "corrupt_record",
281
+ message: `grant ${args.logId} has a record but no grant.cose`,
282
+ });
283
+ return { record, grant, view: inspectGrant(grant) };
284
+ }
285
+
286
+ function decodeHeaderJson(value: string, what: string): unknown {
287
+ let text: string;
288
+ try {
289
+ text = new TextDecoder("utf-8", { fatal: true }).decode(
290
+ base64ToBytes(value),
291
+ );
292
+ } catch {
293
+ throw new PaymentError(
294
+ what === "X-PAYMENT"
295
+ ? "invalid_payment"
296
+ : "invalid_payment_requirements",
297
+ `${what} is not base64 of UTF-8 text`,
298
+ );
299
+ }
300
+ try {
301
+ return JSON.parse(text);
302
+ } catch {
303
+ throw new PaymentError(
304
+ what === "X-PAYMENT"
305
+ ? "invalid_payment"
306
+ : "invalid_payment_requirements",
307
+ `${what} is not base64 of JSON`,
308
+ );
309
+ }
310
+ }
311
+
312
+ function requirementsView(required: PaymentRequired) {
313
+ return {
314
+ accepts: required.accepts,
315
+ ...(required.resource !== undefined
316
+ ? { resource: required.resource }
317
+ : {}),
318
+ };
319
+ }
320
+
321
+ /**
322
+ * No lock: paying twice for one grant is prevented by the nonce, not by
323
+ * serialising calls. Every authorisation this server signs for a grant
324
+ * carries `grantPaymentNonce(grant content, payer)`, so overlapping calls,
325
+ * a retry after a lost answer and other processes sharing the payer all
326
+ * sign the same nonce, and EIP-3009 settles at most one authorisation per
327
+ * (payer, nonce). The daily cap is approximate under concurrency: calls that
328
+ * pass the check before either records its payment can overshoot it by at
329
+ * most one per-call cap each.
330
+ */
331
+ export function makeRegisterGrantTool(deps: ResolvedDeps) {
332
+ return (args: Args<typeof registerGrantInputShape>) =>
333
+ registerGrant(args, deps);
334
+ }
335
+
336
+ function registerGrant(
337
+ args: Args<typeof registerGrantInputShape>,
338
+ deps: ResolvedDeps,
339
+ ): Promise<ToolResult> {
340
+ return guardHandler("register_grant", async () => {
341
+ const located = locate(args, deps);
342
+ if ("content" in located) return located;
343
+ const { record, grant } = located;
344
+ const store = deps.grants;
345
+
346
+ if (record.state === "complete") return completeResult(record, deps);
347
+ if (record.state === "accepted") return advance(record, grant, deps);
348
+
349
+ // issued | payment_required: (re)send the registration.
350
+ const baseUrl =
351
+ args.baseUrl ?? record.baseUrl ?? deps.env["FORESTRIE_BASE_URL"];
352
+ if (!baseUrl)
353
+ return missingInput("register_grant", "baseUrl", "FORESTRIE_BASE_URL");
354
+ const rootLogId =
355
+ args.rootLogId ?? record.rootLogId ?? deps.env["FORESTRIE_ROOT_LOG_ID"];
356
+ if (!rootLogId)
357
+ return missingInput(
358
+ "register_grant",
359
+ "rootLogId",
360
+ "FORESTRIE_ROOT_LOG_ID",
361
+ );
362
+ if (
363
+ (record.baseUrl !== undefined && record.baseUrl !== baseUrl) ||
364
+ (record.rootLogId !== undefined && record.rootLogId !== rootLogId)
365
+ )
366
+ return problemResult("register_grant", {
367
+ code: "invalid_input",
368
+ message: `grant ${record.logId} was already sent to ${record.baseUrl} for forest ${record.rootLogId}; it is resumed there, not moved`,
369
+ });
370
+
371
+ let parent: Uint8Array | undefined = store.read(
372
+ record.logId,
373
+ "parent.cose",
374
+ );
375
+ if (args.parentGrant !== undefined || parent === undefined) {
376
+ const p = resolveGrant(
377
+ args.parentGrant,
378
+ deps,
379
+ "FORESTRIE_AUTHORITY_GRANT",
380
+ );
381
+ if (p !== undefined) {
382
+ if (p.view.logId !== record.ownerLogId)
383
+ throw new InputError(
384
+ `the parent grant is for log ${p.view.logId}, but this grant's owner is ${record.ownerLogId}`,
385
+ );
386
+ parent = base64ToBytes(p.b64);
387
+ record.parentRequiresPayment = p.view.requiresChildPayment;
388
+ }
389
+ }
390
+ if (parent !== undefined && parent.length > MAX_PARENT_GRANT_BYTES)
391
+ throw new InputError(
392
+ `the parent grant is ${parent.length} bytes; canopy reads at most ${MAX_PARENT_GRANT_BYTES}`,
393
+ );
394
+ // Without the parent as evidence canopy cannot see a payment policy;
395
+ // send an empty body rather than guess one.
396
+ const body =
397
+ parent !== undefined ? registerGrantBody(parent) : new Uint8Array(0);
398
+
399
+ record.baseUrl = baseUrl;
400
+ record.rootLogId = rootLogId;
401
+ store.save(record, parent !== undefined ? { "parent.cose": parent } : {});
402
+
403
+ const grantB64 = bytesToBase64(grant);
404
+ const resourceUrl = registerGrantUrl(baseUrl, rootLogId);
405
+ const send = (payment?: string) =>
406
+ postGrant(
407
+ {
408
+ baseUrl,
409
+ rootLogId,
410
+ grantB64,
411
+ body,
412
+ ...(payment !== undefined ? { payment } : {}),
413
+ },
414
+ { fetchImpl: deps.fetchImpl },
415
+ );
416
+
417
+ // A payment the caller relays from its own wallet.
418
+ let payment: { header: string; via: "payer" | "relay" } | undefined;
419
+ if (args.payment !== undefined) {
420
+ readPaymentPayload(decodeHeaderJson(args.payment, "X-PAYMENT"));
421
+ payment = { header: args.payment, via: "relay" };
422
+ }
423
+
424
+ let outcome = await sendOrReport(
425
+ record,
426
+ deps,
427
+ () => send(payment?.header),
428
+ payment,
429
+ );
430
+ if ("content" in outcome) return outcome;
431
+
432
+ if (outcome.kind === "payment_required" && payment === undefined) {
433
+ const required = readPaymentRequired(
434
+ decodeHeaderJson(outcome.paymentRequiredHeader, "X-PAYMENT-REQUIRED"),
435
+ );
436
+ record.paymentRequired = required;
437
+ record.state = "payment_required";
438
+ store.save(record);
439
+
440
+ const payer = deps.payer();
441
+ if (payer === undefined)
442
+ return ok(
443
+ `payment_required: canopy asks ${describeOptions(required)} to register the grant for log ${record.logId}. No payer is configured here, so pay from your own x402 wallet: give it the paymentRequiredHeader (or the requirements), then call register_grant again with logId ${record.logId} and payment set to the X-PAYMENT value it returns.`,
444
+ {
445
+ state: "payment_required",
446
+ logId: record.logId,
447
+ resourceUrl,
448
+ requirements: requirementsView(required),
449
+ paymentRequiredHeader: outcome.paymentRequiredHeader,
450
+ ...(outcome.problem.detail !== undefined
451
+ ? { detail: outcome.problem.detail }
452
+ : {}),
453
+ },
454
+ );
455
+
456
+ // Pay within the caps, only the canopy this server is configured for.
457
+ const configured = deps.env["FORESTRIE_BASE_URL"];
458
+ if (
459
+ configured === undefined ||
460
+ originOf(configured) === undefined ||
461
+ originOf(configured) !== originOf(baseUrl)
462
+ )
463
+ return problemResult("register_grant", {
464
+ code: "cap_origin",
465
+ message: `this server pays only the canopy in FORESTRIE_BASE_URL (${configured ?? "unset"}), not ${baseUrl}`,
466
+ requirements: requirementsView(required),
467
+ });
468
+ const caps = readPaymentCaps(deps.env);
469
+ const now = deps.now();
470
+ let option;
471
+ try {
472
+ option = selectWithinCaps(required, caps, {
473
+ resourceUrl,
474
+ spentTodayAtomic: deps.ledger.spentToday(
475
+ now,
476
+ caps.network,
477
+ caps.asset,
478
+ ),
479
+ });
480
+ } catch (err) {
481
+ if (!(err instanceof PaymentError)) throw err;
482
+ return problemResult("register_grant", {
483
+ code: err.code,
484
+ message: err.message,
485
+ requirements: requirementsView(required),
486
+ });
487
+ }
488
+ const nonce = await grantPaymentNonce(
489
+ grantContent(grant),
490
+ hexToBytes(payer.address),
491
+ );
492
+ let header: string;
493
+ try {
494
+ header = await payer.pay({
495
+ required,
496
+ option,
497
+ nonce,
498
+ nowSeconds: Math.floor(now.getTime() / 1000),
499
+ });
500
+ } catch (err) {
501
+ return problemResult("register_grant", {
502
+ code: "payer_failed",
503
+ message: `the payer (${payer.description}) did not sign the payment: ${err instanceof Error ? err.message : String(err)}. Nothing was sent or counted.`,
504
+ });
505
+ }
506
+ const summary = readPaymentPayload(
507
+ decodeHeaderJson(header, "X-PAYMENT"),
508
+ );
509
+ if (
510
+ summary.value !== option.amount ||
511
+ summary.to.toLowerCase() !== option.payTo.toLowerCase() ||
512
+ summary.network !== option.network ||
513
+ summary.nonce.toLowerCase() !== nonce
514
+ )
515
+ return problemResult("register_grant", {
516
+ code: "invalid_payment",
517
+ message: `the payer signed ${summary.value} to ${summary.to} on ${summary.network} with nonce ${summary.nonce}, not the ${option.amount} to ${option.payTo} on ${option.network} with the grant's nonce ${nonce} it was asked for; not sending it`,
518
+ });
519
+ // Committed from here: count it, then send it. A retry re-signs with
520
+ // the same nonce, which the ledger counts once.
521
+ deps.ledger.record(now, {
522
+ at: now.toISOString(),
523
+ network: option.network,
524
+ asset: option.asset.toLowerCase(),
525
+ amount: option.amount,
526
+ payTo: option.payTo,
527
+ from: summary.from,
528
+ nonce: summary.nonce,
529
+ logId: record.logId,
530
+ });
531
+ payment = { header, via: "payer" };
532
+ outcome = await sendOrReport(record, deps, () => send(header), payment);
533
+ if ("content" in outcome) return outcome;
534
+ }
535
+
536
+ if (payment !== undefined) {
537
+ const summary = readPaymentPayload(
538
+ decodeHeaderJson(payment.header, "X-PAYMENT"),
539
+ );
540
+ if (outcome.kind === "payment_required") {
541
+ // canopy refused the payment presented: the next call starts from
542
+ // the challenge again.
543
+ record.state = "payment_required";
544
+ store.save(record);
545
+ return problemResult("register_grant", {
546
+ code: "payment_rejected",
547
+ message: `canopy refused the payment (${outcome.problem.detail ?? outcome.problem.title}). Nothing was registered${payment.via === "payer" ? "; the authorisation still counts toward today's cap until it expires unspent" : ""}.`,
548
+ logId: record.logId,
549
+ payment: summary,
550
+ });
551
+ }
552
+ record.payment = {
553
+ ...summary,
554
+ via: payment.via,
555
+ at: deps.now().toISOString(),
556
+ };
557
+ }
558
+
559
+ if (outcome.kind === "problem") {
560
+ store.save(record);
561
+ return problemResult("register_grant", {
562
+ ...httpProblemValue("canopy refused the grant", outcome.problem),
563
+ logId: record.logId,
564
+ });
565
+ }
566
+ if (outcome.kind === "payment_required")
567
+ // Only reachable with a payment present, handled above.
568
+ throw new Error("unreachable: payment_required after payment");
569
+
570
+ record.state = "accepted";
571
+ record.statusUrl = outcome.statusUrl;
572
+ record.acceptedAt = deps.now().toISOString();
573
+ store.save(record);
574
+ return ok(
575
+ `accepted: canopy enqueued the grant for log ${record.logId} for sequencing under authority log ${record.ownerLogId}${record.payment ? `, paid ${record.payment.value} (${record.payment.via})` : ""}. Call register_grant again with logId ${record.logId}${outcome.retryAfterSeconds !== undefined ? ` in about ${outcome.retryAfterSeconds}s` : ""} to follow it to its receipt.`,
576
+ {
577
+ ...record,
578
+ ...(outcome.retryAfterSeconds !== undefined
579
+ ? { retryAfterSeconds: outcome.retryAfterSeconds }
580
+ : {}),
581
+ },
582
+ );
583
+ });
584
+ }
585
+
586
+ /** Send, mapping a lost answer to a problem that says how to resume. */
587
+ async function sendOrReport(
588
+ record: GrantRecord,
589
+ deps: ResolvedDeps,
590
+ send: () => ReturnType<typeof postGrant>,
591
+ payment: { via: "payer" | "relay" } | undefined,
592
+ ): Promise<RegisterGrantOutcome | ToolResult> {
593
+ let res;
594
+ try {
595
+ res = await send();
596
+ } catch (err) {
597
+ if (!(err instanceof NetError)) throw err;
598
+ return problemResult("register_grant", {
599
+ code: err.code,
600
+ message: `${err.message}. canopy may or may not have received the grant${payment ? " and the payment" : ""}. Call register_grant again with logId ${record.logId}: ${payment?.via === "payer" ? "it signs again with the grant's own nonce, so at most one payment can settle" : payment ? "pass the same payment again" : "it is sent again unchanged"}.`,
601
+ logId: record.logId,
602
+ });
603
+ }
604
+ return readRegisterGrantResponse(res, originOf(record.baseUrl!));
605
+ }
606
+
607
+ function describeOptions(required: PaymentRequired): string {
608
+ return required.accepts
609
+ .map(
610
+ (o) =>
611
+ `${o.amount} atomic units of ${o.asset} on ${o.network}, to ${o.payTo}`,
612
+ )
613
+ .join(" or ");
614
+ }
615
+
616
+ /** Accepted: follow the status URL to the receipt, then complete the grant. */
617
+ async function advance(
618
+ record: GrantRecord,
619
+ grant: Uint8Array,
620
+ deps: ResolvedDeps,
621
+ ): Promise<ToolResult> {
622
+ const store = deps.grants;
623
+ const origin = originOf(record.baseUrl!);
624
+ if (record.receiptUrl === undefined) {
625
+ const res = await getRegistrationStatus(
626
+ { statusUrl: record.statusUrl! },
627
+ { fetchImpl: deps.fetchImpl },
628
+ );
629
+ const status = readStatusResponse(res, origin);
630
+ if (status.kind === "problem")
631
+ return httpProblem(
632
+ "register_grant",
633
+ "the grant's status URL answered with a problem",
634
+ status.problem,
635
+ );
636
+ if (status.kind === "pending") {
637
+ record.firstPendingAt ??= res.at;
638
+ store.save(record);
639
+ return ok(
640
+ `pending: the grant for log ${record.logId} is not yet sequenced; call register_grant again in ${status.retryAfterSeconds}s`,
641
+ {
642
+ state: "accepted",
643
+ logId: record.logId,
644
+ retryAfterSeconds: status.retryAfterSeconds,
645
+ firstPendingAt: record.firstPendingAt,
646
+ },
647
+ );
648
+ }
649
+ record.receiptUrl = status.receiptUrl;
650
+ record.entryId = status.entryId;
651
+ record.massifHeight = status.massifHeight;
652
+ store.save(record);
653
+ }
654
+ const res = await fetchReceipt(
655
+ { receiptUrl: record.receiptUrl },
656
+ { fetchImpl: deps.fetchImpl },
657
+ );
658
+ const receipt = readReceiptResponse(res);
659
+ if (receipt.kind === "problem")
660
+ return httpProblem(
661
+ "register_grant",
662
+ "the grant's receipt URL answered with a problem",
663
+ receipt.problem,
664
+ );
665
+ if (receipt.kind === "awaiting_seal")
666
+ return ok(
667
+ `awaiting_seal: the grant is sequenced as entry ${record.entryId} in authority log ${record.ownerLogId}; canopy says "${receipt.reason}". It seals once that log's sealing is delegated (delegate_sealing with as: "authority"); call register_grant again in about a minute.`,
668
+ {
669
+ state: "accepted",
670
+ sealed: false,
671
+ logId: record.logId,
672
+ entryId: record.entryId,
673
+ receiptUrl: record.receiptUrl,
674
+ reason: receipt.reason,
675
+ },
676
+ );
677
+ const completed = completeGrant(grant, receipt.receipt, record.entryId!);
678
+ record.state = "complete";
679
+ record.completedAt = deps.now().toISOString();
680
+ store.save(record, {
681
+ "receipt.cose": receipt.receipt,
682
+ "completed.cose": completed,
683
+ });
684
+ return completeResult(record, deps);
685
+ }
686
+
687
+ async function completeResult(
688
+ record: GrantRecord,
689
+ deps: ResolvedDeps,
690
+ ): Promise<ToolResult> {
691
+ const completed = deps.grants.read(record.logId, "completed.cose");
692
+ if (completed === undefined)
693
+ return problemResult("register_grant", {
694
+ code: "corrupt_record",
695
+ message: `grant ${record.logId} is marked complete but has no completed.cose`,
696
+ });
697
+ const logKey = await (await deps.signer()).publicKeyXY();
698
+ const namesThisServer = record.granteeHex === toHex(logKey);
699
+ return ok(
700
+ `complete: the grant for log ${record.logId} is sealed (entry ${record.entryId}) and completed with its receipt. ${namesThisServer ? "It names this server's log key: pass grantBase64 as grant to delegate_sealing, then to register_statement (or set FORESTRIE_GRANT to it)." : "It names another key: give grantBase64 to the performer holding that key."}`,
701
+ {
702
+ ...record,
703
+ grantBase64: bytesToBase64(completed),
704
+ namesThisServer,
705
+ },
706
+ );
707
+ }