@uuaid/agent-commons 0.2.0-alpha.2 → 0.2.0-alpha.4

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 (72) hide show
  1. package/CONFORMANCE.md +1 -1
  2. package/NOTICE +4 -7
  3. package/PROVENANCE.json +12 -17
  4. package/README.md +103 -12
  5. package/bin/agent-commons.mjs +8 -3
  6. package/examples/Dockerfile +1 -1
  7. package/package.json +5 -2
  8. package/src/carrier.mjs +7 -2
  9. package/src/channels.mjs +1 -1
  10. package/src/config.mjs +2 -1
  11. package/src/continuity.mjs +14 -7
  12. package/src/governance-scope.mjs +1 -1
  13. package/src/governance.mjs +4 -2
  14. package/src/index.d.ts +3 -1
  15. package/src/pillar.mjs +2 -2
  16. package/src/profiles.mjs +2 -1
  17. package/src/relay/auth.mjs +294 -0
  18. package/src/relay/board.mjs +223 -0
  19. package/src/relay/claims.mjs +25 -0
  20. package/src/relay/cli.mjs +49 -0
  21. package/src/relay/client.mjs +437 -0
  22. package/src/relay/docs/reconnect-v1.md +27 -0
  23. package/src/relay/docs/working-rules-v1.md +42 -0
  24. package/src/relay/enroll.mjs +232 -0
  25. package/src/relay/export-cli.mjs +47 -0
  26. package/src/relay/governance.mjs +1328 -0
  27. package/src/relay/mailbox.mjs +103 -0
  28. package/src/relay/records.mjs +146 -0
  29. package/src/relay/render.mjs +38 -0
  30. package/src/relay/roster.mjs +134 -0
  31. package/src/relay/routes.mjs +87 -0
  32. package/src/relay/schema.mjs +1081 -0
  33. package/src/relay/seed.mjs +89 -0
  34. package/src/relay/serve.mjs +116 -0
  35. package/src/relay/server.mjs +633 -0
  36. package/src/relay/signing.mjs +693 -0
  37. package/src/relay/test-mode.mjs +73 -0
  38. package/src/runtime.mjs +25 -12
  39. package/src/spaces/manifest.mjs +245 -0
  40. package/src/spaces/pillar-group.mjs +12 -0
  41. package/src/spaces/policy.mjs +229 -0
  42. package/src/spaces/store.mjs +325 -0
  43. package/src/trust.mjs +44 -5
  44. package/src/uuaid-namespace.d.mts +6 -0
  45. package/src/uuaid-namespace.mjs +48 -0
  46. package/src/vendor/pillar/blob/blob.mjs +12 -14
  47. package/src/vendor/pillar/groups-v2.mjs +1108 -0
  48. package/src/vendor/pillar/groups.mjs +175 -0
  49. package/src/vendor/pillar/identity/jcs.mjs +4 -5
  50. package/src/vendor/pillar/identity/keychain.mjs +51 -2
  51. package/src/vendor/pillar/net/carrier-client.mjs +29 -7
  52. package/src/vendor/pillar/net/refusals.mjs +4 -2
  53. package/test/channels.test.mjs +0 -268
  54. package/test/cohort.test.mjs +0 -36
  55. package/test/collaboration.test.mjs +0 -459
  56. package/test/continuity.test.mjs +0 -229
  57. package/test/custody.negative.test.mjs +0 -165
  58. package/test/derivation-parity.test.mjs +0 -19
  59. package/test/fixtures/derivation-vectors.json +0 -26
  60. package/test/fixtures/ed25519-small-order.vectors.json +0 -15
  61. package/test/governance-expected-red.expected-red.mjs +0 -241
  62. package/test/governance-scope.test.mjs +0 -142
  63. package/test/governance-t1-t10.test.mjs +0 -477
  64. package/test/governance.test.mjs +0 -514
  65. package/test/hold-admission.test.mjs +0 -1185
  66. package/test/keychain-strict-ed25519.test.mjs +0 -240
  67. package/test/offline.test.mjs +0 -152
  68. package/test/runtime-channels.test.mjs +0 -1140
  69. package/test/runtime.test.mjs +0 -201
  70. package/test/security-runtime.test.mjs +0 -104
  71. package/test/strict-ed25519-consumers.test.mjs +0 -234
  72. package/test/transport.test.mjs +0 -353
