@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.
- package/LICENSE +21 -0
- package/README.md +178 -2
- package/bin/mcp-register.mjs +2 -0
- package/dist/core/base64.d.ts +7 -0
- package/dist/core/base64.d.ts.map +1 -0
- package/dist/core/base64.js +20 -0
- package/dist/core/base64.js.map +1 -0
- package/dist/core/delegation.d.ts +56 -0
- package/dist/core/delegation.d.ts.map +1 -0
- package/dist/core/delegation.js +130 -0
- package/dist/core/delegation.js.map +1 -0
- package/dist/core/grant.d.ts +41 -0
- package/dist/core/grant.d.ts.map +1 -0
- package/dist/core/grant.js +78 -0
- package/dist/core/grant.js.map +1 -0
- package/dist/core/hex.d.ts +4 -0
- package/dist/core/hex.d.ts.map +1 -0
- package/dist/core/hex.js +18 -0
- package/dist/core/hex.js.map +1 -0
- package/dist/core/index.d.ts +28 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +21 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/issue.d.ts +38 -0
- package/dist/core/issue.d.ts.map +1 -0
- package/dist/core/issue.js +135 -0
- package/dist/core/issue.js.map +1 -0
- package/dist/core/limits.d.ts +9 -0
- package/dist/core/limits.d.ts.map +1 -0
- package/dist/core/limits.js +9 -0
- package/dist/core/limits.js.map +1 -0
- package/dist/core/scrapi.d.ts +66 -0
- package/dist/core/scrapi.d.ts.map +1 -0
- package/dist/core/scrapi.js +174 -0
- package/dist/core/scrapi.js.map +1 -0
- package/dist/core/signer.d.ts +24 -0
- package/dist/core/signer.d.ts.map +1 -0
- package/dist/core/signer.js +2 -0
- package/dist/core/signer.js.map +1 -0
- package/dist/core/statement.d.ts +26 -0
- package/dist/core/statement.d.ts.map +1 -0
- package/dist/core/statement.js +70 -0
- package/dist/core/statement.js.map +1 -0
- package/dist/core/version.d.ts +23 -0
- package/dist/core/version.d.ts.map +1 -0
- package/dist/core/version.js +23 -0
- package/dist/core/version.js.map +1 -0
- package/dist/core/x402.d.ts +99 -0
- package/dist/core/x402.d.ts.map +1 -0
- package/dist/core/x402.js +202 -0
- package/dist/core/x402.js.map +1 -0
- package/dist/net/delegate.d.ts +56 -0
- package/dist/net/delegate.d.ts.map +1 -0
- package/dist/net/delegate.js +182 -0
- package/dist/net/delegate.js.map +1 -0
- package/dist/net/http.d.ts +17 -0
- package/dist/net/http.d.ts.map +1 -0
- package/dist/net/http.js +52 -0
- package/dist/net/http.js.map +1 -0
- package/dist/net/index.d.ts +14 -0
- package/dist/net/index.d.ts.map +1 -0
- package/dist/net/index.js +13 -0
- package/dist/net/index.js.map +1 -0
- package/dist/net/write.d.ts +55 -0
- package/dist/net/write.d.ts.map +1 -0
- package/dist/net/write.js +46 -0
- package/dist/net/write.js.map +1 -0
- package/dist/node/cli.d.ts +8 -0
- package/dist/node/cli.d.ts.map +1 -0
- package/dist/node/cli.js +143 -0
- package/dist/node/cli.js.map +1 -0
- package/dist/node/grant-store.d.ts +49 -0
- package/dist/node/grant-store.d.ts.map +1 -0
- package/dist/node/grant-store.js +95 -0
- package/dist/node/grant-store.js.map +1 -0
- package/dist/node/grant-tools.d.ts +29 -0
- package/dist/node/grant-tools.d.ts.map +1 -0
- package/dist/node/grant-tools.js +498 -0
- package/dist/node/grant-tools.js.map +1 -0
- package/dist/node/ledger.d.ts +26 -0
- package/dist/node/ledger.d.ts.map +1 -0
- package/dist/node/ledger.js +78 -0
- package/dist/node/ledger.js.map +1 -0
- package/dist/node/payer.d.ts +48 -0
- package/dist/node/payer.d.ts.map +1 -0
- package/dist/node/payer.js +170 -0
- package/dist/node/payer.js.map +1 -0
- package/dist/node/resolve-input.d.ts +19 -0
- package/dist/node/resolve-input.d.ts.map +1 -0
- package/dist/node/resolve-input.js +76 -0
- package/dist/node/resolve-input.js.map +1 -0
- package/dist/node/server.d.ts +47 -0
- package/dist/node/server.d.ts.map +1 -0
- package/dist/node/server.js +212 -0
- package/dist/node/server.js.map +1 -0
- package/dist/node/signer-file.d.ts +34 -0
- package/dist/node/signer-file.d.ts.map +1 -0
- package/dist/node/signer-file.js +183 -0
- package/dist/node/signer-file.js.map +1 -0
- package/dist/node/store.d.ts +40 -0
- package/dist/node/store.d.ts.map +1 -0
- package/dist/node/store.js +117 -0
- package/dist/node/store.js.map +1 -0
- package/dist/node/text.d.ts +29 -0
- package/dist/node/text.d.ts.map +1 -0
- package/dist/node/text.js +124 -0
- package/dist/node/text.js.map +1 -0
- package/dist/node/tools.d.ts +118 -0
- package/dist/node/tools.d.ts.map +1 -0
- package/dist/node/tools.js +548 -0
- package/dist/node/tools.js.map +1 -0
- package/package.json +108 -3
- package/src/core/base64.ts +18 -0
- package/src/core/delegation.ts +239 -0
- package/src/core/grant.ts +127 -0
- package/src/core/hex.ts +18 -0
- package/src/core/index.ts +84 -0
- package/src/core/issue.ts +209 -0
- package/src/core/limits.ts +8 -0
- package/src/core/scrapi.ts +236 -0
- package/src/core/signer.ts +23 -0
- package/src/core/statement.ts +133 -0
- package/src/core/version.ts +25 -0
- package/src/core/x402.ts +314 -0
- package/src/net/delegate.ts +274 -0
- package/src/net/http.ts +71 -0
- package/src/net/index.ts +26 -0
- package/src/net/write.ts +124 -0
- package/src/node/cli.ts +170 -0
- package/src/node/grant-store.ts +154 -0
- package/src/node/grant-tools.ts +707 -0
- package/src/node/ledger.ts +98 -0
- package/src/node/payer.ts +241 -0
- package/src/node/resolve-input.ts +96 -0
- package/src/node/server.ts +332 -0
- package/src/node/signer-file.ts +251 -0
- package/src/node/store.ts +175 -0
- package/src/node/text.ts +153 -0
- package/src/node/tools.ts +810 -0
package/src/net/write.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The write path's requests. Thin: each builds one request, returns the
|
|
3
|
+
* raw response, and leaves reading it to `src/core/scrapi.ts` or
|
|
4
|
+
* `src/core/delegation.ts`. Every function takes `fetchImpl` through
|
|
5
|
+
* `opts`.
|
|
6
|
+
*
|
|
7
|
+
* Endpoints, all public and none needing an operator token:
|
|
8
|
+
*
|
|
9
|
+
* - canopy `POST /register/{R}/entries`, the statement as
|
|
10
|
+
* `application/cose`, authorised by `Forestrie-Grant <base64>`;
|
|
11
|
+
* - canopy `POST /register/{R}/grants`, the child grant as
|
|
12
|
+
* `Forestrie-Grant <base64>`, the parent grant in a CBOR body, and an
|
|
13
|
+
* `X-PAYMENT` when paying canopy's 402;
|
|
14
|
+
* - canopy `POST /api/forest/{childLogId}/prepare`, no body, the same
|
|
15
|
+
* grant: publishes the child log's public root to the delegation
|
|
16
|
+
* coordinator when register-grant's auto-forward did not;
|
|
17
|
+
* - coordinator `GET /api/logs/{logId}/pending-delegation`;
|
|
18
|
+
* - coordinator `POST /api/delegations/certificate`, JSON.
|
|
19
|
+
*/
|
|
20
|
+
import type { FetchOptions, RawResponse } from "@forestrie/mcp-resolve/net";
|
|
21
|
+
import type { CertificateSubmission } from "../core/delegation.js";
|
|
22
|
+
import { rawRequest, trimBase } from "./http.js";
|
|
23
|
+
|
|
24
|
+
export function postStatement(
|
|
25
|
+
input: {
|
|
26
|
+
baseUrl: string;
|
|
27
|
+
rootLogId: string;
|
|
28
|
+
statement: Uint8Array;
|
|
29
|
+
grantB64: string;
|
|
30
|
+
},
|
|
31
|
+
opts?: FetchOptions,
|
|
32
|
+
): Promise<RawResponse> {
|
|
33
|
+
return rawRequest(
|
|
34
|
+
`${trimBase(input.baseUrl)}/register/${encodeURIComponent(input.rootLogId)}/entries`,
|
|
35
|
+
{
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: {
|
|
38
|
+
"content-type": "application/cose",
|
|
39
|
+
authorization: `Forestrie-Grant ${input.grantB64}`,
|
|
40
|
+
},
|
|
41
|
+
body: input.statement,
|
|
42
|
+
},
|
|
43
|
+
opts,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The URL `postGrant` POSTs: also the x402 resource a payment is for. */
|
|
48
|
+
export function registerGrantUrl(baseUrl: string, rootLogId: string): string {
|
|
49
|
+
return `${trimBase(baseUrl)}/register/${encodeURIComponent(rootLogId)}/grants`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function postGrant(
|
|
53
|
+
input: {
|
|
54
|
+
baseUrl: string;
|
|
55
|
+
rootLogId: string;
|
|
56
|
+
grantB64: string;
|
|
57
|
+
/** `registerGrantBody(parentGrant)`: canonical CBOR `{parentGrant}`. */
|
|
58
|
+
body: Uint8Array;
|
|
59
|
+
/** The `X-PAYMENT` header value, when paying. */
|
|
60
|
+
payment?: string;
|
|
61
|
+
},
|
|
62
|
+
opts?: FetchOptions,
|
|
63
|
+
): Promise<RawResponse> {
|
|
64
|
+
return rawRequest(
|
|
65
|
+
registerGrantUrl(input.baseUrl, input.rootLogId),
|
|
66
|
+
{
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: {
|
|
69
|
+
"content-type": "application/cbor",
|
|
70
|
+
authorization: `Forestrie-Grant ${input.grantB64}`,
|
|
71
|
+
...(input.payment !== undefined ? { "x-payment": input.payment } : {}),
|
|
72
|
+
},
|
|
73
|
+
body: input.body,
|
|
74
|
+
},
|
|
75
|
+
opts,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getRegistrationStatus(
|
|
80
|
+
input: { statusUrl: string },
|
|
81
|
+
opts?: FetchOptions,
|
|
82
|
+
): Promise<RawResponse> {
|
|
83
|
+
return rawRequest(input.statusUrl, { method: "GET" }, opts);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function prepareChildLog(
|
|
87
|
+
input: { baseUrl: string; logId: string; grantB64: string },
|
|
88
|
+
opts?: FetchOptions,
|
|
89
|
+
): Promise<RawResponse> {
|
|
90
|
+
return rawRequest(
|
|
91
|
+
`${trimBase(input.baseUrl)}/api/forest/${encodeURIComponent(input.logId)}/prepare`,
|
|
92
|
+
{
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: { authorization: `Forestrie-Grant ${input.grantB64}` },
|
|
95
|
+
},
|
|
96
|
+
opts,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getPendingDelegation(
|
|
101
|
+
input: { coordinatorUrl: string; logId: string },
|
|
102
|
+
opts?: FetchOptions,
|
|
103
|
+
): Promise<RawResponse> {
|
|
104
|
+
return rawRequest(
|
|
105
|
+
`${trimBase(input.coordinatorUrl)}/api/logs/${encodeURIComponent(input.logId)}/pending-delegation`,
|
|
106
|
+
{ method: "GET", headers: { accept: "application/json" } },
|
|
107
|
+
opts,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function postDelegationCertificate(
|
|
112
|
+
input: { coordinatorUrl: string; submission: CertificateSubmission },
|
|
113
|
+
opts?: FetchOptions,
|
|
114
|
+
): Promise<RawResponse> {
|
|
115
|
+
return rawRequest(
|
|
116
|
+
`${trimBase(input.coordinatorUrl)}/api/delegations/certificate`,
|
|
117
|
+
{
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { "content-type": "application/json" },
|
|
120
|
+
body: JSON.stringify(input.submission),
|
|
121
|
+
},
|
|
122
|
+
opts,
|
|
123
|
+
);
|
|
124
|
+
}
|
package/src/node/cli.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bin entry, reached via `bin/mcp-register.mjs`.
|
|
3
|
+
*
|
|
4
|
+
* <no args> → StdioServerTransport; this is what
|
|
5
|
+
* `npx -y @forestrie/mcp-register` does.
|
|
6
|
+
* --help | --version → text on stdout, exit 0.
|
|
7
|
+
*
|
|
8
|
+
* TWO RULES, both silent-corruption bugs if broken:
|
|
9
|
+
*
|
|
10
|
+
* 1. **Nothing may write to stdout in stdio mode except the transport.** A
|
|
11
|
+
* stray `console.log` does not produce a warning — it produces a malformed
|
|
12
|
+
* JSON-RPC frame and an MCP client that mysteriously fails to initialise.
|
|
13
|
+
* All logging goes to stderr. `scripts/check-stdio-clean.mjs` proves it
|
|
14
|
+
* against a real spawned process on every CI run.
|
|
15
|
+
*
|
|
16
|
+
* 2. **`main` returns an exit code; it does not call `process.exit`.** Killing
|
|
17
|
+
* the process mid-write truncates whatever the stdio transport had
|
|
18
|
+
* buffered. The wrapper sets `process.exitCode` and lets node drain.
|
|
19
|
+
*
|
|
20
|
+
* Serves the tools `createServer()` (`server.ts`) registers. It is
|
|
21
|
+
* called with no `deps`, so it reads `process.env` and calls the real
|
|
22
|
+
* `globalThis.fetch` — exactly what `npx -y @forestrie/mcp-register` should do.
|
|
23
|
+
*/
|
|
24
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
25
|
+
import {
|
|
26
|
+
PACKAGE_VERSION,
|
|
27
|
+
RESOLVE_VERSION,
|
|
28
|
+
VERIFIER_VERSION,
|
|
29
|
+
} from "../core/index.js";
|
|
30
|
+
import { createServer } from "./server.js";
|
|
31
|
+
import {
|
|
32
|
+
PROMPT_NAMES,
|
|
33
|
+
PROMPT_TITLES,
|
|
34
|
+
TOOL_NAMES,
|
|
35
|
+
TOOL_TITLES,
|
|
36
|
+
} from "./text.js";
|
|
37
|
+
|
|
38
|
+
/** One line per tool, generated from the same list the server registers
|
|
39
|
+
* from — never a hand-typed block. */
|
|
40
|
+
const TOOLS_BLOCK = TOOL_NAMES.map(
|
|
41
|
+
(name) => ` ${name.padEnd(26)} ${TOOL_TITLES[name]}`,
|
|
42
|
+
).join("\n");
|
|
43
|
+
|
|
44
|
+
const PROMPTS_BLOCK = PROMPT_NAMES.map(
|
|
45
|
+
(name) => ` ${name.padEnd(26)} ${PROMPT_TITLES[name]}`,
|
|
46
|
+
).join("\n");
|
|
47
|
+
|
|
48
|
+
export const HELP = `forestrie-mcp-register ${PACKAGE_VERSION}
|
|
49
|
+
|
|
50
|
+
MCP server that signs statements with a log key, registers them on a
|
|
51
|
+
Forestrie transparency log under a grant you hold, delegates sealing for
|
|
52
|
+
your data log, and verifies the receipt by composing @forestrie/mcp-resolve
|
|
53
|
+
and @forestrie/mcp-verify. Signature bytes never cross the MCP boundary.
|
|
54
|
+
|
|
55
|
+
USAGE
|
|
56
|
+
forestrie-mcp-register start the MCP server on stdio (the default)
|
|
57
|
+
forestrie-mcp-register --help
|
|
58
|
+
forestrie-mcp-register --version
|
|
59
|
+
|
|
60
|
+
TOOLS
|
|
61
|
+
${TOOLS_BLOCK}
|
|
62
|
+
|
|
63
|
+
PROMPTS
|
|
64
|
+
${PROMPTS_BLOCK}
|
|
65
|
+
|
|
66
|
+
CONFIGURATION (each overridable per call)
|
|
67
|
+
FORESTRIE_BASE_URL canopy SCRAPI base URL
|
|
68
|
+
FORESTRIE_ROOT_LOG_ID the forest's root log id (R in /register/{R}/entries)
|
|
69
|
+
FORESTRIE_GRANT your Forestrie-Grant: base64, or a file path
|
|
70
|
+
FORESTRIE_AUTHORITY_GRANT your authority log's grant, for issue_grant and register_grant
|
|
71
|
+
FORESTRIE_COORDINATOR_URL delegation coordinator, for delegate_sealing
|
|
72
|
+
FORESTRIE_KNOWN_SEALER_KEY registrar voucher key, base64 x‖y, for delegate_sealing
|
|
73
|
+
MCP_REGISTER_HOME key files, grants, registrations, spend ledger
|
|
74
|
+
(default ~/.config/forestrie/mcp-register)
|
|
75
|
+
MCP_REGISTER_KEY_FILE the log key file (default $MCP_REGISTER_HOME/log-key.jwk)
|
|
76
|
+
MCP_REGISTER_AUTHORITY_KEY_FILE
|
|
77
|
+
the authority key file (default $MCP_REGISTER_HOME/authority-key.jwk)
|
|
78
|
+
|
|
79
|
+
PAYMENT (configuration only; never a tool argument)
|
|
80
|
+
MCP_REGISTER_PAYER_KEY_FILE EVM private key (0x + 64 hex, mode 600) that pays canopy's
|
|
81
|
+
x402 402; unset means relay mode
|
|
82
|
+
MCP_REGISTER_PAY_MAX_PER_CALL cap per payment, atomic units (required with a payer)
|
|
83
|
+
MCP_REGISTER_PAY_MAX_PER_DAY cap per UTC day, atomic units (required with a payer)
|
|
84
|
+
MCP_REGISTER_PAY_NETWORK the one network (default eip155:84532, Base Sepolia)
|
|
85
|
+
MCP_REGISTER_PAY_ASSET the one asset (default USDC on Base Sepolia)
|
|
86
|
+
MCP_REGISTER_PAY_TO optional: the only payee
|
|
87
|
+
MCP_REGISTER_PAY_ALLOW_MAINNET 1 to pay on a network that is not a testnet (off)
|
|
88
|
+
|
|
89
|
+
CLIENT CONFIG
|
|
90
|
+
{"mcpServers": {"forestrie-register":
|
|
91
|
+
{"command": "npx", "args": ["-y", "@forestrie/mcp-register"]}}}
|
|
92
|
+
|
|
93
|
+
Verifier: @forestrie/mcp-verify ${VERIFIER_VERSION}
|
|
94
|
+
Resolver: @forestrie/mcp-resolve ${RESOLVE_VERSION}
|
|
95
|
+
Docs: https://github.com/forestrie/mcp-register#readme
|
|
96
|
+
`;
|
|
97
|
+
|
|
98
|
+
/** stdout is the transport in stdio mode; everything else is stderr. */
|
|
99
|
+
const out = (line: string): void => {
|
|
100
|
+
process.stdout.write(`${line}\n`);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export async function main(argv: readonly string[]): Promise<number> {
|
|
104
|
+
const args = [...argv];
|
|
105
|
+
|
|
106
|
+
if (args.includes("--help") || args.includes("-h") || args[0] === "help") {
|
|
107
|
+
out(HELP);
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
111
|
+
out(PACKAGE_VERSION);
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const verb = args[0];
|
|
116
|
+
if (verb !== undefined && !verb.startsWith("-")) {
|
|
117
|
+
process.stderr.write(
|
|
118
|
+
`forestrie-mcp-register: unknown command '${verb}'. Try --help.\n`,
|
|
119
|
+
);
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Default: speak MCP on stdio. From here on, stdout belongs to the
|
|
124
|
+
// transport and nothing else may touch it.
|
|
125
|
+
const server = createServer();
|
|
126
|
+
const transport = new StdioServerTransport();
|
|
127
|
+
await server.connect(transport);
|
|
128
|
+
|
|
129
|
+
// Wait for the client to go away (stdin ends, the transport closes) or
|
|
130
|
+
// for a signal. `server.server.onclose`, not `transport.onclose`: connect()
|
|
131
|
+
// installed the latter and overwriting it would skip the SDK's own
|
|
132
|
+
// cleanup. Listeners are removed afterwards so nothing here keeps the
|
|
133
|
+
// event loop alive.
|
|
134
|
+
let finish: () => void = () => {};
|
|
135
|
+
const ended = new Promise<void>((resolve) => {
|
|
136
|
+
finish = resolve;
|
|
137
|
+
});
|
|
138
|
+
const onSignal = (): void => finish();
|
|
139
|
+
server.server.onclose = finish;
|
|
140
|
+
process.stdin.once("end", finish);
|
|
141
|
+
process.on("SIGINT", onSignal);
|
|
142
|
+
process.on("SIGTERM", onSignal);
|
|
143
|
+
await ended;
|
|
144
|
+
process.off("SIGINT", onSignal);
|
|
145
|
+
process.off("SIGTERM", onSignal);
|
|
146
|
+
process.stdin.off("end", finish);
|
|
147
|
+
|
|
148
|
+
// A signal leaves the transport open, and its stdin listener keeps the
|
|
149
|
+
// process alive for as long as the client holds the pipe. Close it so the
|
|
150
|
+
// event loop drains; the wrapper sets the exit code, never process.exit.
|
|
151
|
+
await server.close();
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Entry point. `process.exitCode` rather than `process.exit()`: node then
|
|
157
|
+
* drains stdout before exiting, which a hard exit would not.
|
|
158
|
+
*/
|
|
159
|
+
export async function run(): Promise<void> {
|
|
160
|
+
try {
|
|
161
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
162
|
+
} catch (err) {
|
|
163
|
+
process.stderr.write(
|
|
164
|
+
`forestrie-mcp-register: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`,
|
|
165
|
+
);
|
|
166
|
+
process.exitCode = 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
await run();
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where child grants are kept between issue_grant and register_grant:
|
|
3
|
+
* `<home>/grants/<logId>/` holding `record.json`, `grant.cose` (as signed),
|
|
4
|
+
* `parent.cose` (the requester's authority grant, when known),
|
|
5
|
+
* `receipt.cose` and `completed.cose` (the grant with its receipt: the
|
|
6
|
+
* credential).
|
|
7
|
+
*
|
|
8
|
+
* The record is keyed by the child log id, so register_grant resumes from
|
|
9
|
+
* whatever state the last call reached. Nothing here is secret.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import type { PaymentRequired, PaymentSummary } from "../core/x402.js";
|
|
14
|
+
import { StoreError } from "./store.js";
|
|
15
|
+
|
|
16
|
+
export type GrantState =
|
|
17
|
+
/** Signed (or supplied), not yet accepted by canopy. */
|
|
18
|
+
| "issued"
|
|
19
|
+
/** canopy answered 402; waiting for a payment (relay mode). */
|
|
20
|
+
| "payment_required"
|
|
21
|
+
/** canopy answered 303: enqueued for sequencing under the owner log. */
|
|
22
|
+
| "accepted"
|
|
23
|
+
/** Sealed, and completed with its receipt. */
|
|
24
|
+
| "complete";
|
|
25
|
+
|
|
26
|
+
export interface GrantRecord {
|
|
27
|
+
logId: string;
|
|
28
|
+
ownerLogId: string;
|
|
29
|
+
state: GrantState;
|
|
30
|
+
/** True when issue_grant signed it here. */
|
|
31
|
+
issuedHere: boolean;
|
|
32
|
+
/** The key the grant names, hex x‖y. */
|
|
33
|
+
granteeHex: string;
|
|
34
|
+
maxHeight?: number;
|
|
35
|
+
flagsHex: string;
|
|
36
|
+
/** Whether the parent grant carries GF_CHILD_PAYMENT_REQUIRED. */
|
|
37
|
+
parentRequiresPayment?: boolean;
|
|
38
|
+
issuedAt: string;
|
|
39
|
+
baseUrl?: string;
|
|
40
|
+
rootLogId?: string;
|
|
41
|
+
paymentRequired?: PaymentRequired;
|
|
42
|
+
payment?: PaymentSummary & { via: "payer" | "relay"; at: string };
|
|
43
|
+
statusUrl?: string;
|
|
44
|
+
acceptedAt?: string;
|
|
45
|
+
firstPendingAt?: string;
|
|
46
|
+
entryId?: string;
|
|
47
|
+
massifHeight?: number;
|
|
48
|
+
receiptUrl?: string;
|
|
49
|
+
completedAt?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
53
|
+
const STATES: GrantState[] = [
|
|
54
|
+
"issued",
|
|
55
|
+
"payment_required",
|
|
56
|
+
"accepted",
|
|
57
|
+
"complete",
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
function io<T>(what: string, run: () => T): T {
|
|
61
|
+
try {
|
|
62
|
+
return run();
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw new StoreError(
|
|
65
|
+
"store_failed",
|
|
66
|
+
`${what}: ${err instanceof Error ? err.message : String(err)}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class GrantStore {
|
|
72
|
+
constructor(readonly root: string) {}
|
|
73
|
+
|
|
74
|
+
dir(logId: string): string {
|
|
75
|
+
if (!UUID.test(logId))
|
|
76
|
+
throw new StoreError(
|
|
77
|
+
"unknown_registration",
|
|
78
|
+
`logId must be a lowercase UUID (the child log id issue_grant returned); got ${logId}`,
|
|
79
|
+
);
|
|
80
|
+
return join(this.root, "grants", logId);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
path(logId: string, file: string): string {
|
|
84
|
+
return join(this.dir(logId), file);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
has(logId: string): boolean {
|
|
88
|
+
try {
|
|
89
|
+
readFileSync(this.path(logId, "record.json"));
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
save(record: GrantRecord, files: Record<string, Uint8Array> = {}): void {
|
|
97
|
+
io(`cannot save grant ${record.logId} under ${this.root}`, () => {
|
|
98
|
+
mkdirSync(this.dir(record.logId), { recursive: true, mode: 0o700 });
|
|
99
|
+
for (const [name, bytes] of Object.entries(files))
|
|
100
|
+
writeFileSync(this.path(record.logId, name), bytes);
|
|
101
|
+
writeFileSync(
|
|
102
|
+
this.path(record.logId, "record.json"),
|
|
103
|
+
JSON.stringify(record, null, 2) + "\n",
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
load(logId: string): GrantRecord {
|
|
109
|
+
const path = this.path(logId, "record.json");
|
|
110
|
+
let text: string;
|
|
111
|
+
try {
|
|
112
|
+
text = readFileSync(path, "utf8");
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT")
|
|
115
|
+
throw new StoreError(
|
|
116
|
+
"unknown_registration",
|
|
117
|
+
`no grant for log ${logId} in ${this.root}: issue_grant records each grant it signs there, or pass the grant itself`,
|
|
118
|
+
);
|
|
119
|
+
throw new StoreError(
|
|
120
|
+
"store_failed",
|
|
121
|
+
`cannot read ${path}: ${(err as Error).message}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
let record: Partial<GrantRecord> | undefined;
|
|
125
|
+
try {
|
|
126
|
+
record = JSON.parse(text) as Partial<GrantRecord>;
|
|
127
|
+
} catch {
|
|
128
|
+
record = undefined;
|
|
129
|
+
}
|
|
130
|
+
if (
|
|
131
|
+
record === undefined ||
|
|
132
|
+
record.logId !== logId ||
|
|
133
|
+
typeof record.ownerLogId !== "string" ||
|
|
134
|
+
!STATES.includes(record.state as GrantState)
|
|
135
|
+
)
|
|
136
|
+
throw new StoreError(
|
|
137
|
+
"corrupt_record",
|
|
138
|
+
`${path} is not a grant record for ${logId}`,
|
|
139
|
+
);
|
|
140
|
+
return record as GrantRecord;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
read(logId: string, file: string): Uint8Array | undefined {
|
|
144
|
+
try {
|
|
145
|
+
return new Uint8Array(readFileSync(this.path(logId, file)));
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
148
|
+
throw new StoreError(
|
|
149
|
+
"store_failed",
|
|
150
|
+
`cannot read ${file} for grant ${logId}: ${(err as Error).message}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|