@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,209 @@
1
+ /**
2
+ * Issue a child grant as the requester, and complete it once canopy has
3
+ * sealed it.
4
+ *
5
+ * The requester owns an authority log (ARC-0028: the requester funds, the
6
+ * performer writes) and is its own grant issuer: it signs a child grant
7
+ * with its authority key, naming the performer's log key in `grantData`.
8
+ * Forestrie runs no issuer.
9
+ *
10
+ * The bytes are the ES256 profile of `@forestrie/grant-builder`'s
11
+ * `signGrantPayloadWithEs256Pem`, which univocity verifies
12
+ * (`verifyCoseSign1ES256`), with the PEM replaced by the signer seam:
13
+ *
14
+ * - untagged COSE Sign1, protected `{1: -7}` (wire `a10126`);
15
+ * - payload the 32-byte SHA-256 of the canonical grant v0 CBOR;
16
+ * - signature over `["Signature1", protected, h'', payload]`, r‖s;
17
+ * - unprotected `{-65537: 8 zero bytes, -65538: grant v0 CBOR}`, in that
18
+ * canonical order.
19
+ *
20
+ * grant-builder itself is not imported: its index pulls in its node-only
21
+ * PEM module, and this file is browser-safe.
22
+ *
23
+ * Completing a grant merges the receipt (396) and the entry's idtimestamp
24
+ * (-65537) into the unprotected header without re-signing, as forestrie-cli
25
+ * `register-grant-complete.ts` does. The completed grant is the
26
+ * `Forestrie-Grant` credential register_statement and delegate_sealing take.
27
+ */
28
+ import {
29
+ appendCborBstr,
30
+ appendCborText,
31
+ decodeCoseSign1,
32
+ encodeCborDeterministic,
33
+ encodeGrantPayloadV0Canonical,
34
+ mergeUnprotectedIntoCoseSign1,
35
+ uuidToBytes,
36
+ } from "@forestrie/encoding";
37
+ import { GrantError, type GrantView } from "./grant.js";
38
+ import { bytesEqual, sha256 } from "./hex.js";
39
+ import type { Es256Signer } from "./signer.js";
40
+
41
+ const ES256_PROTECTED_HEADER = new Uint8Array([0xa1, 0x01, 0x26]);
42
+ // CBOR negative-int keys (major type 1): -65537, then -65538.
43
+ const CBOR_KEY_IDTIMESTAMP = [0x3a, 0x00, 0x01, 0x00, 0x00];
44
+ const CBOR_KEY_FORESTRIE_GRANT_V0 = [0x3a, 0x00, 0x01, 0x00, 0x01];
45
+ const HEADER_RECEIPT = 396;
46
+ const HEADER_IDTIMESTAMP = -65537;
47
+
48
+ /** canopy's register-grant body key for the parent grant evidence. */
49
+ const PARENT_GRANT_BODY_KEY = "parentGrant";
50
+ /** canopy reads at most this much register-grant body (`auth-grant.ts`). */
51
+ export const MAX_PARENT_GRANT_BYTES = 16 * 1024;
52
+
53
+ /** GF_CREATE|GF_EXTEND (byte 3), GF_DATA_LOG (byte 7): a data-log creation
54
+ * grant, the shape that both opens the child log and carries statements. */
55
+ export function dataLogCreateExtendFlags(): Uint8Array {
56
+ const flags = new Uint8Array(8);
57
+ flags[3] = 0x03;
58
+ flags[7] = 0x02;
59
+ return flags;
60
+ }
61
+
62
+ export interface IssueGrantInput {
63
+ /** The child data log the grant creates (UUID). */
64
+ logId: string;
65
+ /** The requester's authority log the grant is sequenced into (UUID). */
66
+ ownerLogId: string;
67
+ /** The key the grant names: 64 bytes x‖y of the performer's log key. */
68
+ grantData: Uint8Array;
69
+ /** The batch ceiling canopy prices a paid child grant by. At least 1. */
70
+ maxHeight: number;
71
+ }
72
+
73
+ export interface IssuedGrant {
74
+ /** The signed grant, untagged COSE Sign1. */
75
+ grant: Uint8Array;
76
+ /** The canonical grant v0 CBOR that was signed (its SHA-256 is the payload). */
77
+ grantPayload: Uint8Array;
78
+ flags: Uint8Array;
79
+ }
80
+
81
+ export async function issueChildGrant(
82
+ input: IssueGrantInput,
83
+ authority: Es256Signer,
84
+ ): Promise<IssuedGrant> {
85
+ let logId: Uint8Array;
86
+ let ownerLogId: Uint8Array;
87
+ try {
88
+ logId = uuidToBytes(input.logId);
89
+ ownerLogId = uuidToBytes(input.ownerLogId);
90
+ } catch {
91
+ throw new GrantError(
92
+ `logId and ownerLogId must be UUIDs (got ${input.logId}, ${input.ownerLogId})`,
93
+ );
94
+ }
95
+ if (bytesEqual(logId, ownerLogId))
96
+ throw new GrantError(
97
+ "a child grant must name a log other than its owner (logId equals ownerLogId)",
98
+ );
99
+ if (input.grantData.length !== 64)
100
+ throw new GrantError(
101
+ `grantData must be a 64-byte ES256 x‖y (got ${input.grantData.length} bytes): canopy forwards only ES256 child roots`,
102
+ );
103
+ if (!Number.isSafeInteger(input.maxHeight) || input.maxHeight < 1)
104
+ throw new GrantError(
105
+ `maxHeight must be a positive integer (got ${input.maxHeight})`,
106
+ );
107
+
108
+ const flags = dataLogCreateExtendFlags();
109
+ const grantPayload = encodeGrantPayloadV0Canonical({
110
+ logId,
111
+ ownerLogId,
112
+ grant: flags,
113
+ maxHeight: input.maxHeight,
114
+ minGrowth: 0,
115
+ grantData: input.grantData,
116
+ });
117
+ const payload = await sha256(grantPayload);
118
+
119
+ const sig: number[] = [0x84];
120
+ appendCborText(sig, "Signature1");
121
+ appendCborBstr(sig, ES256_PROTECTED_HEADER);
122
+ appendCborBstr(sig, new Uint8Array(0));
123
+ appendCborBstr(sig, payload);
124
+ const signature = await authority.sign(new Uint8Array(sig));
125
+ if (signature.length !== 64)
126
+ throw new GrantError(
127
+ `the authority signer returned ${signature.length} bytes; ES256 needs a 64-byte r‖s`,
128
+ );
129
+
130
+ const out: number[] = [0x84];
131
+ appendCborBstr(out, ES256_PROTECTED_HEADER);
132
+ out.push(0xa2);
133
+ out.push(...CBOR_KEY_IDTIMESTAMP);
134
+ appendCborBstr(out, new Uint8Array(8));
135
+ out.push(...CBOR_KEY_FORESTRIE_GRANT_V0);
136
+ appendCborBstr(out, grantPayload);
137
+ appendCborBstr(out, payload);
138
+ appendCborBstr(out, signature);
139
+ return { grant: new Uint8Array(out), grantPayload, flags };
140
+ }
141
+
142
+ /**
143
+ * Why `parent` cannot authorise a child grant signed by `authorityXY`, or
144
+ * `[]`. The parent is the requester's own authority grant: an auth-log
145
+ * grant naming the authority key, whose log becomes the child's owner.
146
+ */
147
+ export function parentGrantProblems(
148
+ parent: GrantView,
149
+ authorityXY: Uint8Array,
150
+ ): string[] {
151
+ const problems: string[] = [];
152
+ if (!parent.authLog)
153
+ problems.push(
154
+ `the parent grant's flags ${parent.flagsHex} are not an authority-log grant (GF_AUTH_LOG); only an authority log can own a child log`,
155
+ );
156
+ if (parent.grantData.length !== 64)
157
+ problems.push(
158
+ `the parent grant names a ${parent.grantData.length}-byte key; canopy's prepare refuses non-ES256 parents, so the authority key must be ES256`,
159
+ );
160
+ else if (!bytesEqual(parent.grantData, authorityXY))
161
+ problems.push(
162
+ "the parent grant names a different key than this server's authority key, so this key does not own that authority log and cannot issue under it",
163
+ );
164
+ return problems;
165
+ }
166
+
167
+ /** The register-grant request body: canonical CBOR `{parentGrant: <bytes>}`. */
168
+ export function registerGrantBody(parentGrant: Uint8Array): Uint8Array {
169
+ return encodeCborDeterministic(
170
+ new Map<string, unknown>([[PARENT_GRANT_BODY_KEY, parentGrant]]),
171
+ );
172
+ }
173
+
174
+ const ENTRY_ID = /^[0-9a-f]{32}$/;
175
+
176
+ /** Merge the sealed receipt and the entry's idtimestamp into the grant. */
177
+ export function completeGrant(
178
+ grant: Uint8Array,
179
+ receipt: Uint8Array,
180
+ entryIdHex: string,
181
+ ): Uint8Array {
182
+ if (!ENTRY_ID.test(entryIdHex))
183
+ throw new GrantError(
184
+ `entryId must be 32 lowercase hex (got ${entryIdHex})`,
185
+ );
186
+ const idtimestamp = new Uint8Array(8);
187
+ for (let i = 0; i < 8; i++)
188
+ idtimestamp[i] = parseInt(entryIdHex.slice(i * 2, i * 2 + 2), 16);
189
+ return mergeUnprotectedIntoCoseSign1(
190
+ grant,
191
+ new Map<number, unknown>([
192
+ [HEADER_RECEIPT, receipt],
193
+ [HEADER_IDTIMESTAMP, idtimestamp],
194
+ ]),
195
+ );
196
+ }
197
+
198
+ /** The grant v0 CBOR a grant carries at unprotected -65538: its content. */
199
+ export function grantContent(grant: Uint8Array): Uint8Array {
200
+ const sign1 = decodeCoseSign1(grant);
201
+ const unprotected = sign1?.unprotected;
202
+ const content =
203
+ unprotected instanceof Map ? unprotected.get(-65538) : undefined;
204
+ if (!(content instanceof Uint8Array) || content.length === 0)
205
+ throw new GrantError(
206
+ "not a Forestrie-Grant: no grant v0 CBOR at unprotected label -65538",
207
+ );
208
+ return content;
209
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * canopy's statement size limit, `maxStatementSize` in its SCITT
3
+ * configuration (canopy `scrapi/transparency-configuration.ts`): 4 MiB,
4
+ * checked against the signed statement's Content-Length. The limit is on
5
+ * the COSE Sign1 as sent, so the payload must leave room for the headers
6
+ * and the signature.
7
+ */
8
+ export const MAX_STATEMENT_BYTES = 4 * 1024 * 1024;
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Read canopy's answers to the write path, over already-fetched responses.
3
+ *
4
+ * The registration contract (canopy `scrapi/register-signed-statement.ts`,
5
+ * `scrapi/query-registration-status.ts`, `scrapi/resolve-receipt.ts`):
6
+ *
7
+ * - `POST /register/{R}/entries` answers 303 with `Location` = the
8
+ * transient status URL `…/logs/{R}/{T}/entries/{sha256(statement)}`.
9
+ * - The status URL answers 303 to itself (`Retry-After`) until the entry is
10
+ * sequenced, then 303 to the permanent receipt URL
11
+ * `…/logs/{R}/{T}/{massifHeight}/entries/{entryId}/receipt`.
12
+ * - The receipt URL answers 404 "checkpoint missing" (or "checkpoint does
13
+ * not cover entry") until a checkpoint covering the entry is sealed,
14
+ * then 200 with the receipt. Other 404s are faults.
15
+ * - Failures are `application/problem+cbor` maps `{title, status, detail?,
16
+ * reason?}`.
17
+ */
18
+ import { decodeCborDeterministic } from "@forestrie/encoding";
19
+
20
+ /** The parts of a response these readers look at. Headers keyed lowercase. */
21
+ export interface ResponseView {
22
+ url: string;
23
+ status: number;
24
+ headers: Record<string, string>;
25
+ body: Uint8Array;
26
+ }
27
+
28
+ export interface Problem {
29
+ status: number;
30
+ title: string;
31
+ detail?: string;
32
+ reason?: string;
33
+ /** From the response's Retry-After, e.g. canopy's queue-full 503. */
34
+ retryAfterSeconds?: number;
35
+ }
36
+
37
+ export function readProblem(res: ResponseView): Problem {
38
+ const retry = retryAfter(res);
39
+ const base: Problem = {
40
+ status: res.status,
41
+ title: `HTTP ${res.status}`,
42
+ ...(retry !== undefined ? { retryAfterSeconds: retry } : {}),
43
+ };
44
+ if (res.body.length === 0) return base;
45
+ try {
46
+ const decoded = decodeCborDeterministic(res.body);
47
+ const get = (k: string): unknown =>
48
+ decoded instanceof Map
49
+ ? decoded.get(k)
50
+ : typeof decoded === "object" && decoded !== null
51
+ ? (decoded as Record<string, unknown>)[k]
52
+ : undefined;
53
+ const title = get("title");
54
+ const detail = get("detail");
55
+ const reason = get("reason");
56
+ if (typeof title === "string")
57
+ return {
58
+ ...base,
59
+ title,
60
+ ...(typeof detail === "string" ? { detail } : {}),
61
+ ...(typeof reason === "string" ? { reason } : {}),
62
+ };
63
+ } catch {
64
+ // Not CBOR: fall through to printable text.
65
+ }
66
+ const text = new TextDecoder()
67
+ .decode(res.body)
68
+ .replace(/[^\x20-\x7e]+/g, " ")
69
+ .trim();
70
+ return text ? { ...base, detail: text.slice(0, 200) } : base;
71
+ }
72
+
73
+ /**
74
+ * Resolve a Location against the URL that answered, and keep it on the
75
+ * expected origin: a status or receipt URL that points elsewhere is not
76
+ * followed (the caller would otherwise fetch wherever a server says).
77
+ */
78
+ function follow(
79
+ location: string,
80
+ res: ResponseView,
81
+ origin: string | undefined,
82
+ ): { ok: true; url: string } | { ok: false; problem: Problem } {
83
+ let url: URL;
84
+ try {
85
+ url = new URL(location, res.url);
86
+ } catch {
87
+ return {
88
+ ok: false,
89
+ problem: {
90
+ status: res.status,
91
+ title: "Invalid Location",
92
+ detail: `the Location header is not a URL: ${location.slice(0, 200)}`,
93
+ },
94
+ };
95
+ }
96
+ if (origin !== undefined && url.origin !== origin)
97
+ return {
98
+ ok: false,
99
+ problem: {
100
+ status: res.status,
101
+ title: "Unexpected Location",
102
+ detail: `the Location header points to ${url.origin}, not ${origin}; not following it`,
103
+ },
104
+ };
105
+ return { ok: true, url: url.toString() };
106
+ }
107
+
108
+ /** The origin of a base URL, or `undefined` when it does not parse. */
109
+ export function originOf(baseUrl: string): string | undefined {
110
+ try {
111
+ return new URL(baseUrl).origin;
112
+ } catch {
113
+ return undefined;
114
+ }
115
+ }
116
+
117
+ export type RegisterOutcome =
118
+ | { kind: "accepted"; statusUrl: string; retryAfterSeconds?: number }
119
+ | { kind: "problem"; problem: Problem };
120
+
121
+ export function readRegisterResponse(
122
+ res: ResponseView,
123
+ origin?: string,
124
+ ): RegisterOutcome {
125
+ if (res.status === 303 && res.headers["location"]) {
126
+ const next = follow(res.headers["location"], res, origin);
127
+ if (!next.ok) return { kind: "problem", problem: next.problem };
128
+ const retry = retryAfter(res);
129
+ return {
130
+ kind: "accepted",
131
+ statusUrl: next.url,
132
+ ...(retry !== undefined ? { retryAfterSeconds: retry } : {}),
133
+ };
134
+ }
135
+ return { kind: "problem", problem: readProblem(res) };
136
+ }
137
+
138
+ export type RegisterGrantOutcome =
139
+ | RegisterOutcome
140
+ | {
141
+ kind: "payment_required";
142
+ /** The raw `X-PAYMENT-REQUIRED` header (base64 JSON), undecoded. */
143
+ paymentRequiredHeader: string;
144
+ problem: Problem;
145
+ };
146
+
147
+ /**
148
+ * canopy's answer to `POST /register/{R}/grants`: 303 to the grant's status
149
+ * URL, or 402 with an `X-PAYMENT-REQUIRED` challenge when the parent grant
150
+ * makes this child payment-gated (and again, with canopy's reason, when a
151
+ * presented `X-PAYMENT` is refused). A 402 without the header is a problem.
152
+ */
153
+ export function readRegisterGrantResponse(
154
+ res: ResponseView,
155
+ origin?: string,
156
+ ): RegisterGrantOutcome {
157
+ const header = res.headers["x-payment-required"];
158
+ if (res.status === 402 && header)
159
+ return {
160
+ kind: "payment_required",
161
+ paymentRequiredHeader: header,
162
+ problem: readProblem(res),
163
+ };
164
+ return readRegisterResponse(res, origin);
165
+ }
166
+
167
+ const RECEIPT_PATH = /\/(\d+)\/entries\/([0-9a-f]{32})\/receipt$/;
168
+
169
+ export type StatusOutcome =
170
+ | { kind: "pending"; retryAfterSeconds: number }
171
+ | {
172
+ kind: "sequenced";
173
+ receiptUrl: string;
174
+ massifHeight: number;
175
+ entryId: string;
176
+ }
177
+ | { kind: "problem"; problem: Problem };
178
+
179
+ export function readStatusResponse(
180
+ res: ResponseView,
181
+ origin?: string,
182
+ ): StatusOutcome {
183
+ if (res.status !== 303)
184
+ return { kind: "problem", problem: readProblem(res) };
185
+ const next = follow(res.headers["location"] ?? res.url, res, origin);
186
+ if (!next.ok) return { kind: "problem", problem: next.problem };
187
+ const match = RECEIPT_PATH.exec(new URL(next.url).pathname);
188
+ if (match)
189
+ return {
190
+ kind: "sequenced",
191
+ receiptUrl: next.url,
192
+ massifHeight: Number(match[1]),
193
+ entryId: match[2]!,
194
+ };
195
+ return { kind: "pending", retryAfterSeconds: retryAfter(res) ?? 1 };
196
+ }
197
+
198
+ export type ReceiptOutcome =
199
+ | { kind: "sealed"; receipt: Uint8Array; contentType?: string }
200
+ | { kind: "awaiting_seal"; reason: string }
201
+ | { kind: "problem"; problem: Problem };
202
+
203
+ /**
204
+ * The 404s that mean "sequenced, not yet sealed": no checkpoint yet, or one
205
+ * that predates the entry (canopy `scrapi/resolve-receipt.ts`). canopy
206
+ * answers 404 for other reasons too — a missing massif, a height or index
207
+ * mismatch, an unknown forest — and those will not fix themselves by
208
+ * waiting, so they are problems, with canopy's reason.
209
+ */
210
+ const AWAITING_SEAL =
211
+ /checkpoint missing\)$|checkpoint does not cover entry\)$/;
212
+
213
+ export function readReceiptResponse(res: ResponseView): ReceiptOutcome {
214
+ if (res.status === 404) {
215
+ const problem = readProblem(res);
216
+ if (AWAITING_SEAL.test(problem.title))
217
+ return { kind: "awaiting_seal", reason: problem.title };
218
+ return { kind: "problem", problem };
219
+ }
220
+ if (res.status === 200 && res.body.length > 0)
221
+ return {
222
+ kind: "sealed",
223
+ receipt: res.body,
224
+ ...(res.headers["content-type"]
225
+ ? { contentType: res.headers["content-type"] }
226
+ : {}),
227
+ };
228
+ return { kind: "problem", problem: readProblem(res) };
229
+ }
230
+
231
+ function retryAfter(res: ResponseView): number | undefined {
232
+ const raw = res.headers["retry-after"];
233
+ if (raw === undefined) return undefined;
234
+ const n = Number(raw);
235
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
236
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The signer seam: every signature this package makes goes through one
3
+ * `Es256Signer`. Core code builds the exact bytes to be signed and hands
4
+ * them over; it never sees a private key, and no signature byte ever
5
+ * arrives from a tool argument. Where the key lives — a local key file
6
+ * (the default, `src/node/signer-file.ts`), a KMS, a remote signer — is the
7
+ * backend's business.
8
+ *
9
+ * ES256 only: canopy's child-log paths (register-grant auto-forward and
10
+ * `POST /api/forest/{child}/prepare`) accept 64-byte P-256 roots only, so a
11
+ * KS256 log key could not delegate its own sealing without an operator.
12
+ */
13
+ export interface Es256Signer {
14
+ /** The P-256 public key as 64 bytes x‖y — what a grant's `grantData` names. */
15
+ publicKeyXY(): Promise<Uint8Array>;
16
+ /**
17
+ * ECDSA P-256 with SHA-256 over `message` (the signer hashes), returned as
18
+ * IEEE P1363 r‖s, 64 bytes. For COSE that message is the Sig_structure.
19
+ */
20
+ sign(message: Uint8Array): Promise<Uint8Array>;
21
+ /** Where the key lives, for humans: never key material. */
22
+ readonly description: string;
23
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Build and sign the statement canopy registers: an untagged COSE Sign1,
3
+ * payload attached, protected header `{1: -7, 3: cty, 4: kid, 15: {1: iss,
4
+ * 2: sub}}`, empty unprotected header.
5
+ *
6
+ * Byte-for-byte the construction of `@forestrie/encoding`'s
7
+ * `signCoseSign1Statement` (the one forestrie-cli uses), with the
8
+ * WebCrypto call replaced by the signer seam: the same protected map, the
9
+ * same Sig_structure, the same envelope encoder.
10
+ *
11
+ * kid is the key's 32-byte x coordinate, the binding canopy checks first
12
+ * (canopy `grant/statement-signer-binding.ts`).
13
+ */
14
+ import {
15
+ COSE_ALG_ES256,
16
+ encodeCoseProtectedMapBytes,
17
+ encodeCoseSign1Statement,
18
+ encodeSigStructure,
19
+ verifyCoseSign1WithParsedKey,
20
+ type CoseProtectedHeaderOptions,
21
+ } from "@forestrie/encoding";
22
+ import { MAX_STATEMENT_BYTES } from "./limits.js";
23
+ import { sha256, toHex } from "./hex.js";
24
+ import type { Es256Signer } from "./signer.js";
25
+
26
+ export class StatementError extends Error {
27
+ constructor(
28
+ readonly code: "invalid_input" | "too_large" | "signer_failed",
29
+ message: string,
30
+ ) {
31
+ super(message);
32
+ }
33
+ }
34
+
35
+ export interface SignStatementInput {
36
+ payload: Uint8Array;
37
+ /** The payload's media type, carried in protected header 3. */
38
+ contentType: string;
39
+ /** CWT iss; defaults to the hex of the kid. */
40
+ issuer?: string;
41
+ /** CWT sub; defaults to `sha-256:<hex of payload>`, as forestrie-cli does. */
42
+ subject?: string;
43
+ /** CWT iat, seconds since the epoch; omitted when absent. */
44
+ issuedAt?: number;
45
+ }
46
+
47
+ export interface SignedStatement {
48
+ statement: Uint8Array;
49
+ /** sha256 of the statement: the id in canopy's status URL. */
50
+ contentHash: string;
51
+ kid: Uint8Array;
52
+ issuer: string;
53
+ subject: string;
54
+ }
55
+
56
+ export async function signStatement(
57
+ input: SignStatementInput,
58
+ signer: Es256Signer,
59
+ ): Promise<SignedStatement> {
60
+ if (input.payload.length === 0)
61
+ throw new StatementError("invalid_input", "payload is empty");
62
+ if (input.payload.length > MAX_STATEMENT_BYTES)
63
+ throw new StatementError(
64
+ "too_large",
65
+ `payload is ${input.payload.length} bytes; canopy accepts signed statements up to ${MAX_STATEMENT_BYTES} bytes. Register a small manifest naming the artefact's digest instead`,
66
+ );
67
+ if (!input.contentType.trim())
68
+ throw new StatementError("invalid_input", "contentType is empty");
69
+
70
+ const xy = await signer.publicKeyXY();
71
+ if (xy.length !== 64)
72
+ throw new StatementError(
73
+ "signer_failed",
74
+ `signer returned a ${xy.length}-byte public key; ES256 needs 64 bytes x‖y`,
75
+ );
76
+ const kid = xy.slice(0, 32);
77
+ const issuer = input.issuer ?? toHex(kid);
78
+ const subject =
79
+ input.subject ?? `sha-256:${toHex(await sha256(input.payload))}`;
80
+ const options: CoseProtectedHeaderOptions = {
81
+ alg: COSE_ALG_ES256,
82
+ cty: input.contentType,
83
+ cwtClaims: {
84
+ iss: issuer,
85
+ sub: subject,
86
+ ...(input.issuedAt !== undefined ? { iat: input.issuedAt } : {}),
87
+ },
88
+ };
89
+
90
+ const sigStructure = encodeSigStructure(
91
+ encodeCoseProtectedMapBytes(kid, options),
92
+ new Uint8Array(0),
93
+ input.payload,
94
+ );
95
+ const signature = await signer.sign(sigStructure);
96
+ if (signature.length !== 64)
97
+ throw new StatementError(
98
+ "signer_failed",
99
+ `signer returned a ${signature.length}-byte signature; ES256 is 64 bytes r‖s`,
100
+ );
101
+ const statement = encodeCoseSign1Statement(
102
+ input.payload,
103
+ kid,
104
+ signature,
105
+ options,
106
+ );
107
+
108
+ // Check our own work before canopy does: a signer that signs with a key
109
+ // other than the one it reports would otherwise surface as a bare 403.
110
+ const verified = await verifyCoseSign1WithParsedKey(statement, {
111
+ x: xy.slice(0, 32),
112
+ y: xy.slice(32),
113
+ curve: "P-256",
114
+ });
115
+ if (!verified)
116
+ throw new StatementError(
117
+ "signer_failed",
118
+ "the signature does not verify under the signer's own public key",
119
+ );
120
+ if (statement.length > MAX_STATEMENT_BYTES)
121
+ throw new StatementError(
122
+ "too_large",
123
+ `the signed statement is ${statement.length} bytes; canopy's limit is ${MAX_STATEMENT_BYTES}`,
124
+ );
125
+
126
+ return {
127
+ statement,
128
+ contentHash: toHex(await sha256(statement)),
129
+ kid,
130
+ issuer,
131
+ subject,
132
+ };
133
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Version strings, as literals.
3
+ *
4
+ * Not read from `package.json`: `src/core` is browser-safe, so it has no
5
+ * `node:fs`, and a JSON import would bake a resolveJsonModule edge into the
6
+ * `"."` bundle for a handful of strings. The MCP smoke test asserts
7
+ * `PACKAGE_VERSION === package.json#version`, so a forgotten bump is a red
8
+ * test rather than a silent lie in `initialize`'s serverInfo.
9
+ *
10
+ * Only the versions this package reports (`--help`, `initialize`) live here.
11
+ * Bump each one in the same commit as the dependency it names.
12
+ */
13
+
14
+ /** Keep in sync with package.json#version. Asserted by test/node/mcp-smoke. */
15
+ export const PACKAGE_VERSION = "0.1.1";
16
+
17
+ /** Keep in sync with package.json#dependencies["@forestrie/mcp-verify"] — the
18
+ * verifier this package composes with. Asserted by
19
+ * test/core/version.test.ts. */
20
+ export const VERIFIER_VERSION = "0.5.0";
21
+
22
+ /** Keep in sync with package.json#dependencies["@forestrie/mcp-resolve"] —
23
+ * the resolver this package composes with. Asserted by
24
+ * test/core/version.test.ts. */
25
+ export const RESOLVE_VERSION = "0.3.0";