@@ -0,0 +1,232 @@
1
+ // Cloud Commons relay: the request side of the enrollment flows (spec v3.4.5 5.2, 5.3, 5.4; RD1,
2
+ // RD2, RD3, M1, B2, B3, B9).
3
+ //
4
+ // POST /v1/enroll | /v1/rekey | /v1/rebind share one body and one descriptor (5.2, B2):
5
+ // body: { invite_id, secret, member_id, public_key, agent, display_name, platform,
6
+ // created, nonce, sig, old_sig }
7
+ // descriptor: { kind, aud, invite_id, member_id, public_key, agent, old_agent,
8
+ // display_name, platform, created, nonce }
9
+ // sig = Ed25519 by the new key over "<tag>\n" + JCS(descriptor)
10
+ // old_sig = Ed25519 by the current key over the same bytes (re-key only)
11
+ // The relay rebuilds the descriptor: `kind` from the endpoint, `aud` from its configuration and
12
+ // `old_agent` from its own keys table, never from the request.
13
+ //
14
+ // Processing order (5.2): 1 per-IP caps (server.mjs), 2 size cap (routes.mjs), 3 parse and
15
+ // allowlist, 4 invite, 5 proof of possession, 6 nonce spend then the future check, 7 one key ever,
16
+ // 8 member state, 9 spend the invite in one IMMEDIATE transaction. Failures at steps 4-8 against an
17
+ // existing invite are persisted in enroll_attempts; the fifth voids a live invite (voidInvite: an
18
+ // alert to the lane seats, and for a re-bind invite the attempt's death, invite-voided). A failed
19
+ // proof of possession never spends the invite.
20
+ //
21
+ // The flow reuses the 6.1 primitives of the cf48f0e port (auth.mjs): the `created` grammar, the
22
+ // nonce format, strict Ed25519, keyBindsAgentUuaid in agentnet, and the one request-nonce table.
23
+
24
+ import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto";
25
+ import { ed25519 } from "@noble/curves/ed25519.js";
26
+ import { jcs } from "../pillar.mjs";
27
+ import { localIdFromKey } from "../vendor/pillar/identity/keychain.mjs";
28
+ import { keyBindsAgentUuaid } from "../uuaid-namespace.mjs";
29
+ import { RelayError, assertIntegerOnly, sanitizeText } from "./records.mjs";
30
+ import { tx, getMeta } from "./schema.mjs";
31
+ import { appendLog, sha256Hex } from "./signing.mjs";
32
+ import { isCreated, isNonce, isAgentnetAgent, isStrictEd25519PublicKey, verifyBytesStrict, spendNonce, AUTH_MAX_AGE_MS, AUTH_MAX_FUTURE_MS } from "./auth.mjs";
33
+ import { isFrozen, ownerLineSha, voidInvite, rebindCount } from "./governance.mjs";
34
+
35
+ export const ENROLLMENT_KINDS = Object.freeze(["enroll", "rekey", "rebind"]);
36
+ /** D-iii: relay-specific tags, one per endpoint. */
37
+ export const ENROLLMENT_TAGS = Object.freeze({ enroll: "commons-relay.enroll.v1", rekey: "commons-relay.rekey.v1", rebind: "commons-relay.rebind.v1" });
38
+ /** M1: failures against one invite that void it. */
39
+ export const ENROLL_FAILURE_BUDGET = 5;
40
+ const BODY_KEYS = Object.freeze(["invite_id", "secret", "member_id", "public_key", "agent", "display_name", "platform", "created", "nonce", "sig", "old_sig"]);
41
+ const HEX = (n) => new RegExp(`^[0-9a-f]{${n}}$`);
42
+ const INVITE_ID = HEX(32), SECRET = HEX(64), KEY = HEX(64), SIG = HEX(128);
43
+
44
+ /** The uniform refusal for steps 4 and 5: an unknown, dead or mismatched invite and a bad proof look the same. */
45
+ const REFUSED = () => new RelayError("enrollment-refused", 403);
46
+
47
+ /** The descriptor both signatures cover (5.2), in the spec's field order (JCS sorts keys anyway). */
48
+ export function enrollmentDescriptor({ kind, aud, invite_id, member_id, public_key, agent, old_agent, display_name, platform, created, nonce }) {
49
+ return { kind, aud, invite_id, member_id, public_key, agent, old_agent, display_name, platform, created, nonce };
50
+ }
51
+
52
+ /** "<tag>\n" + JCS(descriptor), the exact bytes `sig` and `old_sig` sign. */
53
+ export function enrollmentSigningBytes(d) {
54
+ const tag = ENROLLMENT_TAGS[d?.kind];
55
+ if (!tag) throw new RelayError("invalid-kind", 500);
56
+ return Buffer.from(`${tag}\n${jcs(d)}`, "utf8");
57
+ }
58
+
59
+ /**
60
+ * Build a signed enrollment-flow body (the reference client's signer; tests). `invite` is the invite
61
+ * the owner delivered ({relay: {origin}, kind, member_id, invite_id, secret}). `seedHex` is the NEW key
62
+ * seed; for re-key, `oldSeedHex` is the current key seed. Seeds never leave the caller.
63
+ */
64
+ export function signEnrollmentRequest({ invite, seedHex, oldSeedHex, displayName = null, platform = null, created, nonce }) {
65
+ const kind = invite.kind;
66
+ const seed = Buffer.from(seedHex, "hex");
67
+ const publicKey = Buffer.from(ed25519.getPublicKey(seed)).toString("hex");
68
+ const agent = `uuaid:agentnet:agent:${localIdFromKey(Buffer.from(publicKey, "hex"))}`;
69
+ let oldAgent = null, oldSeed = null;
70
+ if (kind === "rekey") {
71
+ oldSeed = Buffer.from(oldSeedHex, "hex");
72
+ oldAgent = `uuaid:agentnet:agent:${localIdFromKey(Buffer.from(ed25519.getPublicKey(oldSeed)))}`;
73
+ }
74
+ const body = {
75
+ invite_id: invite.invite_id, secret: invite.secret, member_id: kind === "enroll" ? null : invite.member_id,
76
+ public_key: publicKey, agent, display_name: kind === "enroll" ? displayName : null, platform: kind === "enroll" ? platform : null,
77
+ created: created ?? new Date().toISOString(), nonce: nonce ?? randomBytes(16).toString("hex"), sig: null, old_sig: null,
78
+ };
79
+ const bytes = enrollmentSigningBytes(enrollmentDescriptor({ ...body, kind, aud: invite.relay.origin, old_agent: oldAgent }));
80
+ body.sig = Buffer.from(ed25519.sign(bytes, seed)).toString("hex");
81
+ if (oldSeed) body.old_sig = Buffer.from(ed25519.sign(bytes, oldSeed)).toString("hex");
82
+ return { body, agent, publicKey };
83
+ }
84
+
85
+ /** Step 3: the endpoint's field allowlist and value column (5.2 table). Throws 400/422 (never counted). */
86
+ function parseBody(kind, body) {
87
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new RelayError("invalid-json", 400, "body must be a JSON object");
88
+ for (const key of Object.keys(body)) if (!BODY_KEYS.includes(key)) throw new RelayError("unknown-field", 400, key.slice(0, 64));
89
+ for (const key of BODY_KEYS) if (!Object.hasOwn(body, key)) throw new RelayError("missing-field", 400, key);
90
+ assertIntegerOnly(body);
91
+ const bad = (f) => { throw new RelayError("invalid-field", 400, f); };
92
+ if (typeof body.invite_id !== "string" || !INVITE_ID.test(body.invite_id)) bad("invite_id");
93
+ if (typeof body.secret !== "string" || !SECRET.test(body.secret)) bad("secret");
94
+ if (typeof body.public_key !== "string" || !KEY.test(body.public_key)) bad("public_key");
95
+ if (!isAgentnetAgent(body.agent)) bad("agent"); // B3: agentnet only
96
+ if (!isCreated(body.created)) bad("created");
97
+ if (!isNonce(body.nonce)) bad("nonce");
98
+ if (typeof body.sig !== "string" || !SIG.test(body.sig)) bad("sig");
99
+ if (kind === "rekey" ? typeof body.old_sig !== "string" || !SIG.test(body.old_sig) : body.old_sig !== null) bad("old_sig");
100
+ if (kind === "enroll" ? body.member_id !== null : typeof body.member_id !== "string" || !isAgentnetAgent(body.member_id)) bad("member_id");
101
+ if (kind === "enroll") {
102
+ const displayName = sanitizeText(body.display_name, { field: "display_name", max: 64 });
103
+ const platform = sanitizeText(body.platform, { field: "platform", max: 64 });
104
+ if (!displayName.trim()) bad("display_name");
105
+ if (!platform.trim()) bad("platform");
106
+ return { ...body, display_name: displayName, platform };
107
+ }
108
+ if (body.display_name !== null) bad("display_name");
109
+ if (body.platform !== null) bad("platform");
110
+ return body;
111
+ }
112
+
113
+ /** A refusal at steps 4-8 that counts against the invite. */
114
+ class Failure extends Error {
115
+ constructor(step, reason, error) { super(reason); this.step = step; this.reason = reason; this.error = error; }
116
+ }
117
+
118
+ const secretMatches = (secretHex, storedSha256Hex) =>
119
+ timingSafeEqual(Buffer.from(sha256Hex(Buffer.from(secretHex, "hex")), "hex"), Buffer.from(storedSha256Hex, "hex"));
120
+
121
+ /** The member's current active key (re-key's old key), from the relay's own table. */
122
+ const activeMemberKey = (db, memberId) => db.prepare(`SELECT public_key, agent FROM keys WHERE principal_kind = 'member' AND principal_id = ? AND status = 'active'
123
+ ORDER BY added_at DESC LIMIT 2`).all(memberId);
124
+
125
+ /** Persist one failure, then void a live invite once the budget is spent (M1). Its own transactions. */
126
+ function recordFailure(db, invite, kind, failure, sourceIp, now) {
127
+ tx(db, () => {
128
+ db.prepare("INSERT INTO enroll_attempts(invite_id, kind, step, reason, source_ip, at) VALUES (?, ?, ?, ?, ?, ?)")
129
+ .run(invite.invite_id, kind, failure.step, failure.reason, sourceIp, now);
130
+ appendLog(db, "security", { kind: "enroll.failed", invite_id: invite.invite_id, endpoint: kind, step: failure.step, reason: failure.reason }, now);
131
+ });
132
+ const n = db.prepare("SELECT count(*) n FROM enroll_attempts WHERE invite_id = ?").get(invite.invite_id).n;
133
+ const live = db.prepare(`SELECT 1 FROM invites i WHERE i.invite_id = ? AND i.voided_at IS NULL
134
+ AND NOT EXISTS (SELECT 1 FROM enrollments e WHERE e.invite_id = i.invite_id)`).get(invite.invite_id);
135
+ if (n < ENROLL_FAILURE_BUDGET || !live) return { failures: n, voided: false };
136
+ // D1-4: no void (and no death) while frozen or clock-suspect; the next failure after that voids it.
137
+ if (isFrozen(db) || getMeta(db, "clock_suspect") === "1") return { failures: n, voided: false };
138
+ voidInvite(db, invite.invite_id, now);
139
+ return { failures: n, voided: true };
140
+ }
141
+
142
+ /**
143
+ * Apply one enrollment-flow request (5.2 steps 3-9). `kind` is the endpoint's; `aud` the relay's
144
+ * configured audience; `sourceIp` the connection's address (the server refused a non-IP at step 1).
145
+ * Returns the pending enrollment, or throws a RelayError (403 enrollment-refused for steps 4-5,
146
+ * 401 stale, 409 replay / key-not-new / invalid-state, 503 frozen, 400s for step 3).
147
+ */
148
+ export function applyEnrollmentRequest(db, kind, rawBody, { aud, now, sourceIp }) {
149
+ if (!ENROLLMENT_KINDS.includes(kind)) throw new RelayError("invalid-kind", 500);
150
+ if (typeof aud !== "string" || !aud) throw new RelayError("relay-aud-unset", 500);
151
+ if (!Number.isSafeInteger(now)) throw new RelayError("invalid-time", 500);
152
+ if (typeof sourceIp !== "string" || sourceIp.length < 2) throw new RelayError("invalid-source", 400);
153
+ const body = parseBody(kind, rawBody); // step 3
154
+ if (isFrozen(db)) throw new RelayError("frozen", 503); // 9.2: every member request but GET /v1/status
155
+ const invite = db.prepare("SELECT * FROM invites WHERE invite_id = ?").get(body.invite_id);
156
+ if (!invite) throw REFUSED(); // nothing to count against
157
+ try {
158
+ // Step 4: the invite (constant-time secret check; live; the endpoint's kind and member, B2).
159
+ if (!secretMatches(body.secret, invite.secret_sha256)) throw new Failure(4, "secret", REFUSED());
160
+ if (invite.voided_at !== null) throw new Failure(4, "voided", REFUSED());
161
+ if (db.prepare("SELECT 1 FROM enrollments WHERE invite_id = ?").get(invite.invite_id)) throw new Failure(4, "spent", REFUSED());
162
+ if (now >= invite.expires_at || now < invite.created_at) throw new Failure(4, "expired", REFUSED());
163
+ if (invite.kind !== kind) throw new Failure(4, "kind", REFUSED());
164
+ if (invite.member_id !== body.member_id) throw new Failure(4, "member", REFUSED());
165
+ // Step 5: proof of possession over the descriptor the relay rebuilds.
166
+ const created = Date.parse(body.created);
167
+ if (created < now - AUTH_MAX_AGE_MS) throw new Failure(5, "stale", new RelayError("stale", 401));
168
+ if (!isStrictEd25519PublicKey(body.public_key) || !keyBindsAgentUuaid(body.agent, body.public_key)) throw new Failure(5, "key", REFUSED());
169
+ let oldAgent = null, oldKey = null;
170
+ if (kind === "rekey") {
171
+ const active = activeMemberKey(db, body.member_id);
172
+ if (active.length !== 1) throw new Failure(5, "old-key", REFUSED());
173
+ oldAgent = active[0].agent; oldKey = active[0].public_key;
174
+ }
175
+ const bytes = enrollmentSigningBytes(enrollmentDescriptor({ ...body, kind, aud, old_agent: oldAgent }));
176
+ if (!verifyBytesStrict(body.public_key, bytes, body.sig)) throw new Failure(5, "sig", REFUSED());
177
+ if (kind === "rekey" && !verifyBytesStrict(oldKey, bytes, body.old_sig)) throw new Failure(5, "old-sig", REFUSED());
178
+ // Step 6 (B9): spend the nonce, then refuse a future-dated request. The nonce stays spent.
179
+ if (!spendNonce(db, body.agent, body.nonce, created, now)) throw new Failure(6, "replay", new RelayError("replay", 409));
180
+ if (created > now + AUTH_MAX_FUTURE_MS) throw new Failure(6, "future", new RelayError("stale", 401));
181
+ // Steps 7-9 in one IMMEDIATE transaction: the checks are read where the write happens.
182
+ return tx(db, () => {
183
+ // Step 7 (RD1): one key ever; the key and its id were never seen, in any status.
184
+ if (db.prepare("SELECT 1 FROM keys WHERE public_key = ? OR agent = ?").get(body.public_key, body.agent)) throw new Failure(7, "key-not-new", new RelayError("key-not-new", 409));
185
+ if (kind === "enroll" && db.prepare("SELECT 1 FROM members WHERE member_id = ?").get(body.agent)) throw new Failure(7, "member-exists", new RelayError("key-not-new", 409));
186
+ // Step 8 (RD2): member state.
187
+ if (kind === "rekey") {
188
+ const m = db.prepare("SELECT status FROM members WHERE member_id = ?").get(body.member_id);
189
+ if (m?.status !== "active" || db.prepare("SELECT 1 FROM suspensions WHERE member_id = ? AND ended_at IS NULL").get(body.member_id))
190
+ throw new Failure(8, "member-not-active", new RelayError("invalid-state", 409, "re-key needs an active, unsuspended member"));
191
+ }
192
+ if (kind === "rebind") {
193
+ const m = db.prepare("SELECT status FROM members WHERE member_id = ?").get(body.member_id);
194
+ const own = db.prepare(`SELECT 1 FROM suspensions s WHERE s.member_id = ? AND s.reason = 'rebind' AND s.invite_id = ? AND s.ended_at IS NULL
195
+ AND NOT EXISTS (SELECT 1 FROM rebind_ends r WHERE r.invite_id = s.invite_id)`).get(body.member_id, invite.invite_id);
196
+ if (!m || m.status === "revoked" || !own) throw new Failure(8, "attempt-not-live", new RelayError("invalid-state", 409, "the re-bind attempt is not live"));
197
+ }
198
+ // Step 9: spend the invite.
199
+ if (db.prepare("SELECT 1 FROM enrollments WHERE invite_id = ?").get(invite.invite_id)) throw new Failure(4, "spent", REFUSED());
200
+ const memberId = kind === "enroll" ? body.agent : body.member_id;
201
+ const enrollmentId = `enr-${randomBytes(12).toString("hex")}`;
202
+ const details = db.prepare("SELECT lane, intended_display_name, intended_platform FROM invite_details WHERE invite_id = ?").get(invite.invite_id);
203
+ if (kind === "enroll") {
204
+ db.prepare("INSERT INTO members(member_id, status, role, updated_at) VALUES (?, 'pending', NULL, ?)").run(memberId, now);
205
+ db.prepare("INSERT INTO member_profiles(member_id, display_name, platform, lane, created_at) VALUES (?, ?, ?, ?, ?)")
206
+ .run(memberId, body.display_name, body.platform, details.lane, now);
207
+ }
208
+ db.prepare("INSERT INTO keys(public_key, agent, principal_kind, principal_id, status, added_at, revoked_at) VALUES (?, ?, 'member', ?, 'pending', ?, NULL)")
209
+ .run(body.public_key, body.agent, memberId, now);
210
+ db.prepare("INSERT INTO enrollments(id, invite_id, kind, member_id, public_key, spent_at) VALUES (?, ?, ?, ?, ?, ?)")
211
+ .run(enrollmentId, invite.invite_id, kind, memberId, body.public_key, now);
212
+ const profile = db.prepare("SELECT display_name, platform FROM member_profiles WHERE member_id = ?").get(memberId);
213
+ // 5.3: the owner-visible line {member_id, agent, platform, display_name, invite_id, created_by}.
214
+ const line = `commons-relay ${kind} enrollment=${enrollmentId} member=${memberId} agent=${body.agent} platform=${JSON.stringify(profile.platform)} display_name=${JSON.stringify(profile.display_name)} invite=${invite.invite_id} created_by=${invite.created_by}`;
215
+ const outboxId = randomUUID();
216
+ db.prepare("INSERT INTO owner_record_outbox(id, subject, line, line_sha256, created_at) VALUES (?, ?, ?, ?, ?)").run(outboxId, enrollmentId, line, ownerLineSha(line), now);
217
+ // 5.2: the invite's intended name and platform beside what the agent sent; a mismatch is shown, not hidden.
218
+ const mismatch = kind === "enroll" && (body.display_name !== details.intended_display_name || body.platform !== details.intended_platform);
219
+ // RD3 hook: the confirming seat sees how many re-binds this member already had (the 2nd and later raise rebind.repeat on confirmation).
220
+ const detail = { enrollment: enrollmentId, kind, invite_id: invite.invite_id, created_by: invite.created_by, owner_line: outboxId,
221
+ intended_display_name: details.intended_display_name, intended_platform: details.intended_platform, mismatch,
222
+ ...(kind === "rebind" ? { rebind_count: rebindCount(db, memberId) } : {}) };
223
+ db.prepare("INSERT INTO alerts(at, kind, subject, detail) VALUES (?, 'enrollment.request', ?, ?)").run(now, memberId, jcs(detail));
224
+ appendLog(db, "enrollment", { kind: "enrollment.spent", enrollment: enrollmentId, enrollment_kind: kind, invite_id: invite.invite_id, member_id: memberId, agent: body.agent }, now);
225
+ return { kind, enrollment_id: enrollmentId, member_id: memberId, agent: body.agent, status: "pending", owner_line: outboxId };
226
+ });
227
+ } catch (error) {
228
+ if (!(error instanceof Failure)) throw error;
229
+ recordFailure(db, invite, kind, error, sourceIp, now);
230
+ throw error.error;
231
+ }
232
+ }
@@ -0,0 +1,47 @@
1
+ // Cloud Commons relay: write the relay-signed export (spec v3.4.5 9.3: a truncated log tail is detected;
2
+ // the relay-signed export head covers all logs). Run where the relay key lives (on
3
+ // the relay's own host or container); it opens relay.db, opens no listener and makes no network call.
4
+ //
5
+ // node src/relay/export-cli.mjs --db <relay*.db> --relay-key-file <0600 file, 64-hex seed>
6
+ // --aud <the relay's aud: the test aud, or a production https origin> --out <file> [--at <ms>]
7
+ // The output is exportRelay(db) plus `head` = {body: {v, aud, epoch, at, logs: [{log, count,
8
+ // head_hash}] for every log}, sig}, both read in one snapshot. Exit status 2 on any refusal.
9
+
10
+ import { writeFileSync, realpathSync } from "node:fs";
11
+ import { pathToFileURL } from "node:url";
12
+ import { RELAY_TEST_AUD } from "./auth.mjs";
13
+ import { openRelayDb } from "./schema.mjs";
14
+ import { exportRelaySigned } from "./signing.mjs";
15
+ import { readPrivateFile, StartRefused, isProductionAud } from "./server.mjs";
16
+
17
+ const FLAGS = Object.freeze(["--db", "--relay-key-file", "--aud", "--out", "--at"]);
18
+
19
+ export function exportMain(argv = process.argv.slice(2), { stderr = process.stderr, now = Date.now } = {}) {
20
+ let db;
21
+ try {
22
+ const o = {};
23
+ for (let i = 0; i < argv.length; i += 2) {
24
+ if (!FLAGS.includes(argv[i]) || i + 1 >= argv.length || Object.hasOwn(o, argv[i].slice(2))) throw new StartRefused([`bad argument ${JSON.stringify(String(argv[i]).slice(0, 40))}`]);
25
+ o[argv[i].slice(2)] = argv[i + 1];
26
+ }
27
+ if (o.aud !== RELAY_TEST_AUD && !isProductionAud(o.aud)) throw new StartRefused([`aud must be ${RELAY_TEST_AUD} (L1) or a canonical https origin (L2)`]);
28
+ for (const k of ["db", "relay-key-file", "out"]) if (typeof o[k] !== "string") throw new StartRefused([`--${k} is required`]);
29
+ const at = o.at === undefined ? now() : /^(0|[1-9][0-9]{0,15})$/.test(o.at) ? Number(o.at) : NaN;
30
+ if (!Number.isSafeInteger(at)) throw new StartRefused(["--at must be an integer (ms)"]);
31
+ const seed = readPrivateFile(o["relay-key-file"], "--relay-key-file").trim();
32
+ if (!/^[0-9a-f]{64}$/.test(seed)) throw new StartRefused(["--relay-key-file must hold one 64-hex key seed"]);
33
+ db = openRelayDb(o.db);
34
+ const exp = exportRelaySigned(db, { relaySeedHex: seed, aud: o.aud, at });
35
+ writeFileSync(o.out, `${JSON.stringify(exp)}\n`, { mode: 0o600 });
36
+ return exp.head.body;
37
+ } catch (error) {
38
+ stderr.write(`relay export refused: ${String(error?.message ?? error).slice(0, 500)}\n`);
39
+ process.exitCode = 2;
40
+ return null;
41
+ } finally {
42
+ try { db?.close(); } catch { /* closed */ }
43
+ }
44
+ }
45
+
46
+ const invoked = (() => { try { return process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; } catch { return false; } })();
47
+ if (invoked) exportMain();