@forestrie/forestrie-cli 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +425 -0
- package/TRUST-MODEL.md +8 -0
- package/dist/cli.js +33811 -0
- package/dist/decode-receipt.d.ts +19 -0
- package/dist/decode-receipt.d.ts.map +1 -0
- package/dist/decode-receipt.js +19 -0
- package/dist/decode-receipt.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/decode-receipt-cbor.d.ts +26 -0
- package/dist/lib/decode-receipt-cbor.d.ts.map +1 -0
- package/dist/lib/decode-receipt-cbor.js +182 -0
- package/dist/lib/decode-receipt-cbor.js.map +1 -0
- package/dist/lib/decode-receipt-decode.d.ts +103 -0
- package/dist/lib/decode-receipt-decode.d.ts.map +1 -0
- package/dist/lib/decode-receipt-decode.js +237 -0
- package/dist/lib/decode-receipt-decode.js.map +1 -0
- package/dist/lib/decode-receipt-labels.d.ts +54 -0
- package/dist/lib/decode-receipt-labels.d.ts.map +1 -0
- package/dist/lib/decode-receipt-labels.js +117 -0
- package/dist/lib/decode-receipt-labels.js.map +1 -0
- package/dist/lib/decode-receipt-render.d.ts +11 -0
- package/dist/lib/decode-receipt-render.d.ts.map +1 -0
- package/dist/lib/decode-receipt-render.js +242 -0
- package/dist/lib/decode-receipt-render.js.map +1 -0
- package/package.json +83 -0
- package/src/cli.ts +5 -0
- package/src/command.ts +51 -0
- package/src/commands/admin-onboard-token.ts +51 -0
- package/src/commands/admin.ts +20 -0
- package/src/commands/complete-grant.ts +41 -0
- package/src/commands/create-consistency-proof.ts +44 -0
- package/src/commands/create-log.ts +95 -0
- package/src/commands/decode-receipt.ts +20 -0
- package/src/commands/delegate.ts +55 -0
- package/src/commands/deploy.ts +72 -0
- package/src/commands/fetch-accumulator.ts +49 -0
- package/src/commands/onboard-genesis.ts +70 -0
- package/src/commands/onboard-request.ts +59 -0
- package/src/commands/register-grant.ts +74 -0
- package/src/commands/register.ts +54 -0
- package/src/commands/resolve-receipt.ts +101 -0
- package/src/commands/sign-statement.ts +53 -0
- package/src/commands/verify-grant.ts +89 -0
- package/src/commands/verify.ts +92 -0
- package/src/commoncli.ts +34 -0
- package/src/decode-receipt.ts +56 -0
- package/src/index.ts +10 -0
- package/src/lib/bytes.ts +7 -0
- package/src/lib/checkpoint-provider.ts +297 -0
- package/src/lib/complete-grant-derive.ts +237 -0
- package/src/lib/consistency-proof.ts +271 -0
- package/src/lib/create-log-build.ts +168 -0
- package/src/lib/create-log-prepare.ts +75 -0
- package/src/lib/create-receipt-chain-verify.ts +267 -0
- package/src/lib/create-receipt-derive.ts +208 -0
- package/src/lib/create-receipt-inputs.ts +79 -0
- package/src/lib/decode-checkpoint-calldata.ts +188 -0
- package/src/lib/decode-receipt-cbor.ts +202 -0
- package/src/lib/decode-receipt-decode.ts +333 -0
- package/src/lib/decode-receipt-labels.ts +132 -0
- package/src/lib/decode-receipt-render.ts +284 -0
- package/src/lib/delegate-flow.ts +289 -0
- package/src/lib/delegate-voucher.ts +122 -0
- package/src/lib/deploy-artifact.ts +79 -0
- package/src/lib/deploy-bootstrap.ts +100 -0
- package/src/lib/deploy-flow.ts +184 -0
- package/src/lib/deploy-genesis-log-id.ts +29 -0
- package/src/lib/deploy-rpc.ts +40 -0
- package/src/lib/deployment-record.ts +65 -0
- package/src/lib/fsio.ts +91 -0
- package/src/lib/grant-flags-local.ts +19 -0
- package/src/lib/onboard-attestation.ts +120 -0
- package/src/lib/onboard-genesis-body.ts +94 -0
- package/src/lib/openssl-error-queue.ts +56 -0
- package/src/lib/register-flow.ts +292 -0
- package/src/lib/register-grant-build.ts +173 -0
- package/src/lib/register-grant-complete.ts +47 -0
- package/src/lib/register-grant-flow.ts +258 -0
- package/src/lib/resolve-receipt-freshen.ts +221 -0
- package/src/lib/sign-statement-build.ts +117 -0
- package/src/lib/sign-statement-key.ts +145 -0
- package/src/lib/verify-anchored.ts +326 -0
- package/src/lib/verify-checkpoint-chain.ts +160 -0
- package/src/lib/verify-eventscan.ts +214 -0
- package/src/lib/verify-inputs.ts +143 -0
- package/src/lib/verify-known-accumulator.ts +161 -0
- package/src/lib/verify-report.ts +265 -0
- package/src/lib/x402-payment.ts +115 -0
- package/src/main/admin-onboard-token.ts +216 -0
- package/src/main/complete-grant.ts +265 -0
- package/src/main/create-consistency-proof.ts +83 -0
- package/src/main/create-log.ts +347 -0
- package/src/main/create-receipt.ts +763 -0
- package/src/main/decode-receipt.ts +72 -0
- package/src/main/delegate.ts +141 -0
- package/src/main/deploy.ts +273 -0
- package/src/main/fetch-accumulator.ts +181 -0
- package/src/main/not-implemented.ts +43 -0
- package/src/main/onboard-genesis.ts +192 -0
- package/src/main/onboard-request.ts +333 -0
- package/src/main/register-grant.ts +270 -0
- package/src/main/register.ts +243 -0
- package/src/main/sign-statement.ts +168 -0
- package/src/main/verify.ts +767 -0
- package/src/options/admin-onboard-token.ts +48 -0
- package/src/options/common.ts +69 -0
- package/src/options/complete-grant.ts +40 -0
- package/src/options/create-consistency-proof.ts +63 -0
- package/src/options/create-log.ts +99 -0
- package/src/options/create-receipt.ts +243 -0
- package/src/options/decode-receipt.ts +25 -0
- package/src/options/delegate.ts +93 -0
- package/src/options/deploy.ts +105 -0
- package/src/options/fetch-accumulator.ts +62 -0
- package/src/options/onboard-genesis.ts +74 -0
- package/src/options/onboard-request.ts +57 -0
- package/src/options/register-grant.ts +72 -0
- package/src/options/register.ts +71 -0
- package/src/options/sign-statement.ts +64 -0
- package/src/options/verify.ts +250 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bootstrap-key registrant attestation producer (devdocs plan-2607-43 slice
|
|
3
|
+
* 06, ADR-0059 D8) — the client half of canopy's
|
|
4
|
+
* `onboarding/onboard-attestation.ts` verifier; the two must agree
|
|
5
|
+
* byte-for-byte on Sig_structure.
|
|
6
|
+
*
|
|
7
|
+
* COSE_Sign1, protected `{1: alg, 3: content type}`, payload = CWT claims:
|
|
8
|
+
* `iss` = the CAIP-10 univocityInstanceId, `aud` = the operator origin,
|
|
9
|
+
* `iat`/`exp` = a bounded freshness window, and a private claim (-70000)
|
|
10
|
+
* carrying the exact chain binding. Signed by the deployment's bootstrap key
|
|
11
|
+
* — the only key that can ever produce the instance's first checkpoint, so
|
|
12
|
+
* holder-of-key and whose-instance-this-is are the same party.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { sign as signSecp256k1 } from "viem/accounts";
|
|
16
|
+
import { keccak256, hexToBytes as viemHexToBytes } from "viem";
|
|
17
|
+
import {
|
|
18
|
+
encodeCborDeterministic,
|
|
19
|
+
encodeSigStructure,
|
|
20
|
+
} from "@forestrie/encoding";
|
|
21
|
+
import type { Es256SigningKey } from "./sign-statement-key.js";
|
|
22
|
+
|
|
23
|
+
export const ONBOARD_ATTESTATION_CONTENT_TYPE =
|
|
24
|
+
"application/forestrie-onboard-attestation+cwt";
|
|
25
|
+
|
|
26
|
+
export const COSE_ALG_ES256 = -7;
|
|
27
|
+
export const COSE_ALG_KS256 = -65799;
|
|
28
|
+
|
|
29
|
+
/** Private CWT claim: map {1: chainId tstr, 2: univocityAddr 40-lowerhex}. */
|
|
30
|
+
export const CLAIM_CHAIN_BINDING = -70000;
|
|
31
|
+
|
|
32
|
+
/** Default freshness window (canopy's policy ceiling is 24 h). */
|
|
33
|
+
export const DEFAULT_ATTESTATION_WINDOW_SEC = 3600;
|
|
34
|
+
|
|
35
|
+
export interface OnboardAttestationInput {
|
|
36
|
+
chainId: string;
|
|
37
|
+
/** 40-hex lowercase address body, no 0x. */
|
|
38
|
+
univocityAddr: string;
|
|
39
|
+
/** Operator deployment origin, e.g. `https://api-a.forest-2.forestrie.dev`. */
|
|
40
|
+
aud: string;
|
|
41
|
+
nowSec: number;
|
|
42
|
+
windowSec?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function claimsBytes(input: OnboardAttestationInput): Uint8Array {
|
|
46
|
+
const window = input.windowSec ?? DEFAULT_ATTESTATION_WINDOW_SEC;
|
|
47
|
+
const claims = new Map<number, unknown>([
|
|
48
|
+
[1, `eip155:${input.chainId}:0x${input.univocityAddr}`],
|
|
49
|
+
[3, input.aud],
|
|
50
|
+
[4, input.nowSec + window],
|
|
51
|
+
[6, input.nowSec],
|
|
52
|
+
[
|
|
53
|
+
CLAIM_CHAIN_BINDING,
|
|
54
|
+
new Map<number, unknown>([
|
|
55
|
+
[1, input.chainId],
|
|
56
|
+
[2, input.univocityAddr],
|
|
57
|
+
]),
|
|
58
|
+
],
|
|
59
|
+
]);
|
|
60
|
+
return encodeCborDeterministic(claims);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function protectedBytes(alg: number): Uint8Array {
|
|
64
|
+
return encodeCborDeterministic(
|
|
65
|
+
new Map<number, unknown>([
|
|
66
|
+
[1, alg],
|
|
67
|
+
[3, ONBOARD_ATTESTATION_CONTENT_TYPE],
|
|
68
|
+
]),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function assemble(
|
|
73
|
+
prot: Uint8Array,
|
|
74
|
+
payload: Uint8Array,
|
|
75
|
+
signature: Uint8Array,
|
|
76
|
+
): Uint8Array {
|
|
77
|
+
return encodeCborDeterministic([prot, new Map(), payload, signature]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** ES256 attestation signed with the deploy-generated bootstrap PEM key. */
|
|
81
|
+
export async function buildOnboardAttestationEs256(
|
|
82
|
+
key: Es256SigningKey,
|
|
83
|
+
input: OnboardAttestationInput,
|
|
84
|
+
): Promise<Uint8Array> {
|
|
85
|
+
const prot = protectedBytes(COSE_ALG_ES256);
|
|
86
|
+
const payload = claimsBytes(input);
|
|
87
|
+
const sigStructure = encodeSigStructure(prot, new Uint8Array(0), payload);
|
|
88
|
+
const signature = new Uint8Array(
|
|
89
|
+
await crypto.subtle.sign(
|
|
90
|
+
{ name: "ECDSA", hash: "SHA-256" },
|
|
91
|
+
key.privateKey,
|
|
92
|
+
sigStructure as unknown as ArrayBuffer,
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
return assemble(prot, payload, signature);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* KS256 attestation: the delegation-cose profile — keccak256(Sig_structure),
|
|
100
|
+
* 65-byte `r‖s‖v` EOA signature by the bootstrap key.
|
|
101
|
+
*/
|
|
102
|
+
export async function buildOnboardAttestationKs256(
|
|
103
|
+
privateKeyHex: string,
|
|
104
|
+
input: OnboardAttestationInput,
|
|
105
|
+
): Promise<Uint8Array> {
|
|
106
|
+
const prot = protectedBytes(COSE_ALG_KS256);
|
|
107
|
+
const payload = claimsBytes(input);
|
|
108
|
+
const sigStructure = encodeSigStructure(prot, new Uint8Array(0), payload);
|
|
109
|
+
const hash = keccak256(sigStructure);
|
|
110
|
+
const key = privateKeyHex.startsWith("0x")
|
|
111
|
+
? (privateKeyHex as `0x${string}`)
|
|
112
|
+
: (`0x${privateKeyHex}` as `0x${string}`);
|
|
113
|
+
const sig = await signSecp256k1({ hash, privateKey: key, to: "bytes" });
|
|
114
|
+
if (sig.length !== 65) {
|
|
115
|
+
throw new Error(`KS256 signature must be 65 bytes, got ${sig.length}`);
|
|
116
|
+
}
|
|
117
|
+
return assemble(prot, payload, sig);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export { viemHexToBytes as hexToBytes };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct-sign ES256 forest genesis body (FOR-406, plan-2607-27 W2).
|
|
3
|
+
*
|
|
4
|
+
* The operator onboarding payload for a `forestrie deploy`ed forest: the
|
|
5
|
+
* five-label v2 genesis map canopy's forest-genesis expects for the
|
|
6
|
+
* direct-sign path, encoded with the sanctioned deterministic RFC 8949
|
|
7
|
+
* §4.2 codec. This is the single shipped implementation that retires the
|
|
8
|
+
* demo's onboard-genesis.mjs and system-testing's es256-genesis helper.
|
|
9
|
+
*/
|
|
10
|
+
import { createPublicKey } from "node:crypto";
|
|
11
|
+
import { encodeCborDeterministic } from "@forestrie/encoding";
|
|
12
|
+
|
|
13
|
+
/** CBOR labels aligned with canopy forest-genesis-labels (v2 direct-sign). */
|
|
14
|
+
const LABEL_GENESIS_VERSION = -68009;
|
|
15
|
+
const LABEL_UNIVOCITY_ADDR = -68011;
|
|
16
|
+
const LABEL_CHAIN_ID = -68013;
|
|
17
|
+
const LABEL_GENESIS_ALG = -68014;
|
|
18
|
+
const LABEL_BOOTSTRAP_KEY = -68015;
|
|
19
|
+
const SCHEMA_V2 = 2;
|
|
20
|
+
const COSE_ALG_ES256 = -7;
|
|
21
|
+
|
|
22
|
+
function hexToBytes20(hex: string): Uint8Array {
|
|
23
|
+
const stripped = hex.startsWith("0x") ? hex.slice(2) : hex;
|
|
24
|
+
if (!/^[0-9a-fA-F]{40}$/.test(stripped)) {
|
|
25
|
+
throw new Error(`expected a 20-byte hex contract address, got ${hex}`);
|
|
26
|
+
}
|
|
27
|
+
const out = new Uint8Array(20);
|
|
28
|
+
for (let i = 0; i < 20; i += 1) {
|
|
29
|
+
out[i] = Number.parseInt(stripped.slice(i * 2, i * 2 + 2), 16);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Raw 64-byte x||y public key from a P-256 PEM (private or public). */
|
|
35
|
+
export function es256PublicKeyXy(pem: string): Uint8Array {
|
|
36
|
+
const jwk = createPublicKey(pem).export({ format: "jwk" });
|
|
37
|
+
if (jwk.crv !== "P-256" || !jwk.x || !jwk.y) {
|
|
38
|
+
throw new Error("bootstrap PEM must be a P-256 (ES256) key");
|
|
39
|
+
}
|
|
40
|
+
const x = Buffer.from(jwk.x, "base64url");
|
|
41
|
+
const y = Buffer.from(jwk.y, "base64url");
|
|
42
|
+
if (x.length !== 32 || y.length !== 32) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`expected 32-byte P-256 coordinates, got x=${x.length} y=${y.length}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const xy = new Uint8Array(64);
|
|
48
|
+
xy.set(x, 0);
|
|
49
|
+
xy.set(y, 32);
|
|
50
|
+
return xy;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Build the v2 direct-sign genesis CBOR body. */
|
|
54
|
+
export function buildEs256GenesisBody(opts: {
|
|
55
|
+
chainId: string;
|
|
56
|
+
univocityAddress: string;
|
|
57
|
+
bootstrapKeyXy: Uint8Array;
|
|
58
|
+
}): Uint8Array {
|
|
59
|
+
if (opts.bootstrapKeyXy.length !== 64) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`bootstrap key must be 64 bytes (x||y), got ${opts.bootstrapKeyXy.length}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (!opts.chainId.trim()) {
|
|
65
|
+
throw new Error("chainId must be a non-empty string");
|
|
66
|
+
}
|
|
67
|
+
return encodeCborDeterministic(
|
|
68
|
+
new Map<number, unknown>([
|
|
69
|
+
[LABEL_GENESIS_VERSION, SCHEMA_V2],
|
|
70
|
+
[LABEL_UNIVOCITY_ADDR, hexToBytes20(opts.univocityAddress)],
|
|
71
|
+
[LABEL_CHAIN_ID, opts.chainId],
|
|
72
|
+
[LABEL_GENESIS_ALG, COSE_ALG_ES256],
|
|
73
|
+
[LABEL_BOOTSTRAP_KEY, opts.bootstrapKeyXy],
|
|
74
|
+
]),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Genesis POST URL with the sealing-callback webhook query param. */
|
|
79
|
+
export function genesisPostUrl(
|
|
80
|
+
baseUrl: string,
|
|
81
|
+
logId: string,
|
|
82
|
+
webhookUrl: string,
|
|
83
|
+
): string {
|
|
84
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
85
|
+
return `${base}/api/forest/${logId}/genesis?webhookUrl=${encodeURIComponent(webhookUrl)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Coordinator signing-route webhook for a log (ADR-0050 sealing wiring). */
|
|
89
|
+
export function coordinatorSigningRouteUrl(
|
|
90
|
+
coordinatorUrl: string,
|
|
91
|
+
logId: string,
|
|
92
|
+
): string {
|
|
93
|
+
return `${coordinatorUrl.replace(/\/$/, "")}/api/logs/${logId}/signing-route`;
|
|
94
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenSSL/BoringSSL thread-local error-queue hygiene (FOR-343).
|
|
3
|
+
*
|
|
4
|
+
* On bun (BoringSSL), a FAILED `crypto.subtle.importKey` over malformed DER
|
|
5
|
+
* leaves one unconsumed error on the thread-local OpenSSL error queue. The
|
|
6
|
+
* NEXT `node:crypto` PEM parse (`createPrivateKey` / `createPublicKey`) reads
|
|
7
|
+
* that stale error and misreports a perfectly valid key as
|
|
8
|
+
* `error:0c00007b:ASN.1 encoding routines:OPENSSL_internal:HEADER_TOO_LONG`.
|
|
9
|
+
* A single subsequent parse consumes the stale entry, so the corruption
|
|
10
|
+
* "poisons" exactly one following operation.
|
|
11
|
+
*
|
|
12
|
+
* This bites cross-module in the single bun test runtime: `forestrie deploy`'s
|
|
13
|
+
* negative-path test (a deliberately invalid bootstrap PEM routed through
|
|
14
|
+
* `@forestrie/deploy-core`'s WebCrypto importKey) poisons the queue, and a
|
|
15
|
+
* later `sign-statement` / `register-grant` PEM parse in another test file
|
|
16
|
+
* then fails spuriously. See the FOR-343 repro.
|
|
17
|
+
*
|
|
18
|
+
* These helpers keep the failure contained: {@link drainOpenSslErrorQueue}
|
|
19
|
+
* consumes the stale entry at the source (right after a known crypto failure),
|
|
20
|
+
* and {@link parsePemResilient} retries once so a valid key is never rejected
|
|
21
|
+
* because of a queue poisoned elsewhere.
|
|
22
|
+
*/
|
|
23
|
+
import { createPublicKey } from "node:crypto";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Consume any single stale entry left on the OpenSSL error queue by a prior
|
|
27
|
+
* failed crypto operation. Parses a deliberately invalid PEM and swallows the
|
|
28
|
+
* (expected) throw: that parse reads and clears the stale entry. Best-effort
|
|
29
|
+
* and never throws.
|
|
30
|
+
*/
|
|
31
|
+
export function drainOpenSslErrorQueue(): void {
|
|
32
|
+
try {
|
|
33
|
+
createPublicKey({
|
|
34
|
+
key: "-----BEGIN PUBLIC KEY-----\nAA\n-----END PUBLIC KEY-----\n",
|
|
35
|
+
format: "pem",
|
|
36
|
+
});
|
|
37
|
+
} catch {
|
|
38
|
+
// Expected: the invalid PEM consumes the stale queue entry (and its own).
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Run a PEM `parse` that may spuriously throw because the OpenSSL error queue
|
|
44
|
+
* was poisoned by an unrelated prior crypto failure. On the first failure the
|
|
45
|
+
* stale entry is consumed, so a single retry either succeeds (the input was
|
|
46
|
+
* always valid) or throws the genuine error (the input is truly invalid).
|
|
47
|
+
*/
|
|
48
|
+
export function parsePemResilient<T>(parse: () => T): T {
|
|
49
|
+
try {
|
|
50
|
+
return parse();
|
|
51
|
+
} catch {
|
|
52
|
+
// Retry once: the failed attempt above drained any poisoning entry, so a
|
|
53
|
+
// valid key now parses; a genuinely-invalid key throws the real error here.
|
|
54
|
+
return parse();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FOR-342 registration flow: POST the signed statement, then drive the
|
|
3
|
+
* SCRAPI 303 receipt-redirect contract to completion.
|
|
4
|
+
*
|
|
5
|
+
* `@forestrie/scrapi-client` deliberately ships poll-once primitives with
|
|
6
|
+
* NO sleep loops — this module owns the pacing (`--timeout` /
|
|
7
|
+
* `--poll-interval`), honouring a longer `Retry-After` when the worker
|
|
8
|
+
* sends one. Fetch/sleep/clock are injectable for tests.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
ScrapiRegistrationError,
|
|
12
|
+
queryRegistrationOnce,
|
|
13
|
+
registerSignedStatement,
|
|
14
|
+
resolveReceiptOnce,
|
|
15
|
+
toAbsoluteScrapiUrl,
|
|
16
|
+
type ProblemDetails,
|
|
17
|
+
} from "@forestrie/scrapi-client";
|
|
18
|
+
|
|
19
|
+
/** Where the flow failed — drives the `--json` error code. */
|
|
20
|
+
export type RegisterFlowStage =
|
|
21
|
+
| "register" // POST /register/{logId}/entries rejected
|
|
22
|
+
| "status" // query-registration-status broke the 303 contract
|
|
23
|
+
| "receipt" // resolve-receipt answered non-200/404
|
|
24
|
+
| "network" // fetch itself failed (ECONNREFUSED, DNS, reset) — no response
|
|
25
|
+
| "timeout"; // `--timeout` budget exhausted (pending, or a hung request)
|
|
26
|
+
|
|
27
|
+
export class RegisterFlowError extends Error {
|
|
28
|
+
readonly stage: RegisterFlowStage;
|
|
29
|
+
readonly httpStatus: number | undefined;
|
|
30
|
+
/** Problem `detail` (or body preview) from the failing response. */
|
|
31
|
+
readonly detail: string | undefined;
|
|
32
|
+
/** RFC 9457 problem details decoded from a CBOR error body. */
|
|
33
|
+
readonly problem: ProblemDetails | undefined;
|
|
34
|
+
readonly statusUrl: string | undefined;
|
|
35
|
+
readonly receiptUrl: string | undefined;
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
message: string,
|
|
39
|
+
info: {
|
|
40
|
+
stage: RegisterFlowStage;
|
|
41
|
+
httpStatus?: number;
|
|
42
|
+
detail?: string;
|
|
43
|
+
problem?: ProblemDetails;
|
|
44
|
+
statusUrl?: string;
|
|
45
|
+
receiptUrl?: string;
|
|
46
|
+
},
|
|
47
|
+
) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "RegisterFlowError";
|
|
50
|
+
this.stage = info.stage;
|
|
51
|
+
this.httpStatus = info.httpStatus;
|
|
52
|
+
this.detail = info.detail;
|
|
53
|
+
this.problem = info.problem;
|
|
54
|
+
this.statusUrl = info.statusUrl;
|
|
55
|
+
this.receiptUrl = info.receiptUrl;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Progress callbacks so the CLI can narrate long polls on stderr. */
|
|
60
|
+
export type RegisterProgress =
|
|
61
|
+
| { phase: "registered"; statusUrl: string }
|
|
62
|
+
| { phase: "status-pending"; statusUrl: string; attempt: number }
|
|
63
|
+
| { phase: "receipt-located"; receiptUrl: string; entryIdHex: string }
|
|
64
|
+
| { phase: "receipt-pending"; receiptUrl: string; attempt: number };
|
|
65
|
+
|
|
66
|
+
/** Injectable effects — production defaults are real fetch/sleep/clock. */
|
|
67
|
+
export type RegisterFlowDeps = {
|
|
68
|
+
fetchImpl?: typeof fetch;
|
|
69
|
+
sleep?: (ms: number) => Promise<void>;
|
|
70
|
+
now?: () => number;
|
|
71
|
+
onProgress?: (progress: RegisterProgress) => void;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type RegisterFlowParams = {
|
|
75
|
+
/** SCRAPI origin, no trailing slash. */
|
|
76
|
+
baseUrl: string;
|
|
77
|
+
/** Forest bootstrap log id — first `/register/` path segment. */
|
|
78
|
+
logId: string;
|
|
79
|
+
/** Completed grant, `Authorization: Forestrie-Grant` base64. */
|
|
80
|
+
grantB64: string;
|
|
81
|
+
/** COSE Sign1 signed statement wire bytes. */
|
|
82
|
+
statement: Uint8Array;
|
|
83
|
+
/** Overall budget covering both poll phases. */
|
|
84
|
+
timeoutMs: number;
|
|
85
|
+
/** Pacing between polls; a longer `Retry-After` wins. */
|
|
86
|
+
pollIntervalMs: number;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type RegisterFlowResult = {
|
|
90
|
+
/** Final query-registration-status URL (from the register 303). */
|
|
91
|
+
statusUrl: string;
|
|
92
|
+
/** Permanent resolve-receipt URL. */
|
|
93
|
+
receiptUrl: string;
|
|
94
|
+
/** Permanent entry id (32 hex chars) from the receipt Location. */
|
|
95
|
+
entryIdHex: string;
|
|
96
|
+
/** Receipt wire bytes (CBOR). */
|
|
97
|
+
receipt: Uint8Array;
|
|
98
|
+
contentType: string | undefined;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const defaultSleep = (ms: number): Promise<void> =>
|
|
102
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
103
|
+
|
|
104
|
+
/** One line for a transport failure: `code: message (cause)` where known. */
|
|
105
|
+
function describeFetchError(err: unknown): string {
|
|
106
|
+
if (!(err instanceof Error)) return String(err);
|
|
107
|
+
const code = (err as { code?: unknown }).code;
|
|
108
|
+
const cause =
|
|
109
|
+
err.cause instanceof Error && err.cause.message !== err.message
|
|
110
|
+
? ` (${err.cause.message})`
|
|
111
|
+
: "";
|
|
112
|
+
return typeof code === "string" && code !== "" && !err.message.includes(code)
|
|
113
|
+
? `${code}: ${err.message}${cause}`
|
|
114
|
+
: `${err.message}${cause}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function requestUrl(input: Parameters<typeof fetch>[0]): string {
|
|
118
|
+
if (typeof input === "string") return input;
|
|
119
|
+
if (input instanceof URL) return input.href;
|
|
120
|
+
return input.url;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* F4 (plan-2607-14 W1.4): bound every request with an AbortSignal tied to
|
|
125
|
+
* the remaining `--timeout` budget — a hung server cannot stall the flow
|
|
126
|
+
* past the deadline (the deadline was previously only checked BETWEEN
|
|
127
|
+
* polls) — and map fetch-level failures (ECONNREFUSED, DNS, resets) into
|
|
128
|
+
* the `RegisterFlowError` taxonomy instead of letting bare TypeErrors
|
|
129
|
+
* escape as unstructured crashes.
|
|
130
|
+
*
|
|
131
|
+
* The timer uses real wall-clock (`setTimeout`) while `remaining` comes
|
|
132
|
+
* from the injectable `now` — with a fake clock the fake fetch resolves
|
|
133
|
+
* immediately and the timer is cleared before it can fire.
|
|
134
|
+
*/
|
|
135
|
+
function boundedFetch(
|
|
136
|
+
baseFetch: typeof fetch,
|
|
137
|
+
now: () => number,
|
|
138
|
+
deadline: number,
|
|
139
|
+
timeoutMs: number,
|
|
140
|
+
): typeof fetch {
|
|
141
|
+
return (async (
|
|
142
|
+
input: Parameters<typeof fetch>[0],
|
|
143
|
+
init?: RequestInit,
|
|
144
|
+
): Promise<Response> => {
|
|
145
|
+
const remaining = deadline - now();
|
|
146
|
+
if (remaining <= 0) {
|
|
147
|
+
throw new RegisterFlowError(
|
|
148
|
+
`timed out after ${timeoutMs}ms waiting for the receipt`,
|
|
149
|
+
{ stage: "timeout" },
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const controller = new AbortController();
|
|
153
|
+
const timer = setTimeout(() => controller.abort(), remaining);
|
|
154
|
+
try {
|
|
155
|
+
return await baseFetch(input, { ...init, signal: controller.signal });
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (err instanceof RegisterFlowError) throw err;
|
|
158
|
+
const url = requestUrl(input);
|
|
159
|
+
if (controller.signal.aborted) {
|
|
160
|
+
throw new RegisterFlowError(
|
|
161
|
+
`timed out after ${timeoutMs}ms: no response from ${url} within the --timeout budget`,
|
|
162
|
+
{ stage: "timeout", detail: url },
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
throw new RegisterFlowError(
|
|
166
|
+
`request to ${url} failed: ${describeFetchError(err)}`,
|
|
167
|
+
{ stage: "network", detail: describeFetchError(err) },
|
|
168
|
+
);
|
|
169
|
+
} finally {
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
}
|
|
172
|
+
}) as typeof fetch;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Register `statement` and wait for its receipt: POST → 303 status URL →
|
|
177
|
+
* poll query-registration-status until the receipt redirect → poll
|
|
178
|
+
* resolve-receipt (404 = objects still writing) until 200.
|
|
179
|
+
*/
|
|
180
|
+
export async function runRegisterFlow(
|
|
181
|
+
params: RegisterFlowParams,
|
|
182
|
+
deps: RegisterFlowDeps = {},
|
|
183
|
+
): Promise<RegisterFlowResult> {
|
|
184
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
185
|
+
const now = deps.now ?? Date.now;
|
|
186
|
+
const onProgress = deps.onProgress ?? (() => {});
|
|
187
|
+
const deadline = now() + params.timeoutMs;
|
|
188
|
+
const fetchImpl = boundedFetch(
|
|
189
|
+
deps.fetchImpl ?? fetch,
|
|
190
|
+
now,
|
|
191
|
+
deadline,
|
|
192
|
+
params.timeoutMs,
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const paceOrTimeout = async (
|
|
196
|
+
waitMs: number,
|
|
197
|
+
info: { statusUrl: string; receiptUrl?: string },
|
|
198
|
+
): Promise<void> => {
|
|
199
|
+
if (now() + waitMs > deadline) {
|
|
200
|
+
throw new RegisterFlowError(
|
|
201
|
+
`timed out after ${params.timeoutMs}ms waiting for the receipt`,
|
|
202
|
+
{ stage: "timeout", ...info },
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
await sleep(waitMs);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// 1. POST /register/{logId}/entries — only 303 + Location succeeds.
|
|
209
|
+
let statusUrl: string;
|
|
210
|
+
try {
|
|
211
|
+
({ statusUrl } = await registerSignedStatement({
|
|
212
|
+
baseUrl: params.baseUrl,
|
|
213
|
+
bootstrapLogId: params.logId,
|
|
214
|
+
grantBase64: params.grantB64,
|
|
215
|
+
statement: params.statement,
|
|
216
|
+
fetchImpl,
|
|
217
|
+
}));
|
|
218
|
+
} catch (err) {
|
|
219
|
+
if (err instanceof ScrapiRegistrationError) {
|
|
220
|
+
throw new RegisterFlowError(err.message, {
|
|
221
|
+
stage: "register",
|
|
222
|
+
httpStatus: err.httpStatus,
|
|
223
|
+
detail: err.detail,
|
|
224
|
+
...(err.problem !== undefined ? { problem: err.problem } : {}),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
throw err;
|
|
228
|
+
}
|
|
229
|
+
onProgress({ phase: "registered", statusUrl });
|
|
230
|
+
|
|
231
|
+
// 2. Poll query-registration-status until the receipt redirect.
|
|
232
|
+
let receiptUrl: string;
|
|
233
|
+
let entryIdHex: string;
|
|
234
|
+
for (let attempt = 1; ; attempt++) {
|
|
235
|
+
const status = await queryRegistrationOnce({
|
|
236
|
+
statusUrl,
|
|
237
|
+
baseUrl: params.baseUrl,
|
|
238
|
+
fetchImpl,
|
|
239
|
+
});
|
|
240
|
+
if (status.status === "receipt") {
|
|
241
|
+
({ receiptUrl, entryIdHex } = status);
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
if (status.status === "error") {
|
|
245
|
+
throw new RegisterFlowError(
|
|
246
|
+
`registration status: ${status.detail}`,
|
|
247
|
+
{
|
|
248
|
+
stage: "status",
|
|
249
|
+
httpStatus: status.httpStatus,
|
|
250
|
+
detail: status.detail,
|
|
251
|
+
...(status.problem !== undefined ? { problem: status.problem } : {}),
|
|
252
|
+
statusUrl,
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
// pending — the worker may move the status Location and pace us.
|
|
257
|
+
statusUrl = toAbsoluteScrapiUrl(params.baseUrl, status.location);
|
|
258
|
+
onProgress({ phase: "status-pending", statusUrl, attempt });
|
|
259
|
+
await paceOrTimeout(
|
|
260
|
+
Math.max(params.pollIntervalMs, status.retryAfterMs ?? 0),
|
|
261
|
+
{ statusUrl },
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
onProgress({ phase: "receipt-located", receiptUrl, entryIdHex });
|
|
265
|
+
|
|
266
|
+
// 3. Poll resolve-receipt: 404 = checkpoint/massif still writing.
|
|
267
|
+
for (let attempt = 1; ; attempt++) {
|
|
268
|
+
const resolution = await resolveReceiptOnce({ receiptUrl, fetchImpl });
|
|
269
|
+
if (resolution.status === "receipt") {
|
|
270
|
+
return {
|
|
271
|
+
statusUrl,
|
|
272
|
+
receiptUrl,
|
|
273
|
+
entryIdHex,
|
|
274
|
+
receipt: resolution.body,
|
|
275
|
+
contentType: resolution.headers["content-type"],
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (resolution.status === "error") {
|
|
279
|
+
throw new RegisterFlowError(
|
|
280
|
+
`resolve receipt: HTTP ${resolution.httpStatus}`,
|
|
281
|
+
{
|
|
282
|
+
stage: "receipt",
|
|
283
|
+
httpStatus: resolution.httpStatus,
|
|
284
|
+
statusUrl,
|
|
285
|
+
receiptUrl,
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
onProgress({ phase: "receipt-pending", receiptUrl, attempt });
|
|
290
|
+
await paceOrTimeout(params.pollIntervalMs, { statusUrl, receiptUrl });
|
|
291
|
+
}
|
|
292
|
+
}
|