@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.
- package/CONFORMANCE.md +1 -1
- package/NOTICE +4 -7
- package/PROVENANCE.json +12 -17
- package/README.md +103 -12
- package/bin/agent-commons.mjs +8 -3
- package/examples/Dockerfile +1 -1
- package/package.json +5 -2
- package/src/carrier.mjs +7 -2
- package/src/channels.mjs +1 -1
- package/src/config.mjs +2 -1
- package/src/continuity.mjs +14 -7
- package/src/governance-scope.mjs +1 -1
- package/src/governance.mjs +4 -2
- package/src/index.d.ts +3 -1
- package/src/pillar.mjs +2 -2
- package/src/profiles.mjs +2 -1
- package/src/relay/auth.mjs +294 -0
- package/src/relay/board.mjs +223 -0
- package/src/relay/claims.mjs +25 -0
- package/src/relay/cli.mjs +49 -0
- package/src/relay/client.mjs +437 -0
- package/src/relay/docs/reconnect-v1.md +27 -0
- package/src/relay/docs/working-rules-v1.md +42 -0
- package/src/relay/enroll.mjs +232 -0
- package/src/relay/export-cli.mjs +47 -0
- package/src/relay/governance.mjs +1328 -0
- package/src/relay/mailbox.mjs +103 -0
- package/src/relay/records.mjs +146 -0
- package/src/relay/render.mjs +38 -0
- package/src/relay/roster.mjs +134 -0
- package/src/relay/routes.mjs +87 -0
- package/src/relay/schema.mjs +1081 -0
- package/src/relay/seed.mjs +89 -0
- package/src/relay/serve.mjs +116 -0
- package/src/relay/server.mjs +633 -0
- package/src/relay/signing.mjs +693 -0
- package/src/relay/test-mode.mjs +73 -0
- package/src/runtime.mjs +25 -12
- package/src/spaces/manifest.mjs +245 -0
- package/src/spaces/pillar-group.mjs +12 -0
- package/src/spaces/policy.mjs +229 -0
- package/src/spaces/store.mjs +325 -0
- package/src/trust.mjs +44 -5
- package/src/uuaid-namespace.d.mts +6 -0
- package/src/uuaid-namespace.mjs +48 -0
- package/src/vendor/pillar/blob/blob.mjs +12 -14
- package/src/vendor/pillar/groups-v2.mjs +1108 -0
- package/src/vendor/pillar/groups.mjs +175 -0
- package/src/vendor/pillar/identity/jcs.mjs +4 -5
- package/src/vendor/pillar/identity/keychain.mjs +51 -2
- package/src/vendor/pillar/net/carrier-client.mjs +29 -7
- package/src/vendor/pillar/net/refusals.mjs +4 -2
- package/test/channels.test.mjs +0 -268
- package/test/cohort.test.mjs +0 -36
- package/test/collaboration.test.mjs +0 -459
- package/test/continuity.test.mjs +0 -229
- package/test/custody.negative.test.mjs +0 -165
- package/test/derivation-parity.test.mjs +0 -19
- package/test/fixtures/derivation-vectors.json +0 -26
- package/test/fixtures/ed25519-small-order.vectors.json +0 -15
- package/test/governance-expected-red.expected-red.mjs +0 -241
- package/test/governance-scope.test.mjs +0 -142
- package/test/governance-t1-t10.test.mjs +0 -477
- package/test/governance.test.mjs +0 -514
- package/test/hold-admission.test.mjs +0 -1185
- package/test/keychain-strict-ed25519.test.mjs +0 -240
- package/test/offline.test.mjs +0 -152
- package/test/runtime-channels.test.mjs +0 -1140
- package/test/runtime.test.mjs +0 -201
- package/test/security-runtime.test.mjs +0 -104
- package/test/strict-ed25519-consumers.test.mjs +0 -234
- package/test/transport.test.mjs +0 -353
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
// Cloud Commons relay: the HTTP API (spec v3.4.5 sections 6, 7.1, 7.4-7.6, 9.2 Freeze, 11.3, 11.5
|
|
2
|
+
// and 13.2 "Test mode"). In test mode: loopback and named test interfaces only; fixture keys only.
|
|
3
|
+
//
|
|
4
|
+
// START (section 15 "server.mjs"). The relay refuses to start unless BOTH hold:
|
|
5
|
+
// - `aud` is exactly the test audience http://commons-relay.test:8080 (6.1, B5); and
|
|
6
|
+
// - the bind is a literal loopback address (127.0.0.0/8 or ::1), or the address of the network
|
|
7
|
+
// interface named by --test-interface.
|
|
8
|
+
// Anything else (a production aud, a wildcard such as 0.0.0.0 or ::, a host name, any other address)
|
|
9
|
+
// exits with status 2 before any file or key is read, with or without --test-mode. It never warns and
|
|
10
|
+
// carries on. --test-mode adds the clock and barrier files of test-mode.mjs (container-local),
|
|
11
|
+
// loaded only then. No route reaches them: the 6.5 inventory is closed.
|
|
12
|
+
// Security invariant: the entrypoint of any non-test build must not carry --test-mode.
|
|
13
|
+
//
|
|
14
|
+
// PRODUCTION MODE (started only by serve.mjs, the `agent-commons relay serve`
|
|
15
|
+
// command, which has no test flag at all). `mode: "production"` changes exactly three things:
|
|
16
|
+
// - `aud` is the configured https origin, in canonical form (https, no user, path, query, fragment,
|
|
17
|
+
// default port or trailing slash; lower-case host), never the test audience or a `.test` host;
|
|
18
|
+
// - the bind is a literal loopback address only (the host's reverse proxy terminates TLS); no test
|
|
19
|
+
// interface, no test mode, no test directory;
|
|
20
|
+
// - the source address of every request is the `X-Real-IP` header the reverse proxy sets (exactly
|
|
21
|
+
// one, a valid IP, only from a loopback peer); a request without one is refused 400 invalid-source
|
|
22
|
+
// before any other work, the sweep included (11.3), so it changes no state, and the 5.2 per-IP caps
|
|
23
|
+
// never fall into one shared bucket.
|
|
24
|
+
// Test mode (the default) is exactly the START behaviour above.
|
|
25
|
+
//
|
|
26
|
+
// EVERY REQUEST, in this order (6.1 verifier order, 7.1 ingress order):
|
|
27
|
+
// 0. production mode only: the source check (X-Real-IP); a request that fails it is refused 400
|
|
28
|
+
// invalid-source and does no work at all, not even the sweep (11.3);
|
|
29
|
+
// 1. the sweep (11.3: on every request that passes the source check, and every 15 s on a timer);
|
|
30
|
+
// 2. the route inventory, the browser-origin refusal and the size cap, before any body byte is read
|
|
31
|
+
// (routes.mjs classifyRequest); a request without a plain Content-Length is refused;
|
|
32
|
+
// 3. the body, read up to the cap, then a strict JSON parse;
|
|
33
|
+
// 4. 6.1 authentication and the 6.5 principal rules (routes.mjs admitRequest, auth.mjs);
|
|
34
|
+
// 5. the 7.6 rate caps (members: per-member and global token buckets);
|
|
35
|
+
// 6. the 9.2 freeze (member requests other than GET /v1/status get 503);
|
|
36
|
+
// 7. the record handler (board, mailbox, governance) for the route;
|
|
37
|
+
// 8. one relay-signed response (6.2: commons-relay-sig on EVERY response, refusals included) and
|
|
38
|
+
// one source-metadata line (7.6).
|
|
39
|
+
//
|
|
40
|
+
// The enrollment-flow endpoints (POST /v1/enroll, /v1/rekey, /v1/rebind; 5.2) run their own order
|
|
41
|
+
// after the browser-origin refusal: the per-IP caps (10 attempts per hour, 5 concurrent; a source that
|
|
42
|
+
// is not an IP is refused, never counted in a shared bucket), the 4 KB cap, then enroll.mjs. Their
|
|
43
|
+
// response signature binds the body's nonce once the body has been parsed; a refusal made before any
|
|
44
|
+
// body byte is read (403 browser, 429 caps, 413) binds request_nonce null, and a client treats a
|
|
45
|
+
// null-bound response as a refusal only, never as a success. GET /v1/seed returns the member's
|
|
46
|
+
// seed bundle, signed by the relay key (section 8).
|
|
47
|
+
|
|
48
|
+
import { createServer } from "node:http";
|
|
49
|
+
import { isIP } from "node:net";
|
|
50
|
+
import { networkInterfaces } from "node:os";
|
|
51
|
+
import { appendFileSync, lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
52
|
+
import { pathToFileURL } from "node:url";
|
|
53
|
+
import { ed25519 } from "@noble/curves/ed25519.js";
|
|
54
|
+
import { jcs } from "../pillar.mjs";
|
|
55
|
+
import { RelayError } from "./records.mjs";
|
|
56
|
+
import { FLEET_RECORD_TYPES, classifyRequest, admitRequest } from "./routes.mjs";
|
|
57
|
+
import { RELAY_TEST_AUD, RELAY_SIG_HEADER, UNAUTHORIZED, signRelayResponse, requestNonceOf, isNonce } from "./auth.mjs";
|
|
58
|
+
import { applyEnrollmentRequest } from "./enroll.mjs";
|
|
59
|
+
import { seedBundleBody, signSeedBundle } from "./seed.mjs";
|
|
60
|
+
import { runSweep, SWEEP_INTERVAL_MS, handleMemberRecord, ownClaims, boardView } from "./board.mjs";
|
|
61
|
+
import { readInbox, ackInbox, pruneMailbox, relayEpoch } from "./mailbox.mjs";
|
|
62
|
+
import * as G from "./governance.mjs";
|
|
63
|
+
import { renderSeatFeed, renderMemberRecord } from "./render.mjs";
|
|
64
|
+
import { openRelay } from "./roster.mjs";
|
|
65
|
+
|
|
66
|
+
/** 7.6 defaults (open for tuning). Fleet principals are roster-loaded and are not bucketed, so a member flood cannot starve a freeze or a suspension. */
|
|
67
|
+
export const RATE_LIMITS = Object.freeze({
|
|
68
|
+
member: Object.freeze({ capacity: 20, perSecond: 1 }),
|
|
69
|
+
global: Object.freeze({ capacity: 100, perSecond: 10 }),
|
|
70
|
+
});
|
|
71
|
+
/** 7.6 / 5.2 step 1: enrollment-flow requests per source IP. */
|
|
72
|
+
export const ENROLL_LIMITS = Object.freeze({ perIpPerHour: 10, concurrentPerIp: 5, windowMs: 3600000 });
|
|
73
|
+
const ENROLL_PATHS = new Set(["/v1/enroll", "/v1/rekey", "/v1/rebind"]);
|
|
74
|
+
/** 7.6 long-poll: wait <= 25 s; <= 2 concurrent per member; <= 64 in total. */
|
|
75
|
+
export const LONG_POLL = Object.freeze({ maxWaitS: 25, perMember: 2, total: 64 });
|
|
76
|
+
export { SWEEP_INTERVAL_MS };
|
|
77
|
+
const PAGE = 100;
|
|
78
|
+
|
|
79
|
+
// --- start rules ------------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
export class StartRefused extends Error {
|
|
82
|
+
constructor(reasons) {
|
|
83
|
+
super(`relay start refused: ${reasons.join("; ")}`);
|
|
84
|
+
this.name = "StartRefused";
|
|
85
|
+
this.reasons = reasons;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const isLoopback = (host) => (isIP(host) === 4 && host.startsWith("127.")) || host === "::1";
|
|
90
|
+
|
|
91
|
+
/** A literal loopback address, or an address of the interface named as the test interface. Never a name or a wildcard. */
|
|
92
|
+
export function bindAllowed(host, { testInterface } = {}) {
|
|
93
|
+
if (typeof host !== "string" || isIP(host) === 0) return false;
|
|
94
|
+
if (isLoopback(host)) return true;
|
|
95
|
+
if (typeof testInterface !== "string" || !testInterface) return false;
|
|
96
|
+
return (networkInterfaces()[testInterface] ?? []).some(a => a.address === host);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A production audience is one configured https origin in canonical form (it is compared byte for
|
|
101
|
+
* byte, 6.1 B5): exactly `new URL(aud).origin`, so no user, path, query, fragment, default port or
|
|
102
|
+
* trailing slash, and a lower-case host. Never the test audience and never a `.test` host (RFC 6761).
|
|
103
|
+
*/
|
|
104
|
+
export function isProductionAud(aud) {
|
|
105
|
+
if (typeof aud !== "string" || aud.length > 256 || aud === RELAY_TEST_AUD) return false;
|
|
106
|
+
let url;
|
|
107
|
+
try { url = new URL(aud); } catch { return false; }
|
|
108
|
+
if (url.protocol !== "https:" || url.username || url.password || url.origin !== aud) return false;
|
|
109
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
110
|
+
return !(host === "test" || host.endsWith(".test"));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const MODES = Object.freeze(["test", "production"]);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The conjunctive start refusal. Throws StartRefused unless the aud AND the bind are allowed.
|
|
117
|
+
* mode "test" (default): the test aud AND loopback or the named test interface.
|
|
118
|
+
* mode "production": a production https aud AND a literal loopback bind; no test interface.
|
|
119
|
+
*/
|
|
120
|
+
export function checkStart({ aud, host, testInterface, mode = "test" }) {
|
|
121
|
+
const reasons = [];
|
|
122
|
+
if (mode === "production") {
|
|
123
|
+
if (!isProductionAud(aud)) reasons.push("aud must be a canonical https origin (not the L1 test aud, not a .test host)");
|
|
124
|
+
if (typeof host !== "string" || !isLoopback(host)) reasons.push("bind must be a literal loopback address (production sits behind the host's reverse proxy)");
|
|
125
|
+
if (testInterface !== undefined) reasons.push("production mode takes no test interface");
|
|
126
|
+
} else if (mode === "test") {
|
|
127
|
+
if (aud !== RELAY_TEST_AUD) reasons.push(`aud must be exactly ${RELAY_TEST_AUD} (L1)`);
|
|
128
|
+
if (!bindAllowed(host, { testInterface })) reasons.push("bind must be a literal loopback address or an address of the named test interface");
|
|
129
|
+
} else reasons.push("unknown mode");
|
|
130
|
+
if (reasons.length) throw new StartRefused(reasons);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The client address the reverse proxy reports in X-Real-IP (one header, a valid IP, from a loopback peer), or null. */
|
|
134
|
+
export function proxiedSourceIp(req) {
|
|
135
|
+
const peer = req.socket?.remoteAddress ?? "";
|
|
136
|
+
const peerLoopback = isLoopback(peer) || /^::ffff:127\./.test(peer);
|
|
137
|
+
const raw = req.headers?.["x-real-ip"];
|
|
138
|
+
const repeated = (req.rawHeaders ?? []).filter((h, i) => i % 2 === 0 && h.toLowerCase() === "x-real-ip").length > 1;
|
|
139
|
+
if (!peerLoopback || repeated || typeof raw !== "string") return null;
|
|
140
|
+
const ip = raw.trim();
|
|
141
|
+
return isIP(ip) === 0 ? null : ip;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- request helpers --------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
const QUERY_KEYS = Object.freeze({ "/v1/inbox": ["after", "wait"], "/v1/fleet/alerts": ["after"], "/v1/fleet/quarantine": ["after"] });
|
|
147
|
+
|
|
148
|
+
/** Integer-only query values, each key at most once, only the keys the route names. */
|
|
149
|
+
function parseQuery(path, query) {
|
|
150
|
+
const allowed = QUERY_KEYS[path] ?? [];
|
|
151
|
+
const out = {};
|
|
152
|
+
if (query === "") return out;
|
|
153
|
+
for (const part of query.split("&")) {
|
|
154
|
+
const eq = part.indexOf("=");
|
|
155
|
+
const key = eq < 0 ? part : part.slice(0, eq), value = eq < 0 ? "" : part.slice(eq + 1);
|
|
156
|
+
if (!allowed.includes(key) || Object.hasOwn(out, key)) throw new RelayError("invalid-query", 400, key.slice(0, 32));
|
|
157
|
+
if (!/^(0|[1-9][0-9]{0,15})$/.test(value) || !Number.isSafeInteger(Number(value))) throw new RelayError("invalid-query", 400, key);
|
|
158
|
+
out[key] = Number(value);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function readBody(req, cap) {
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
const chunks = [];
|
|
166
|
+
let n = 0, settled = false;
|
|
167
|
+
const fail = (error) => { if (!settled) { settled = true; reject(error); } };
|
|
168
|
+
req.on("data", (chunk) => {
|
|
169
|
+
if (settled) return;
|
|
170
|
+
n += chunk.length;
|
|
171
|
+
if (n > cap) return fail(new RelayError("too-large", 413));
|
|
172
|
+
chunks.push(chunk);
|
|
173
|
+
});
|
|
174
|
+
req.on("end", () => { if (!settled) { settled = true; resolve(Buffer.concat(chunks, n)); } });
|
|
175
|
+
req.on("error", fail);
|
|
176
|
+
req.on("close", () => fail(new RelayError("aborted", 400)));
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
181
|
+
function parseJson(bytes) {
|
|
182
|
+
if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) throw new RelayError("invalid-json", 400, "byte order mark");
|
|
183
|
+
let value;
|
|
184
|
+
try { value = JSON.parse(UTF8.decode(bytes)); } catch { throw new RelayError("invalid-json", 400); }
|
|
185
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new RelayError("invalid-json", 400, "body must be a JSON object");
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const ok = (body) => ({ status: 200, body });
|
|
190
|
+
const clip = (v, n = 200) => (typeof v === "string" ? v.slice(0, n) : null);
|
|
191
|
+
|
|
192
|
+
// --- fleet record dispatch (6.5 fleet record table) -------------------------------------------
|
|
193
|
+
|
|
194
|
+
const D0_TYPES = new Set(["invite.create", "rebind.cancel", "enroll.confirm", "rekey.confirm", "task.open", "task.close", "task.assign",
|
|
195
|
+
"claim.release", "progress.accept", "role.c1", "clock.resume", "review.changes", "review.reject"]);
|
|
196
|
+
const OWNER_TYPES = new Set(["freeze", "veto", "unfreeze"]);
|
|
197
|
+
/** Decision records: the route's <type> must name the decision's action and class (otherwise 400 type-mismatch). */
|
|
198
|
+
const DECISION_ROUTE = (type, b) =>
|
|
199
|
+
D0_TYPES.has(type) ? b.class === "D0" && b.action === type
|
|
200
|
+
: OWNER_TYPES.has(type) ? b.class === "OWNER" && b.action === type
|
|
201
|
+
: type === "suspend" ? b.class === "SUSPEND" && b.action === "suspend"
|
|
202
|
+
: type === "decision.propose" ? ["D1", "D1R", "D2"].includes(b.class)
|
|
203
|
+
: null;
|
|
204
|
+
/** The other fleet records: each record handler checks its own record version, so another type's body is refused there. */
|
|
205
|
+
const RECORD_HANDLERS = Object.freeze({
|
|
206
|
+
verdict: G.addVerdict, "served_by.attest": G.recordAttestation, "finding.open": G.openFinding, "finding.close": G.closeFinding,
|
|
207
|
+
"coauthor.declare": G.declareCoauthor, "verify.result": G.recordVerifyResult, "security.approve": G.recordSecurityApproval,
|
|
208
|
+
message: G.sendSeatMessage, "owner-record.confirm": G.confirmOwnerRecord,
|
|
209
|
+
});
|
|
210
|
+
/** Every 6.5 fleet record type has exactly one handler (checked at load and by test). */
|
|
211
|
+
export const FLEET_DISPATCH = Object.freeze(Object.fromEntries(FLEET_RECORD_TYPES.map(type => [type,
|
|
212
|
+
RECORD_HANDLERS[type] ? "record" : DECISION_ROUTE(type, {}) !== null ? "decision" : null])));
|
|
213
|
+
for (const [type, kind] of Object.entries(FLEET_DISPATCH)) if (!kind) throw new Error(`no handler for fleet record type ${type}`);
|
|
214
|
+
|
|
215
|
+
function fleetRecord(db, type, signed, now, relay) {
|
|
216
|
+
for (const key of Object.keys(signed)) if (key !== "body" && key !== "sig") throw new RelayError("unknown-field", 400, key);
|
|
217
|
+
const b = signed.body;
|
|
218
|
+
if (!b || typeof b !== "object" || Array.isArray(b)) throw new RelayError("invalid-record", 400, "body");
|
|
219
|
+
if (FLEET_DISPATCH[type] === "decision") {
|
|
220
|
+
if (!DECISION_ROUTE(type, b)) throw new RelayError("type-mismatch", 400);
|
|
221
|
+
const out = G.createDecision(db, signed, now);
|
|
222
|
+
// 5.1: the invite goes back once, to the creating seat, with the relay's origin (= aud) and full key (B5, B7).
|
|
223
|
+
return out.invite ? { ...out, invite: { relay: { origin: relay.aud, public_key: relay.publicKey }, ...out.invite } } : out;
|
|
224
|
+
}
|
|
225
|
+
return RECORD_HANDLERS[type](db, signed, now);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// --- fleet views (6.5: alerts, queue, decisions, members, quarantine, owner-outbox) -----------
|
|
229
|
+
// Read-only. Member-originated text reaches a seat only through the 7.4 renderer (EXTERNAL header
|
|
230
|
+
// from relay metadata, inert fenced text).
|
|
231
|
+
|
|
232
|
+
function memberMeta(db, memberId) {
|
|
233
|
+
return db.prepare(`SELECT p.display_name, m.role,
|
|
234
|
+
(SELECT agent FROM keys k WHERE k.principal_kind = 'member' AND k.principal_id = m.member_id AND k.status = 'active') AS agent
|
|
235
|
+
FROM members m JOIN member_profiles p USING(member_id) WHERE m.member_id = ?`).get(memberId) ?? {};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function fleetView(db, view, q) {
|
|
239
|
+
const after = q.after ?? 0;
|
|
240
|
+
switch (view) {
|
|
241
|
+
case "alerts": {
|
|
242
|
+
const items = db.prepare("SELECT id, at, kind, subject, detail FROM alerts WHERE id > ? ORDER BY id LIMIT ?").all(after, PAGE)
|
|
243
|
+
.map(r => ({ id: r.id, at: r.at, kind: r.kind, subject: r.subject, detail: JSON.parse(r.detail) }));
|
|
244
|
+
return { alerts: items, next_after: items.length ? items.at(-1).id : after };
|
|
245
|
+
}
|
|
246
|
+
case "quarantine": {
|
|
247
|
+
const items = db.prepare("SELECT id, member_id, record_id, body, at FROM ext_quarantine WHERE id > ? ORDER BY id LIMIT ?").all(after, PAGE)
|
|
248
|
+
.map(r => {
|
|
249
|
+
const m = memberMeta(db, r.member_id);
|
|
250
|
+
return { id: r.id, member_id: r.member_id, record_id: r.record_id, at: r.at,
|
|
251
|
+
rendered: renderMemberRecord({ type: "note", member_id: r.member_id, agent: m.agent, display_name: m.display_name, role: m.role, at: r.at }, JSON.parse(r.body)) };
|
|
252
|
+
});
|
|
253
|
+
return { items, next_after: items.length ? items.at(-1).id : after };
|
|
254
|
+
}
|
|
255
|
+
case "queue": {
|
|
256
|
+
const lanes = db.prepare("SELECT lane FROM lanes ORDER BY lane").all();
|
|
257
|
+
// 12.2: the verification lane re-runs the patch itself, so the in-review queue carries the patch
|
|
258
|
+
// (unified-diff text, data only; the relay never applies or runs it) beside its sha256 and base.
|
|
259
|
+
const inReview = db.prepare(`SELECT s.submission_id, s.member_id, x.task_id, t.lane, t.base_commit, x.kind, x.body, x.body_sha256, x.created_at
|
|
260
|
+
FROM submissions s JOIN submission_details x USING(submission_id) JOIN tasks t ON t.task_id = x.task_id
|
|
261
|
+
WHERE s.state = 'in-review' ORDER BY x.created_at, s.submission_id LIMIT ?`).all(PAGE)
|
|
262
|
+
.map(({ body, ...r }) => ({ ...r, ...(r.kind === "patch" ? { patch: JSON.parse(body) } : {}) }));
|
|
263
|
+
const triage = db.prepare("SELECT task_id, lane, attempts FROM tasks WHERE status = 'needs-triage' ORDER BY updated_at, task_id LIMIT ?").all(PAGE).map(r => ({ ...r }));
|
|
264
|
+
return { feed: lanes.map(({ lane }) => ({ lane, items: renderSeatFeed(db, lane).slice(0, PAGE) })), in_review: inReview, needs_triage: triage };
|
|
265
|
+
}
|
|
266
|
+
case "decisions": {
|
|
267
|
+
const verdicts = db.prepare("SELECT approver, seq, verdict, body_sha256 FROM approvals WHERE decision_id = ? ORDER BY approver, seq");
|
|
268
|
+
return {
|
|
269
|
+
decisions: db.prepare(`SELECT id, class, action, subject, sponsor, lane, state, owner_line_ref, effect_at, created_at
|
|
270
|
+
FROM decisions WHERE state = 'pending' ORDER BY created_at, id LIMIT ?`).all(PAGE)
|
|
271
|
+
.map(d => ({ ...d, verdicts: verdicts.all(d.id).map(v => ({ ...v })) })),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
case "members": {
|
|
275
|
+
const members = db.prepare(`SELECT m.member_id, m.status, m.role, p.display_name, p.platform, p.lane, p.first_heartbeat_at, p.last_heartbeat_at
|
|
276
|
+
FROM members m JOIN member_profiles p USING(member_id) ORDER BY p.created_at, m.member_id`).all().map(r => ({ ...r }));
|
|
277
|
+
// 5.2 / 5.3 / RD3: what a confirming seat sees: the invite's intended name and platform beside
|
|
278
|
+
// what the agent sent, the owner line, the invite creator (P1), and for a re-bind the count so far.
|
|
279
|
+
const pending = db.prepare(`SELECT e.id, e.kind, e.member_id, e.invite_id, e.spent_at, k.agent, k.status AS key_status, i.created_by,
|
|
280
|
+
d.intended_display_name, d.intended_platform, p.display_name, p.platform,
|
|
281
|
+
o.id AS owner_line_id, o.confirmed_at AS owner_line_confirmed_at
|
|
282
|
+
FROM enrollments e JOIN keys k USING(public_key) JOIN invites i USING(invite_id)
|
|
283
|
+
JOIN invite_details d USING(invite_id) JOIN member_profiles p ON p.member_id = e.member_id
|
|
284
|
+
LEFT JOIN owner_record_outbox o ON o.subject = e.id
|
|
285
|
+
WHERE e.confirmed_by IS NULL AND k.status = 'pending' ORDER BY e.spent_at, e.id`).all()
|
|
286
|
+
.map(r => ({ ...r, mismatch: r.kind === "enroll" && (r.display_name !== r.intended_display_name || r.platform !== r.intended_platform),
|
|
287
|
+
...(r.kind === "rebind" ? { rebind_count: G.rebindCount(db, r.member_id) } : {}) }));
|
|
288
|
+
return { members, pending_enrollments: pending };
|
|
289
|
+
}
|
|
290
|
+
case "owner-outbox":
|
|
291
|
+
return { lines: db.prepare("SELECT id, subject, line, line_sha256, created_at FROM owner_record_outbox WHERE confirmed_at IS NULL ORDER BY created_at, id LIMIT ?")
|
|
292
|
+
.all(PAGE).map(r => ({ ...r })) };
|
|
293
|
+
default:
|
|
294
|
+
throw new RelayError("not-found", 404);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- the handler ------------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
function bucket({ capacity, perSecond }) { return { capacity, perSecond, tokens: capacity, at: null }; }
|
|
301
|
+
function refill(b, now) {
|
|
302
|
+
if (b.at === null) b.at = now;
|
|
303
|
+
else if (now > b.at) { b.tokens = Math.min(b.capacity, b.tokens + ((now - b.at) * b.perSecond) / 1000); b.at = now; }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The request handler and the sweep tick for one relay database. `clock()` is relay time in ms
|
|
308
|
+
* (Date.now, or the test-mode clock file). `barrier(name, signal)` is the test-mode hook or null.
|
|
309
|
+
* `accessLog(entry)` receives one source-metadata entry per request (7.6).
|
|
310
|
+
*/
|
|
311
|
+
export function createRelayHandler({ db, aud, relayKeySeedHex, clock = Date.now, barrier = null, accessLog = () => {}, onError = () => {}, mode = "test" }) {
|
|
312
|
+
if (mode === "production") {
|
|
313
|
+
if (!isProductionAud(aud)) throw new StartRefused(["aud must be a canonical https origin (not the L1 test aud, not a .test host)"]);
|
|
314
|
+
if (barrier !== null) throw new StartRefused(["production mode takes no test hook"]);
|
|
315
|
+
} else if (mode !== "test") throw new StartRefused(["unknown mode"]);
|
|
316
|
+
else if (aud !== RELAY_TEST_AUD) throw new StartRefused([`aud must be exactly ${RELAY_TEST_AUD} (L1)`]);
|
|
317
|
+
const production = mode === "production";
|
|
318
|
+
if (typeof relayKeySeedHex !== "string" || !/^[0-9a-f]{64}$/.test(relayKeySeedHex)) throw new RelayError("relay-key", 500, "the relay key seed must be 64 lower-case hex");
|
|
319
|
+
const publicKey = Buffer.from(ed25519.getPublicKey(Buffer.from(relayKeySeedHex, "hex"))).toString("hex");
|
|
320
|
+
const members = new Map();
|
|
321
|
+
const global = bucket(RATE_LIMITS.global);
|
|
322
|
+
const polls = new Map();
|
|
323
|
+
let pollsTotal = 0;
|
|
324
|
+
const ipAttempts = new Map(), ipInFlight = new Map();
|
|
325
|
+
/** 5.2 step 1: per-IP caps on the enrollment flows. Returns a refusal, or a release function. */
|
|
326
|
+
function ipGate(ip, now) {
|
|
327
|
+
if (typeof ip !== "string" || isIP(ip) === 0) return { refusal: { status: 400, body: { error: "invalid-source" } } };
|
|
328
|
+
const recent = (ipAttempts.get(ip) ?? []).filter(t => t > now - ENROLL_LIMITS.windowMs);
|
|
329
|
+
const inFlight = ipInFlight.get(ip) ?? 0;
|
|
330
|
+
if (recent.length >= ENROLL_LIMITS.perIpPerHour || inFlight >= ENROLL_LIMITS.concurrentPerIp) {
|
|
331
|
+
ipAttempts.set(ip, recent);
|
|
332
|
+
return { refusal: { status: 429, body: { error: "rate-limited" } } };
|
|
333
|
+
}
|
|
334
|
+
recent.push(now);
|
|
335
|
+
ipAttempts.set(ip, recent);
|
|
336
|
+
ipInFlight.set(ip, inFlight + 1);
|
|
337
|
+
let released = false;
|
|
338
|
+
return { release() { if (released) return; released = true; const n = (ipInFlight.get(ip) ?? 1) - 1; if (n > 0) ipInFlight.set(ip, n); else ipInFlight.delete(ip); } };
|
|
339
|
+
}
|
|
340
|
+
const wakers = new Set();
|
|
341
|
+
const stop = new AbortController();
|
|
342
|
+
const wake = () => { for (const w of [...wakers]) w(); };
|
|
343
|
+
const pause = (ms) => new Promise((resolve) => {
|
|
344
|
+
const done = () => { clearTimeout(timer); wakers.delete(done); resolve(); };
|
|
345
|
+
const timer = setTimeout(done, ms);
|
|
346
|
+
wakers.add(done);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
function takeTokens(memberId, now) {
|
|
350
|
+
let m = members.get(memberId);
|
|
351
|
+
if (!m) members.set(memberId, m = bucket(RATE_LIMITS.member));
|
|
352
|
+
refill(m, now); refill(global, now);
|
|
353
|
+
if (m.tokens < 1 || global.tokens < 1) return false;
|
|
354
|
+
m.tokens -= 1; global.tokens -= 1;
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** GET /v1/inbox?after=&wait= (11.5): one page; with wait > 0 and nothing to read, hold up to `wait` seconds. */
|
|
359
|
+
async function inbox(p, q, now, res) {
|
|
360
|
+
const wait = q.wait ?? 0;
|
|
361
|
+
if (wait > LONG_POLL.maxWaitS) throw new RelayError("invalid-query", 400, "wait");
|
|
362
|
+
let page = readInbox(db, p.id, { after: q.after }, now);
|
|
363
|
+
if (page.records.length || wait === 0) return page;
|
|
364
|
+
const mine = polls.get(p.id) ?? 0;
|
|
365
|
+
if (mine >= LONG_POLL.perMember || pollsTotal >= LONG_POLL.total) throw new RelayError("long-poll-limit", 429);
|
|
366
|
+
polls.set(p.id, mine + 1); pollsTotal += 1;
|
|
367
|
+
let gone = false;
|
|
368
|
+
const onClose = () => { gone = true; wake(); };
|
|
369
|
+
res.once("close", onClose);
|
|
370
|
+
try {
|
|
371
|
+
const deadline = Date.now() + wait * 1000;
|
|
372
|
+
while (!gone && !stop.signal.aborted && Date.now() < deadline) {
|
|
373
|
+
await pause(Math.min(1000, deadline - Date.now()));
|
|
374
|
+
// 6.1 step 8: status (and the freeze) are read again before a long-poll returns a page.
|
|
375
|
+
if (G.isFrozen(db)) throw new RelayError("frozen", 503);
|
|
376
|
+
page = readInbox(db, p.id, { after: q.after }, clock());
|
|
377
|
+
if (page.records.length) return page;
|
|
378
|
+
}
|
|
379
|
+
if (G.isFrozen(db)) throw new RelayError("frozen", 503);
|
|
380
|
+
return readInbox(db, p.id, { after: q.after }, clock());
|
|
381
|
+
} finally {
|
|
382
|
+
res.off("close", onClose);
|
|
383
|
+
pollsTotal -= 1;
|
|
384
|
+
const left = (polls.get(p.id) ?? 1) - 1;
|
|
385
|
+
if (left > 0) polls.set(p.id, left); else polls.delete(p.id);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function memberRoute(route, p, body, q, now, res) {
|
|
390
|
+
switch (route.path) {
|
|
391
|
+
case "/v1/status": return ok({ member_id: p.id, status: p.status, role: p.role ?? null, epoch: relayEpoch(db) });
|
|
392
|
+
case "/v1/seed": return ok(signSeedBundle(seedBundleBody(db, p.id, { aud, relayPublicKey: publicKey, now }), relayKeySeedHex));
|
|
393
|
+
case "/v1/claims": return ok({ claims: ownClaims(db, p.id, now) });
|
|
394
|
+
case "/v1/board": return ok({ tasks: boardView(db, p.id, now) });
|
|
395
|
+
case "/v1/inbox": return ok(await inbox(p, q, now, res));
|
|
396
|
+
case "/v1/inbox/ack":
|
|
397
|
+
for (const key of Object.keys(body)) if (key !== "through") throw new RelayError("unknown-field", 400, key);
|
|
398
|
+
return ok(ackInbox(db, p.id, body.through, now));
|
|
399
|
+
default: // POST /v1/records/<type>; admitRequest has checked that <type> is the record's type
|
|
400
|
+
return ok(handleMemberRecord(db, p.id, body, now));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function serve(req, res, meta) {
|
|
405
|
+
const now = clock();
|
|
406
|
+
meta.at = now;
|
|
407
|
+
// Spec 11.3: behind the reverse proxy the source is X-Real-IP. A production request without a
|
|
408
|
+
// valid one is refused here, before the sweep and before anything else: it reads and writes nothing.
|
|
409
|
+
if (production && meta.ip === null) return { status: 400, body: { error: "invalid-source" }, close: true };
|
|
410
|
+
// 11.3: a sweep runs on every request that passes the source check (in test mode, every request; the
|
|
411
|
+
// test-mode source is the socket address, which is never missing), and every 15 s on the timer.
|
|
412
|
+
runSweep(db, now);
|
|
413
|
+
const raw = typeof req.url === "string" ? req.url : "";
|
|
414
|
+
const qi = raw.indexOf("?");
|
|
415
|
+
const path = qi < 0 ? raw : raw.slice(0, qi), query = qi < 0 ? "" : raw.slice(qi + 1);
|
|
416
|
+
meta.path = clip(path, 128);
|
|
417
|
+
const headers = req.headers;
|
|
418
|
+
const cl = headers["content-length"];
|
|
419
|
+
const contentLength = Object.hasOwn(headers, "transfer-encoding") ? NaN
|
|
420
|
+
: cl === undefined ? 0 : /^(0|[1-9][0-9]{0,9})$/.test(cl) ? Number(cl) : NaN;
|
|
421
|
+
const c = classifyRequest({ method: req.method, path, headers, contentLength });
|
|
422
|
+
// 5.2 step 1, after the browser-origin refusal and before the size cap: the per-IP caps.
|
|
423
|
+
if ((c.ok && c.route.auth === "invite") || (c.status === 413 && req.method === "POST" && ENROLL_PATHS.has(path))) {
|
|
424
|
+
const gate = ipGate(meta.ip, now);
|
|
425
|
+
if (gate.refusal) return { ...gate.refusal, close: contentLength !== 0 };
|
|
426
|
+
meta.release = gate.release;
|
|
427
|
+
}
|
|
428
|
+
if (!c.ok) return { status: c.status, body: c.body, close: contentLength !== 0 };
|
|
429
|
+
const route = c.route;
|
|
430
|
+
meta.type = route.recordType ?? route.view ?? route.flow ?? null;
|
|
431
|
+
const bytes = contentLength > 0 ? await readBody(req, route.cap) : Buffer.alloc(0);
|
|
432
|
+
meta.size = bytes.length;
|
|
433
|
+
let body;
|
|
434
|
+
if (route.method === "POST") {
|
|
435
|
+
if (!bytes.length) throw new RelayError("missing-body", 400);
|
|
436
|
+
body = parseJson(bytes);
|
|
437
|
+
}
|
|
438
|
+
if (route.auth === "invite") {
|
|
439
|
+
if (isNonce(body.nonce)) meta.requestNonce = body.nonce; // 6.2: the response binds the request's nonce
|
|
440
|
+
if (barrier) await barrier("before-dispatch", stop.signal);
|
|
441
|
+
const out = ok(applyEnrollmentRequest(db, route.flow, body, { aud, now, sourceIp: meta.ip }));
|
|
442
|
+
if (barrier) await barrier("after-commit", stop.signal);
|
|
443
|
+
return out;
|
|
444
|
+
}
|
|
445
|
+
const a = admitRequest(db, route, { method: req.method, path, query, headers, body }, { aud, now });
|
|
446
|
+
if (!a.ok) return a;
|
|
447
|
+
const p = a.principal;
|
|
448
|
+
meta.principal = p.id; meta.kind = p.kind;
|
|
449
|
+
if (p.kind === "member" && !takeTokens(p.id, now)) return { status: 429, body: { error: "rate-limited" } };
|
|
450
|
+
if (p.kind === "member" && route.path !== "/v1/status" && G.isFrozen(db)) throw new RelayError("frozen", 503);
|
|
451
|
+
const q = parseQuery(route.path, query);
|
|
452
|
+
if (barrier) await barrier("before-dispatch", stop.signal);
|
|
453
|
+
const out = p.kind === "member" ? await memberRoute(route, p, body, q, now, res)
|
|
454
|
+
: route.view ? ok(fleetView(db, route.view, q)) : ok(fleetRecord(db, route.recordType, body, now, { aud, publicKey }));
|
|
455
|
+
if (route.method === "POST") wake();
|
|
456
|
+
if (barrier) await barrier("after-commit", stop.signal);
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function refusal(error) {
|
|
461
|
+
if (error instanceof RelayError) {
|
|
462
|
+
// 6.3: every principal failure is the uniform 401; staleness is principal-independent and keeps its code.
|
|
463
|
+
if (error.status === 401) return error.code === "stale" ? { status: 401, body: { error: "stale" } } : { ...UNAUTHORIZED };
|
|
464
|
+
if (error.status >= 500 && error.status !== 503 && error.status !== 507) { onError(error); return { status: 500, body: { error: "internal" } }; }
|
|
465
|
+
const detail = error.message.startsWith(`${error.code}: `) ? error.message.slice(error.code.length + 2, error.code.length + 202) : null;
|
|
466
|
+
return { status: error.status, body: detail ? { error: error.code, detail } : { error: error.code } };
|
|
467
|
+
}
|
|
468
|
+
// A 9.3 trigger refusal that the handler did not name itself: its RAISE code, as a 409.
|
|
469
|
+
if (error?.code === "ERR_SQLITE_ERROR" && error.errcode === 1811 && /^[a-z0-9][a-z0-9-]{1,63}$/.test(error.message)) return { status: 409, body: { error: error.message } };
|
|
470
|
+
onError(error);
|
|
471
|
+
return { status: 500, body: { error: "internal" } };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function send(res, reply, requestNonce, meta) {
|
|
475
|
+
let { status, body } = reply;
|
|
476
|
+
let bytes, sig;
|
|
477
|
+
try {
|
|
478
|
+
bytes = Buffer.from(jcs(body), "utf8");
|
|
479
|
+
sig = signRelayResponse(relayKeySeedHex, { aud, requestNonce, status, body });
|
|
480
|
+
} catch (error) {
|
|
481
|
+
onError(error);
|
|
482
|
+
status = 500; body = { error: "internal" };
|
|
483
|
+
bytes = Buffer.from(jcs(body), "utf8");
|
|
484
|
+
sig = signRelayResponse(relayKeySeedHex, { aud, requestNonce, status, body });
|
|
485
|
+
}
|
|
486
|
+
meta.status = status;
|
|
487
|
+
try { accessLog({ at: meta.at, ip: meta.ip, user_agent: meta.user_agent, method: meta.method, path: meta.path, status, principal: meta.principal, kind: meta.kind, type: meta.type, size: meta.size }); }
|
|
488
|
+
catch (error) { onError(error); }
|
|
489
|
+
if (res.destroyed || res.writableEnded) return;
|
|
490
|
+
res.writeHead(status, {
|
|
491
|
+
"content-type": "application/json", "content-length": bytes.length, "cache-control": "no-store",
|
|
492
|
+
[RELAY_SIG_HEADER]: sig, ...(reply.close ? { connection: "close" } : {}),
|
|
493
|
+
});
|
|
494
|
+
res.end(bytes);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function handle(req, res) {
|
|
498
|
+
const meta = { at: null, ip: production ? proxiedSourceIp(req) : req.socket?.remoteAddress ?? null, user_agent: clip(req.headers["user-agent"]), method: clip(req.method, 16),
|
|
499
|
+
path: null, status: 0, principal: null, kind: null, type: null, size: 0 };
|
|
500
|
+
// serve() may set meta.requestNonce (an enrollment body's nonce) and meta.release (the per-IP slot).
|
|
501
|
+
meta.requestNonce = requestNonceOf(req.headers);
|
|
502
|
+
meta.release = null;
|
|
503
|
+
let reply;
|
|
504
|
+
try { reply = await serve(req, res, meta); }
|
|
505
|
+
catch (error) { reply = { ...refusal(error), close: error?.code === "too-large" }; }
|
|
506
|
+
finally { meta.release?.(); }
|
|
507
|
+
send(res, reply, meta.requestNonce, meta);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** The timed sweep (11.3, every 15 s): leases and enrollment expiry, decision windows, mailbox retention. */
|
|
511
|
+
function tick() {
|
|
512
|
+
const now = clock();
|
|
513
|
+
runSweep(db, now);
|
|
514
|
+
G.settleAll(db, now);
|
|
515
|
+
pruneMailbox(db, now);
|
|
516
|
+
for (const [ip, times] of ipAttempts) { const recent = times.filter(t => t > now - ENROLL_LIMITS.windowMs); if (recent.length) ipAttempts.set(ip, recent); else ipAttempts.delete(ip); }
|
|
517
|
+
wake();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return Object.freeze({ handle, tick, publicKey, mode, close() { stop.abort(); wake(); } });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Start a relay on an allowed bind (checkStart first). `db` is an open relay database (roster and
|
|
525
|
+
* policy loaded). Returns { url, port, host, publicKey, testMode, tick, close }. The sweep timer is
|
|
526
|
+
* armed at SWEEP_INTERVAL_MS through `scheduler` (default: the global timers).
|
|
527
|
+
*/
|
|
528
|
+
export async function startRelay({ db, aud, host, port = 0, testMode = false, testDir, testInterface, relayKeySeedHex, accessLog, onError,
|
|
529
|
+
scheduler = { setInterval, clearInterval }, mode = "test" }) {
|
|
530
|
+
checkStart({ aud, host, testInterface, mode });
|
|
531
|
+
// Security invariant: production mode never takes a test hook, whatever the caller passes.
|
|
532
|
+
if (mode === "production" && (testMode !== false || testDir !== undefined)) throw new StartRefused(["production mode takes no test mode or test directory"]);
|
|
533
|
+
if (!testMode && testDir !== undefined) throw new StartRefused(["--test-dir needs --test-mode"]);
|
|
534
|
+
let hooks = null;
|
|
535
|
+
if (testMode) {
|
|
536
|
+
const { openTestHooks } = await import("./test-mode.mjs");
|
|
537
|
+
try { hooks = openTestHooks(testDir); } catch (error) { throw new StartRefused([error.message]); }
|
|
538
|
+
}
|
|
539
|
+
const relay = createRelayHandler({ db, aud, relayKeySeedHex, clock: hooks ? hooks.now : Date.now, barrier: hooks ? hooks.barrier : null, accessLog, onError, mode });
|
|
540
|
+
const server = createServer({ headersTimeout: 10000, requestTimeout: 60000, keepAliveTimeout: 5000, maxHeaderSize: 16384 }, (req, res) => {
|
|
541
|
+
relay.handle(req, res).catch((error) => { onError?.(error); res.destroy(); });
|
|
542
|
+
});
|
|
543
|
+
server.maxHeadersCount = 64;
|
|
544
|
+
await new Promise((resolve, reject) => { server.once("error", reject); server.listen({ host, port, exclusive: true }, () => { server.off("error", reject); resolve(); }); });
|
|
545
|
+
const address = server.address();
|
|
546
|
+
if (address.address !== host) { server.close(); throw new StartRefused([`bound ${address.address}, not ${host}`]); }
|
|
547
|
+
const timer = scheduler.setInterval(() => { try { relay.tick(); } catch (error) { onError?.(error); } }, SWEEP_INTERVAL_MS);
|
|
548
|
+
timer?.unref?.();
|
|
549
|
+
return Object.freeze({
|
|
550
|
+
url: `http://${address.family === "IPv6" ? `[${address.address}]` : address.address}:${address.port}`,
|
|
551
|
+
port: address.port, host: address.address, publicKey: relay.publicKey, testMode: hooks !== null, mode, tick: relay.tick,
|
|
552
|
+
async close() {
|
|
553
|
+
scheduler.clearInterval(timer);
|
|
554
|
+
relay.close();
|
|
555
|
+
await new Promise((resolve) => { server.close(() => resolve()); server.closeAllConnections(); });
|
|
556
|
+
},
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// --- command line (the TEST entrypoint) ----------------------------------------------------------
|
|
561
|
+
// The production entrypoint is serve.mjs (`agent-commons relay serve`); this one refuses any aud but the
|
|
562
|
+
// test aud (checkStart, mode "test").
|
|
563
|
+
// node src/relay/server.mjs --aud <aud> --bind <ip> [--port <n>] [--test-interface <name>]
|
|
564
|
+
// [--test-mode --test-dir <abs dir>] --db <relay*.db> --roster <file> --policy <file>
|
|
565
|
+
// --owner-public-key <64 hex> --relay-key-file <file, 0600, 64-hex seed> [--access-log <file>]
|
|
566
|
+
|
|
567
|
+
const FLAGS = Object.freeze({ "--test-mode": false, "--test-dir": true, "--test-interface": true, "--aud": true, "--bind": true, "--port": true,
|
|
568
|
+
"--db": true, "--roster": true, "--policy": true, "--owner-public-key": true, "--relay-key-file": true, "--access-log": true });
|
|
569
|
+
|
|
570
|
+
export function parseArgs(argv) {
|
|
571
|
+
const out = {};
|
|
572
|
+
for (let i = 0; i < argv.length; i++) {
|
|
573
|
+
const flag = argv[i];
|
|
574
|
+
if (!Object.hasOwn(FLAGS, flag)) throw new StartRefused([`unknown argument ${JSON.stringify(String(flag).slice(0, 40))}`]);
|
|
575
|
+
const key = flag.slice(2);
|
|
576
|
+
if (Object.hasOwn(out, key)) throw new StartRefused([`${flag} given twice`]);
|
|
577
|
+
if (!FLAGS[flag]) { out[key] = true; continue; }
|
|
578
|
+
if (i + 1 >= argv.length) throw new StartRefused([`${flag} needs a value`]);
|
|
579
|
+
out[key] = argv[++i];
|
|
580
|
+
}
|
|
581
|
+
return out;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** A private regular file of this user (not a symlink, no group or other access). */
|
|
585
|
+
export function readPrivateFile(path, what) {
|
|
586
|
+
let st;
|
|
587
|
+
try { st = lstatSync(path); } catch { throw new StartRefused([`${what} missing`]); }
|
|
588
|
+
if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o077) !== 0 || (typeof process.getuid === "function" && st.uid !== process.getuid()))
|
|
589
|
+
throw new StartRefused([`${what} must be a regular file of this user with mode 0600`]);
|
|
590
|
+
return readFileSync(path, "utf8");
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** The command line. Exit status 2 on any start refusal, before anything listens. */
|
|
594
|
+
export async function main(argv = process.argv.slice(2), { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
595
|
+
let relay, db;
|
|
596
|
+
try {
|
|
597
|
+
const o = parseArgs(argv);
|
|
598
|
+
// The start check first: nothing is read (no file, no key) before the aud and the bind pass.
|
|
599
|
+
checkStart({ aud: o.aud, host: o.bind, testInterface: o["test-interface"] });
|
|
600
|
+
const port = o.port === undefined ? 0 : /^(0|[1-9][0-9]{0,4})$/.test(o.port) && Number(o.port) <= 65535 ? Number(o.port) : NaN;
|
|
601
|
+
if (Number.isNaN(port)) throw new StartRefused(["--port must be 0-65535"]);
|
|
602
|
+
for (const k of ["db", "roster", "policy", "owner-public-key", "relay-key-file"]) if (typeof o[k] !== "string") throw new StartRefused([`--${k} is required`]);
|
|
603
|
+
let clock = Date.now;
|
|
604
|
+
if (o["test-mode"]) {
|
|
605
|
+
const { openTestHooks } = await import("./test-mode.mjs");
|
|
606
|
+
try { clock = openTestHooks(o["test-dir"]).now; } catch (error) { throw new StartRefused([error.message]); }
|
|
607
|
+
} else if (o["test-dir"] !== undefined) throw new StartRefused(["--test-dir needs --test-mode"]);
|
|
608
|
+
const seed = readPrivateFile(o["relay-key-file"], "--relay-key-file").trim();
|
|
609
|
+
if (!/^[0-9a-f]{64}$/.test(seed)) throw new StartRefused(["--relay-key-file must hold one 64-hex key seed"]);
|
|
610
|
+
const roster = JSON.parse(readFileSync(o.roster, "utf8")), policy = JSON.parse(readFileSync(o.policy, "utf8"));
|
|
611
|
+
db = openRelay({ path: o.db, roster, policy, ownerPublicKey: o["owner-public-key"], now: clock() });
|
|
612
|
+
const log = o["access-log"];
|
|
613
|
+
const accessLog = (entry) => {
|
|
614
|
+
const line = `${JSON.stringify(entry)}\n`;
|
|
615
|
+
if (log) appendFileSync(log, line, { mode: 0o600 }); else stderr.write(`access ${line}`);
|
|
616
|
+
};
|
|
617
|
+
relay = await startRelay({ db, aud: o.aud, host: o.bind, port, testMode: o["test-mode"] === true, testDir: o["test-dir"], testInterface: o["test-interface"],
|
|
618
|
+
relayKeySeedHex: seed, accessLog, onError: (error) => stderr.write(`relay error: ${String(error?.stack ?? error).slice(0, 2000)}\n`) });
|
|
619
|
+
} catch (error) {
|
|
620
|
+
stderr.write(`${error instanceof StartRefused ? error.message : `relay start refused: ${String(error?.message ?? error).slice(0, 500)}`}\n`);
|
|
621
|
+
try { db?.close(); } catch { /* already closed */ }
|
|
622
|
+
process.exitCode = 2;
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
stdout.write(`${JSON.stringify({ listening: relay.url, relay_public_key: relay.publicKey, test_mode: relay.testMode })}\n`);
|
|
626
|
+
const shutdown = async () => { await relay.close(); db.close(); process.exit(0); };
|
|
627
|
+
process.once("SIGTERM", shutdown);
|
|
628
|
+
process.once("SIGINT", shutdown);
|
|
629
|
+
return relay;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const invoked = (() => { try { return process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; } catch { return false; } })();
|
|
633
|
+
if (invoked) await main();
|