@twinsearth/w2m-dsh-plugin 1.9.17 → 1.9.19

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.
@@ -1,2039 +1,2044 @@
1
- import { guardBlobRoute, guardResultEnvelope, guardTaskBody } from './redline-guard.mjs';
2
- /**
3
- * W2M Rabbit relay — node:http / node:https server.
4
- *
5
- * PROTOCOL.md §1 endpoints, §3 SSE, §7 errors
6
- * PROTOCOL-v0.1.2.md §3 deployment params, §4 /healthz, §5 operator token,
7
- * §6 pair rate limit, §7 persistence, §8 SSE hardening.
8
- *
9
- * Zero third-party dependencies: only `node:` builtins.
10
- */
11
-
12
- import http from 'node:http';
13
- import https from 'node:https';
14
- import { createRequire } from 'node:module';
15
-
16
- /** The relay's own version, from the package it was installed from (W2M_RELAY_VERSION overrides). */
17
- function readRelayVersion() {
18
- try {
19
- const pkg = createRequire(import.meta.url)('../../package.json');
20
- if (typeof pkg?.version === 'string' && pkg.version !== '') return pkg.version;
21
- } catch { /* packaged without package.json */ }
22
- return process.env.W2M_RELAY_VERSION ?? 'unknown';
23
- }
24
- export const RELAY_VERSION = readRelayVersion();
25
-
26
- /**
27
- * Capabilities a dispatcher may rely on. A relay that does not advertise a feature is an older
28
- * relay: joint-dev must refuse rather than assume the field will be honoured.
29
- */
30
- export const RELAY_FEATURES = Object.freeze([
31
- 'target-machines',
32
- 'result-projection',
33
- 'guard-v1',
1
+ import { guardBlobRoute, guardResultEnvelope, guardTaskBody } from './redline-guard.mjs';
2
+ /**
3
+ * W2M Rabbit relay — node:http / node:https server.
4
+ *
5
+ * PROTOCOL.md §1 endpoints, §3 SSE, §7 errors
6
+ * PROTOCOL-v0.1.2.md §3 deployment params, §4 /healthz, §5 operator token,
7
+ * §6 pair rate limit, §7 persistence, §8 SSE hardening.
8
+ *
9
+ * Zero third-party dependencies: only `node:` builtins.
10
+ */
11
+
12
+ import http from 'node:http';
13
+ import https from 'node:https';
14
+ import { createRequire } from 'node:module';
15
+
16
+ /** The relay's own version, from the package it was installed from (W2M_RELAY_VERSION overrides). */
17
+ function readRelayVersion() {
18
+ try {
19
+ const pkg = createRequire(import.meta.url)('../../package.json');
20
+ if (typeof pkg?.version === 'string' && pkg.version !== '') return pkg.version;
21
+ } catch { /* packaged without package.json */ }
22
+ return process.env.W2M_RELAY_VERSION ?? 'unknown';
23
+ }
24
+ export const RELAY_VERSION = readRelayVersion();
25
+
26
+ /**
27
+ * Capabilities a dispatcher may rely on. A relay that does not advertise a feature is an older
28
+ * relay: joint-dev must refuse rather than assume the field will be honoured.
29
+ */
30
+ export const RELAY_FEATURES = Object.freeze([
31
+ 'target-machines',
32
+ 'result-projection',
33
+ 'guard-v1',
34
34
  'relay-version-v1',
35
- ]);
36
- import fs from 'node:fs';
37
- import os from 'node:os';
38
- import path from 'node:path';
39
- import { randomBytes } from 'node:crypto';
40
-
41
- import {
42
- MAX_FRAME_BYTES,
43
- PROTOCOL_VERSION,
44
- ProtocolError,
45
- RabbitState,
46
- rfc3339,
47
- } from './state.mjs';
48
-
49
- // v1.2.7: the content-addressed blob channel carries ARTIFACTS, so its frame limit cannot be the JSON frame limit.
50
- //
51
- // MAX_FRAME_BYTES (64 KiB) is exactly the inline threshold: any blob is larger than an inline result by construction, so with
52
- // one shared limit the blob channel could never accept a single byte -- a pipeline blob upload from a real relay failed with
53
- // FRAME_TOO_LARGE while the fake relay in the tests accepted it. The dataflow hard cap stays the agent-side bound; this is the
54
- // transport bound, and it is still bounded.
55
- export const BLOB_FRAME_BYTES = 16 * 1024 * 1024; // 16 MiB per blob frame -- the same cap the agent's dataflow plan uses.
56
- //
57
- // v1.2.8-beta.1: it was 1 MiB while the agent's hard cap is 16 MiB, so an artifact between the two failed its upload with
58
- // BLOB_UPLOAD_FAILED even though the agent had accepted it. One number for "how large may one artifact be" is the honest shape;
59
- // the JSON frame limit stays 64 KiB for every other route.
60
- import { createFleetMessages } from './fleet-messages.mjs';
61
- import { can as mayAct } from '../util/policy-matrix.mjs';
62
- import { createFleetMemory } from './fleet-memory.mjs';
63
- import { createJoinChallenges } from './join-challenge.mjs';
64
- import { createAdmission } from './admission.mjs';
65
- import { Persistence } from './persistence.mjs';
66
- import { PeerRegistry } from './peers.mjs';
67
- import { aggregateTask, buildReport } from './report.mjs';
68
- import { renderMetrics, METRICS_CONTENT_TYPE } from './metrics.mjs';
69
- // v0.3.0 §9: request signing. This import is the entire point of the module --
70
- // until the HTTP layer calls it, HMAC support does not exist for any real request.
71
- import { DEFAULT_SKEW_SECONDS, NonceCache, verifyRequest } from '../signing.mjs';
72
- // v0.7.11: per-device Ed25519 verification, tried before the shared secret.
73
- import {
74
- SIGNATURE_HEADER as DEVICE_SIGNATURE_HEADER,
75
- verifyDeviceSignature,
76
- } from '../chatops/device-signing.mjs';
77
-
78
- export const DEFAULT_SSE_KEEPALIVE_MS = 15_000; // §3.2: every 15s a `: keepalive` line
79
- export const DEFAULT_SWEEP_INTERVAL_MS = 1_000;
80
- export const DEFAULT_PAIR_RATE_LIMIT_PER_MINUTE = 5; // v0.1.2 §6
81
- // The window length is a unit and lives with the limiter. Both names are kept: `DEFAULT_WINDOW_MS` is the
82
- // local binding this module uses, and `RATE_LIMIT_WINDOW_MS` is the name this module has always exported.
83
- // `export ... from` would NOT have worked here -- it re-exports without creating a local binding, so the
84
- // constructor below saw `RATE_LIMIT_WINDOW_MS is not defined` while the export itself looked fine.
85
- import { DEFAULT_WINDOW_MS, SlidingWindowRateLimiter } from '../util/rate-limit.mjs';
86
- export { DEFAULT_WINDOW_MS as RATE_LIMIT_WINDOW_MS };
87
- export const DEFAULT_STATE_DIR = path.join(os.homedir(), '.w2m');
88
-
89
- /**
90
- * The environment variable that opts in to printing the operator token on a start that did not
91
- * generate it (MEDIUM-6 of the v0.5.9 review).
92
- *
93
- * The value is printed **once, on the start that generated it**, because that is the only moment at
94
- * which it exists nowhere else yet; every other start has `<state>/operator-token.txt` to read it
95
- * from, and a relay's stdout ends up in journald, a container log or a CI transcript — where a
96
- * credential that authorises dispatching to the whole fleet does not belong. `W2M_PRINT_OPERATOR_TOKEN=1`
97
- * (or `printOperatorToken: true`, which is the same switch for an embedder) restores the old
98
- * behaviour for an operator who genuinely needs it in the log, and says so in the banner when it does.
99
- */
100
- export const PRINT_OPERATOR_TOKEN_ENV = 'W2M_PRINT_OPERATOR_TOKEN';
101
-
102
- /**
103
- * Resolve that opt-in, so the relay and the CLI agree on one answer.
104
- *
105
- * @param {object} [options] Relay options; `printOperatorToken: true` is the programmatic spelling.
106
- * @returns {'option'|'env'|null} Why printing was asked for, or null.
107
- */
108
- export function printOperatorTokenOptIn(options = {}) {
109
- if (options.printOperatorToken === true) return 'option';
110
- const raw = process.env[PRINT_OPERATOR_TOKEN_ENV];
111
- if (raw === '1' || raw === 'true') return 'env';
112
- return null;
113
- }
114
-
115
- /* ------------------------------------------------------------------ */
116
- /* helpers */
117
- /* ------------------------------------------------------------------ */
118
-
119
- /** v0.1.2 §3: `/w2m/` → `/w2m`; '' / '/' → '/'. Never returns a trailing slash. */
120
- export function normalizeBasePath(value) {
121
- if (value === undefined || value === null || value === '') return '/';
122
- let p = String(value).trim();
123
- if (p === '' || p === '/') return '/';
124
- if (!p.startsWith('/')) p = `/${p}`;
125
- p = p.replace(/\/{2,}/g, '/').replace(/\/+$/, '');
126
- return p === '' ? '/' : p;
127
- }
128
-
129
- function normalizePath(pathname) {
130
- if (!pathname) return '/';
131
- return pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname;
132
- }
133
-
134
- function sendJson(res, status, body, extraHeaders = {}) {
135
- if (res.writableEnded) return undefined;
136
- const payload = Buffer.from(JSON.stringify(body), 'utf8');
137
- res.writeHead(status, {
138
- 'content-type': 'application/json; charset=utf-8',
139
- 'content-length': payload.length,
140
- 'cache-control': 'no-store',
141
- ...extraHeaders,
142
- });
143
- return res.end(payload);
144
- }
145
-
146
- function sendError(res, err) {
147
- if (err instanceof ProtocolError) {
148
- const body = err.toBody();
149
- // §6 just says the 429 "carries retry_after_seconds" without pinning the
150
- // location, so it is emitted both inside error.detail (the §7 shape) and as a
151
- // top-level convenience field. Both are additive.
152
- const retryAfter = err.detail?.retry_after_seconds;
153
- if (typeof retryAfter === 'number') body.retry_after_seconds = retryAfter;
154
- sendJson(res, err.status, body, err.headers ?? {});
155
- return;
156
- }
157
- sendJson(res, 500, {
158
- error: {
159
- code: 'INTERNAL',
160
- message: err?.message ? String(err.message) : 'internal error',
161
- detail: {},
162
- },
163
- });
164
- }
165
-
166
- function bearerToken(req) {
167
- const header = req.headers.authorization;
168
- if (typeof header !== 'string') return null;
169
- const m = /^Bearer\s+(.+)$/i.exec(header.trim());
170
- return m ? m[1].trim() : null;
171
- }
172
-
173
- function firstHeaderValue(value) {
174
- const raw = Array.isArray(value) ? value[0] : value;
175
- if (typeof raw !== 'string') return null;
176
- const first = raw.split(',')[0].trim();
177
- return first === '' ? null : first;
178
- }
179
-
180
- /**
181
- * Read one header case-insensitively from a plain Node headers object.
182
- *
183
- * `signing.mjs` does this internally, but the device-key path needs it before deciding whether that
184
- * module applies at all, so it is needed here too. Node lower-cases incoming header names, and a
185
- * test that builds a request by hand may not, so both spellings are tried.
186
- */
187
- function readHeader(headers, name) {
188
- if (headers === null || headers === undefined) return null;
189
- const found = Object.keys(headers).find((k) => k.toLowerCase() === name);
190
- return found === undefined ? null : firstHeaderValue(headers[found]);
191
- }
192
-
193
- /**
194
- * Read a request body with a hard byte cap.
195
- * @returns {Promise<{tooLarge?:boolean, buffer?:Buffer, aborted?:boolean}>}
196
- */
197
- function readBody(req, limit) {
198
- return new Promise((resolve) => {
199
- const declared = Number(req.headers['content-length']);
200
- if (Number.isFinite(declared) && declared > limit) {
201
- req.resume();
202
- resolve({ tooLarge: true });
203
- return;
204
- }
205
- const chunks = [];
206
- let total = 0;
207
- let settled = false;
208
- const finish = (value) => {
209
- if (settled) return;
210
- settled = true;
211
- resolve(value);
212
- };
213
- req.on('data', (chunk) => {
214
- if (settled) return;
215
- total += chunk.length;
216
- if (total > limit) {
217
- finish({ tooLarge: true });
218
- req.resume();
219
- return;
220
- }
221
- chunks.push(chunk);
222
- });
223
- req.on('end', () => finish({ buffer: Buffer.concat(chunks) }));
224
- req.on('error', () => finish({ aborted: true }));
225
- req.on('aborted', () => finish({ aborted: true }));
226
- });
227
- }
228
-
229
- /**
230
- * Read the raw body bytes with a hard byte cap.
231
- *
232
- * v0.3.0 §9: the body is read exactly ONCE per request and the bytes are kept,
233
- * because an HMAC covers the exact received octets -- re-serialising parsed JSON
234
- * would produce different bytes (key order, whitespace) and break every signature.
235
- */
236
- async function readRawBody(req, limit) {
237
- const body = await readBody(req, limit);
238
- if (body.tooLarge) {
239
- throw new ProtocolError('FRAME_TOO_LARGE', `request frame exceeds ${limit} bytes`, { limit_bytes: limit });
240
- }
241
- if (body.aborted) throw new ProtocolError('BAD_REQUEST', 'request aborted before the body was complete');
242
- return body.buffer;
243
- }
244
-
245
- /** Parse an already-read body buffer as a JSON object. */
246
- function parseJsonBody(buffer) {
247
- const raw = buffer === null || buffer === undefined ? '' : buffer.toString('utf8');
248
- if (raw.trim() === '') return {};
249
- try {
250
- const parsed = JSON.parse(raw);
251
- if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
252
- throw new ProtocolError('BAD_REQUEST', 'request body must be a JSON object');
253
- }
254
- return parsed;
255
- } catch (err) {
256
- if (err instanceof ProtocolError) throw err;
257
- throw new ProtocolError('BAD_REQUEST', 'request body is not valid JSON', { parse_error: String(err.message) });
258
- }
259
- }
260
-
261
- /** Methods that can carry a body the signature must cover. */
262
- function methodHasBody(method) {
263
- return method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE';
264
- }
265
-
266
- /* ------------------------------------------------------------------ */
267
- /* request signing (v0.3.0 §9) */
268
- /* ------------------------------------------------------------------ */
269
-
270
- /**
271
- * Operator-facing hints. The `code` says what happened; the hint says what to do
272
- * about it, because "signature mismatch" without a direction is a support ticket.
273
- */
274
- const SIGNATURE_HINTS = Object.freeze({
275
- SIGNATURE_REQUIRED: 'this relay runs with --require-signature; sign the request (PROTOCOL-v0.3.0.md §9)',
276
- SIGNATURE_INCOMPLETE: 'all three of X-W2M-Signature, X-W2M-Timestamp and X-W2M-Nonce must be present together',
277
- SIGNATURE_MISMATCH: 'the signature matches no configured secret — check for a stale or half-rotated secret',
278
- SIGNATURE_EXPIRED: 'the timestamp is outside the accepted clock-skew window — check the sending machine clock',
279
- SIGNATURE_REPLAY: 'this nonce was already used — generate a fresh nonce for every request',
280
- SIGNATURE_BAD_TIMESTAMP: 'X-W2M-Timestamp must be whole unix seconds',
281
- SIGNATURE_BAD_NONCE: 'X-W2M-Nonce must be at least 8 characters',
282
- SIGNING_NOT_CONFIGURED:
283
- 'the relay has --require-signature enabled but no --signing-secret configured; '
284
- + 'this is a RELAY configuration error, not a client problem',
285
- });
286
-
287
- const SIGNATURE_PROTOCOL_CODES = new Set(Object.keys(SIGNATURE_HINTS));
288
-
289
- /** Turn a `verifyRequest` rejection into the §7 error envelope.
290
- * `ProtocolError` resolves the HTTP status from `ERROR_STATUS`, so the mapping
291
- * table lives in exactly one place (state.mjs) instead of being duplicated here. */
292
- function signatureError(decision) {
293
- const code = SIGNATURE_PROTOCOL_CODES.has(decision.code) ? decision.code : 'SIGNATURE_MISMATCH';
294
- return new ProtocolError(code, decision.error, {
295
- code,
296
- reason: decision.error,
297
- hint: SIGNATURE_HINTS[code] ?? null,
298
- });
299
- }
300
-
301
- /**
302
- * Read an optional secret, failing loudly on a value that is present but unusable.
303
- *
304
- * `signing.mjs` calls a silently-disabled secret the failure mode it exists to
305
- * prevent. Treating `signingSecret: 12345` (or `{}`) as "not configured" would do
306
- * exactly that at the wiring layer, so it is an error instead.
307
- */
308
- function readSecretValue(value, label) {
309
- if (value === undefined || value === null || value === '') return null;
310
- if (typeof value !== 'string') {
311
- throw new ProtocolError('BAD_REQUEST',
312
- `${label} must be a non-empty string when provided (got ${typeof value}); `
313
- + 'refusing to silently run without the signing you asked for', { option: label, received_type: typeof value });
314
- }
315
- return value;
316
- }
317
-
318
- /* ------------------------------------------------------------------ */
319
- /* pair rate limiting (v0.1.2 §6) */
320
- /* ------------------------------------------------------------------ */
321
-
322
- /**
323
- * Per-IP sliding window. Successful pairs count too, otherwise a working pairing
324
- * code could be used to flush the failure budget.
325
- */
326
- /*
327
- * The limiter MOVED to `src/util/rate-limit.mjs` in v0.30.0.
328
- *
329
- * It never used anything from this file, and when the approval gate needed the same window, importing it
330
- * from here made an `agent` module depend on the `relay` layer -- which `scripts/layer-model.mjs` refused,
331
- * correctly. `agent` is rank 2 and `relay` is rank 3, so the edge points up; moving the class to `util`
332
- * (rank 0, a leaf) lets both depend on it in the only direction the model allows.
333
- *
334
- * Re-exported rather than merely imported, because `SlidingWindowRateLimiter` and `RATE_LIMIT_WINDOW_MS` are
335
- * part of this module's published surface and `test/relay.test.mjs` imports the class from here.
336
- */
337
- export { SlidingWindowRateLimiter };
338
-
339
- /* ------------------------------------------------------------------ */
340
- /* server */
341
- /* ------------------------------------------------------------------ */
342
-
343
- export class RelayServer {
344
- constructor(options = {}) {
345
- this.options = options;
346
- this.basePath = normalizeBasePath(options.basePath);
347
- this.trustProxy = options.trustProxy === true;
348
- this.frameLimitBytes = options.frameLimitBytes ?? MAX_FRAME_BYTES;
349
- this.keepaliveMs = options.keepaliveMs ?? DEFAULT_SSE_KEEPALIVE_MS;
350
- this.sweepIntervalMs = options.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
351
- this.pairRateLimitPerMinute = Number.isFinite(options.pairRateLimitPerMinute)
352
- ? options.pairRateLimitPerMinute : DEFAULT_PAIR_RATE_LIMIT_PER_MINUTE;
353
-
354
- // §3: `logger: null` silences. The default writes to STDERR on purpose —
355
- // stdout belongs to protocol output (e.g. the CLI's `--json` line), and mixing
356
- // prose into it breaks `| ConvertFrom-Json` / `| jq`.
357
- this.logger = options.logger === undefined
358
- ? ((msg) => process.stderr.write(`${msg}\n`))
359
- : options.logger;
360
- this.startupMessages = [];
361
-
362
- /* ---- persistence (§7) ---- */
363
- const rawStateDir = options.stateDir ?? (typeof options.state === 'string' ? options.state : null);
364
- const persistEnabled = options.persist !== false && rawStateDir !== '';
365
- const stateDir = persistEnabled
366
- ? path.resolve(rawStateDir === null || rawStateDir === undefined ? DEFAULT_STATE_DIR : String(rawStateDir))
367
- : null;
368
- this.persistence = new Persistence({
369
- dir: stateDir,
370
- now: () => (this.state ? this.state.nowMs() : Date.now()),
371
- });
372
-
373
- /* ---- state ---- */
374
- const injected = options.state && typeof options.state === 'object' ? options.state : null;
375
- this.state = injected ?? new RabbitState({ ...options, persistence: this.persistence });
376
- this.state.setPersistence(this.persistence);
377
-
378
- // v0.3.9: P2P signalling. Ephemeral by design -- see src/relay/peers.mjs.
379
- this.peers = options.peers ?? new PeerRegistry({ nowMs: () => this.state.nowMs() });
380
-
381
- this.relayId = this.state.relayId;
382
- this._startedAtMs = this.state.nowMs();
383
-
384
- /* ---- startup recovery (§7) ---- */
385
- if (this.persistence.enabled) {
386
- const snapshot = this.persistence.loadDevices();
387
- this.persistence.devicesMissing = snapshot.missing;
388
- const ledger = this.persistence.loadLedger();
389
- const revived = this.state.restore({ devices: snapshot.devices, ledger });
390
- this.persistence.revivedDevices = revived.revived_devices;
391
- this.persistence.revivedTasks = revived.revived_tasks;
392
- }
393
-
394
- /* ---- operator token (§5) ---- */
395
- this._initOperatorToken(options);
396
-
397
- /* ---- v0.3.0 §9 request signing ---- */
398
- this.signingSecrets = [
399
- readSecretValue(options.signingSecret, 'signingSecret'),
400
- readSecretValue(options.signingSecretPrevious, 'signingSecretPrevious'),
401
- ].filter((s) => s !== null); // newest first: keyIndex 0 = current, 1 = previous
402
- this.signingConfigured = this.signingSecrets.length > 0;
403
- this.requireSignature = options.requireSignature === true;
404
- /**
405
- * v0.3.3: whether `GET /metrics` exists.
406
- *
407
- * Defaults to off. A monitoring endpoint that appears without being asked for is a surface nobody
408
- * chose, and the operator can turn it on with `--metrics` when a scraper exists.
409
- */
410
- this.metricsEnabled = options.metrics === true;
411
- this.signatureSkewSeconds = Number.isFinite(options.signatureSkewSeconds)
412
- ? options.signatureSkewSeconds : DEFAULT_SKEW_SECONDS;
413
- // One cache per relay instance: replay defence is stateful, and two relays
414
- // sharing a cache would let a nonce blocked on one be replayed on the other.
415
- this.nonceCache = options.nonceCache ?? new NonceCache({
416
- max: options.nonceCacheMax ?? 20_000,
417
- retentionMs: options.nonceCacheRetentionMs ?? 10 * 60_000,
418
- });
419
- this.nonceRetentionMs = options.nonceCacheRetentionMs ?? 10 * 60_000;
420
-
421
- /* ---- /v1/pair rate limit (§6) ---- */
422
- this.pairLimiter = new SlidingWindowRateLimiter({
423
- limit: this.pairRateLimitPerMinute,
424
- windowMs: DEFAULT_WINDOW_MS,
425
- now: () => this.state.nowMs(),
426
- });
427
-
428
- /* ---- transport ---- */
429
- this.tlsCertPath = options.tlsCert ?? null;
430
- this.tlsKeyPath = options.tlsKey ?? null;
431
- if ((this.tlsCertPath && !this.tlsKeyPath) || (!this.tlsCertPath && this.tlsKeyPath)) {
432
- throw new ProtocolError('BAD_REQUEST', 'tlsCert and tlsKey must be provided together', {
433
- tls_cert: this.tlsCertPath, tls_key: this.tlsKeyPath,
434
- });
435
- }
436
- const handler = (req, res) => {
437
- this.handle(req, res).catch((err) => this.reportError(req, res, err));
438
- };
439
- if (this.tlsCertPath) {
440
- let cert;
441
- let key;
442
- try {
443
- cert = fs.readFileSync(this.tlsCertPath);
444
- key = fs.readFileSync(this.tlsKeyPath);
445
- } catch (err) {
446
- // Never silently fall back to plain HTTP: that would be a security downgrade.
447
- throw new ProtocolError('BAD_REQUEST', `cannot read TLS material: ${err.message}`, {
448
- tls_cert: this.tlsCertPath, tls_key: this.tlsKeyPath,
449
- });
450
- }
451
- this.server = https.createServer({ cert, key }, handler);
452
- this.scheme = 'https';
453
- } else {
454
- this.server = http.createServer(handler);
455
- this.scheme = 'http';
456
- }
457
-
458
- // v0.1.2 BUG-1: an externally supplied code must be REGISTERED, not merely
459
- // assigned — otherwise the banner advertises a code /v1/pair does not know.
460
- this.pairingCode = typeof options.pairingCode === 'string' && options.pairingCode.trim() !== ''
461
- ? this.state.registerPairingCode(options.pairingCode, { ttlMs: options.pairingCodeTtlMs })
462
- : this.state.createPairingCode();
463
- this.subscribers = new Set();
464
- this.host = null;
465
- this.port = null;
466
- this._sweepTimer = null;
467
-
468
- // SSE connections are long-lived: never let Node time the request out.
469
- this.server.requestTimeout = options.requestTimeoutMs ?? 0;
470
- this.server.headersTimeout = options.headersTimeoutMs ?? 60_000;
471
-
472
- this._buildStartupMessages();
473
- }
474
-
475
- /**
476
- * Diagnostics sink. `logger: null` is the documented "silent" value, and a
477
- * caller-supplied logger may throw — neither may break a request. (A direct
478
- * `this.logger(...)` call after `res.writeHead()` once threw a TypeError that
479
- * surfaced as "Cannot write headers after they are sent to the client".)
480
- */
481
- _log(message) {
482
- if (typeof this.logger !== 'function') return;
483
- try {
484
- this.logger(message);
485
- } catch { /* a broken logger must never break the relay */ }
486
- }
487
-
488
- /**
489
- * Last-resort error path for a request whose handler rejected.
490
- * Once the status line is on the wire (SSE, streamed report) the response can no
491
- * longer be rewritten — the only honest action is to end the stream, loudly.
492
- */
493
- reportError(req, res, err) {
494
- if (res.headersSent) {
495
- this._log(`[w2m-rabbit] !! error after the response had started on ${req.method} ${req.url}: `
496
- + `${err?.stack ?? err}`);
497
- try { res.end(); } catch { /* already gone */ }
498
- return;
499
- }
500
- sendError(res, err);
501
- }
502
-
503
- _initOperatorToken(options) {
504
- const wantsRequired = options.operatorTokenRequired !== false;
505
- const explicit = options.operatorToken;
506
- const persisted = this.persistence.enabled ? this.persistence.readOperatorToken() : null;
507
- /**
508
- * Why the token may be printed, when it may be: `'generated'` is the one start whose token exists
509
- * nowhere else until this process writes it, and `'option'`/`'env'` are the documented opt-ins.
510
- * @type {'generated'|'option'|'env'|null}
511
- */
512
- this.operatorTokenPrintReason = printOperatorTokenOptIn(options);
513
-
514
- if (!wantsRequired || explicit === '') {
515
- this.operatorToken = null;
516
- this.operatorTokenRequired = false;
517
- this.operatorTokenSource = 'disabled';
518
- this.operatorTokenPrintable = false;
519
- this.operatorTokenPrintReason = null;
520
- return;
521
- }
522
- this.operatorTokenRequired = true;
523
- if (typeof explicit === 'string' && explicit.length > 0) {
524
- this.operatorToken = explicit;
525
- this.operatorTokenSource = 'option';
526
- this.persistence.writeOperatorToken(explicit);
527
- } else if (persisted) {
528
- // §5.1 + §7: keep the token stable across restarts, otherwise every restart
529
- // would silently invalidate the plugin's configured operatorToken.
530
- this.operatorToken = persisted;
531
- this.operatorTokenSource = 'restored';
532
- } else {
533
- this.operatorToken = randomBytes(32).toString('hex');
534
- this.operatorTokenSource = 'generated';
535
- this.persistence.writeOperatorToken(this.operatorToken);
536
- }
537
- // MEDIUM-6: the material is printed only by the start that generated it, or when the operator has
538
- // explicitly asked for it. "The posture, never the material" still holds everywhere else.
539
- if (this.operatorTokenSource === 'generated') {
540
- this.operatorTokenPrintReason = this.operatorTokenPrintReason ?? 'generated';
541
- }
542
- this.operatorTokenPrintable = this.operatorTokenPrintReason !== null;
543
- }
544
-
545
- _buildStartupMessages() {
546
- const m = this.startupMessages;
547
- m.push(`[w2m-rabbit] relay_id=${this.relayId}`);
548
- m.push(`[w2m-rabbit] base_path=${this.basePath}${this.basePath === '/' ? ' (root)' : ''}`);
549
- m.push(`[w2m-rabbit] trust_proxy=${this.trustProxy}${this.trustProxy ? '' : ' (X-Forwarded-* ignored)'}`);
550
- m.push(`[w2m-rabbit] pair_rate_limit=${this.pairRateLimitPerMinute === 0 ? 'disabled' : `${this.pairRateLimitPerMinute}/min/IP`}`);
551
- // §9: report the posture, never the material.
552
- if (this.signingConfigured) {
553
- m.push(`[w2m-rabbit] request signing=ON (${this.signingSecrets.length} secret(s)`
554
- + `${this.signingSecrets.length > 1 ? ', rotation active' : ''})`
555
- + `${this.requireSignature ? ' - REQUIRED: unsigned /v1/* is rejected with 401' : ' - optional: unsigned requests are still accepted'}`);
556
- } else if (this.requireSignature) {
557
- m.push('[w2m-rabbit] !! CONFIGURATION ERROR: --require-signature is set but no --signing-secret was provided. '
558
- + 'Every /v1/* request will fail with 500 SIGNING_NOT_CONFIGURED (this is the RELAY\'s fault, not the client\'s). '
559
- + 'Either set --signing-secret or drop --require-signature.');
560
- } else {
561
- m.push('[w2m-rabbit] request signing=off (no signing secret configured; behaviour is identical to v0.2.3)');
562
- }
563
- if (this.persistence.enabled) {
564
- m.push(`[w2m-rabbit] persistence=on dir=${this.persistence.dir} `
565
- + `revived_devices=${this.persistence.revivedDevices} revived_tasks=${this.persistence.revivedTasks}`);
566
- } else {
567
- m.push('[w2m-rabbit] persistence=off (in-memory only; devices and tasks are lost on restart)');
568
- }
569
- if (this.persistence.devicesCorrupt) {
570
- m.push('[w2m-rabbit] !! WARNING: devices.json was CORRUPT — started from an EMPTY device table; '
571
- + 'every machine must pair again. The damaged file was left untouched.');
572
- }
573
- for (const w of this.persistence.warnings) {
574
- m.push(`[w2m-rabbit] !! ${w.file}: ${w.message}`);
575
- }
576
- if (this.operatorTokenRequired) {
577
- // MEDIUM-6: the token is material, not posture, so it is printed as rarely as it can be — on the
578
- // start that generated it (`operatorTokenSource === 'generated'`) or when `printOperatorToken` /
579
- // `W2M_PRINT_OPERATOR_TOKEN=1` asked for it — and once, never twice. Where it is stored is said in
580
- // every case, including the case where there is nowhere to store it.
581
- //
582
- // DELIBERATE, do not "fix": on that one start the CLI prints the value a second time, in its own
583
- // stdout banner. Two sinks for one start is not a leak — the defect was printing it on *every*
584
- // start, restored ones included — and the box is the documented first-start handoff a human reads
585
- // (docs/DEPLOY.md §2.4, test/crossnetwork.test.mjs). Collapsing the pair would trade a documented
586
- // behaviour for one saved line on one start.
587
- const tokenFile = this.persistence.operatorTokenFile ?? null;
588
- const stored = tokenFile === null
589
- ? 'persistence is off, so this token exists only in this process and cannot be read back later'
590
- : `stored at ${tokenFile} (0600)`;
591
- if (this.operatorTokenPrintable) {
592
- m.push(`[w2m-rabbit] operator token (${this.operatorTokenSource}) - required for POST /v1/task:`);
593
- m.push(`[w2m-rabbit] ${this.operatorToken}`);
594
- m.push(`[w2m-rabbit] printed once because ${this.operatorTokenPrintReason === 'generated'
595
- ? 'this start generated it' : `${this.operatorTokenPrintReason} asked for it (${PRINT_OPERATOR_TOKEN_ENV})`}`
596
- + ` - and ${stored}`);
597
- } else {
598
- m.push(`[w2m-rabbit] operator token (${this.operatorTokenSource}) - required for POST /v1/task, NOT printed`);
599
- m.push(`[w2m-rabbit] read it from ${tokenFile ?? '<state>/operator-token.txt'} - ${stored}`);
600
- m.push(`[w2m-rabbit] set ${PRINT_OPERATOR_TOKEN_ENV}=1 to print it on this start as well`);
601
- }
602
- } else {
603
- m.push('[w2m-rabbit] !! WARNING: OPERATOR TOKEN DISABLED (--operator-token "") - '
604
- + 'any device that can reach this relay can dispatch tasks to the whole group. '
605
- + 'Only acceptable on a trusted LAN / for debugging; /v1/task then requires a device token.');
606
- }
607
- }
608
-
609
- get url() {
610
- if (this.host === null || this.port === null) return null;
611
- const host = this.host.includes(':') ? `[${this.host}]` : this.host;
612
- const prefix = this.basePath === '/' ? '' : this.basePath;
613
- return `${this.scheme}://${host}:${this.port}${prefix}`;
614
- }
615
-
616
- listen({ host = '127.0.0.1', port = 0 } = {}) {
617
- return new Promise((resolve, reject) => {
618
- const onError = (err) => reject(err);
619
- this.server.once('error', onError);
620
- this.server.listen(port, host, () => {
621
- this.server.removeListener('error', onError);
622
- const addr = this.server.address();
623
- this.host = addr.address;
624
- this.port = addr.port;
625
- this._sweepTimer = setInterval(() => {
626
- this.maintenance();
627
- }, this.sweepIntervalMs);
628
- this._sweepTimer.unref?.();
629
- if (this.logger) {
630
- this._log(`[w2m-rabbit] listening on ${this.url} pairing code: ${this.pairingCode}`);
631
- for (const message of this.startupMessages) this._log(message);
632
- }
633
- resolve({
634
- host: this.host,
635
- port: this.port,
636
- url: this.url,
637
- pairingCode: this.pairingCode,
638
- relayId: this.relayId,
639
- basePath: this.basePath,
640
- operatorToken: this.operatorToken,
641
- operatorTokenRequired: this.operatorTokenRequired,
642
- // The CLI prints its own banner, so it has to reach the same conclusion this class did rather
643
- // than re-deriving one from the token's presence (MEDIUM-6).
644
- operatorTokenSource: this.operatorTokenSource,
645
- operatorTokenPrintable: this.operatorTokenPrintable === true,
646
- operatorTokenPrintReason: this.operatorTokenPrintReason ?? null,
647
- scheme: this.scheme,
648
- });
649
- });
650
- });
651
- }
652
-
653
- sweep() {
654
- return this.state.sweepExpired();
655
- }
656
-
657
- close() {
658
- return new Promise((resolve) => {
659
- if (this._sweepTimer) clearInterval(this._sweepTimer);
660
- this._sweepTimer = null;
661
- // v0.4.0: a deferred P2P offer is a pending timer, and a closed relay must not fire one.
662
- this.state.clearPendingOffers?.();
663
- for (const sub of [...this.subscribers]) this._closeSubscriber(sub);
664
- this.server.close(() => resolve());
665
- this.server.closeAllConnections?.();
666
- });
667
- }
668
-
669
- /* ---------------- proxy awareness (v0.1.2 §3) ---------------- */
670
-
671
- /** `X-Forwarded-For` first hop is honoured ONLY under `trustProxy`. */
672
- clientIp(req) {
673
- if (this.trustProxy) {
674
- const forwarded = firstHeaderValue(req.headers['x-forwarded-for']);
675
- if (forwarded) return forwarded;
676
- }
677
- return req.socket?.remoteAddress ?? 'unknown';
678
- }
679
-
680
- /** `X-Forwarded-Proto` is honoured ONLY under `trustProxy`. */
681
- effectiveScheme(req) {
682
- if (this.trustProxy) {
683
- const proto = firstHeaderValue(req.headers['x-forwarded-proto']);
684
- if (proto) return proto.toLowerCase();
685
- }
686
- return this.scheme;
687
- }
688
-
689
- /* ---------------- request dispatch ---------------- */
690
-
691
- async handle(req, res) {
692
- let url;
693
- try {
694
- url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
695
- } catch {
696
- throw new ProtocolError('BAD_REQUEST', 'malformed request URL');
697
- }
698
- const rawPath = url.pathname;
699
- const method = (req.method ?? 'GET').toUpperCase();
700
-
701
- // §3: strip the deployment prefix, then route. `/healthz` also answers at the
702
- // root because health checks are usually pointed straight at the container.
703
- // `stripped` keeps the literal request path (no trailing-slash normalisation)
704
- // because that is what a signature covers; `path` is the normalised route key.
705
- let stripped;
706
- if (this.basePath === '/') {
707
- stripped = rawPath;
708
- } else if (rawPath === this.basePath) {
709
- stripped = '/';
710
- } else if (rawPath.startsWith(`${this.basePath}/`)) {
711
- stripped = rawPath.slice(this.basePath.length);
712
- } else if (rawPath === '/healthz') {
713
- stripped = '/healthz';
714
- } else {
715
- // Outside the prefix: an explicit 404, never a gateway-style 502.
716
- throw new ProtocolError('NOT_FOUND', `path is outside the configured base path ${this.basePath}`, {
717
- path: rawPath, base_path: this.basePath,
718
- });
719
- }
720
- const path = normalizePath(stripped);
721
- // B3/V12:basePath 剥离与 URL 规范化之后立即拒绝数据面 blob 路由,
722
- // 覆盖 `..`、`%2e%2e`、basePath 前缀等变形;在读取 body 之前返回。
723
- // Percent-encoded dot segments are not resolved by WHATWG URL, so the gate resolves them itself:
724
- const gatePath = (() => {
725
- try { return normalizePath(new URL(decodeURIComponent(path), 'http://gate.invalid').pathname); } catch { return path; }
726
- })();
727
- if (gatePath === '/v1/fleet/blob-sessions' || gatePath.startsWith('/v1/fleet/blob-sessions/')
728
- || gatePath === '/v1/fleet/blob-latest' || gatePath.startsWith('/v1/fleet/blobs')) {
729
- const refusal = guardBlobRoute(method);
730
- return sendJson(res, refusal.status, refusal.body);
731
- }
732
- const signedPath = `${stripped}${url.search}`;
733
-
734
- // v0.3.0 §9: read the body ONCE, before routing. An HMAC covers the exact
735
- // received bytes, so no route may consume the stream first and re-serialise.
736
- /** v1.2.7: only the blob channel gets the larger frame cap; every JSON route keeps the 64 KiB frame limit. */
737
- const blobFrameLimit = method === 'PUT' && path.startsWith('/v1/fleet/blobs/') ? BLOB_FRAME_BYTES : this.frameLimitBytes;
738
- const rawBody = methodHasBody(method) ? await readRawBody(req, blobFrameLimit) : null;
739
- const takeJson = () => parseJsonBody(rawBody);
740
-
741
- /* ---- public endpoints ---- */
742
- // `/healthz` and `/v1/pair` are permanently exempt from signature checks:
743
- // the first is an operations probe that must not need credentials, the second
744
- // is the bootstrap step that happens before the two sides share a secret.
745
- if (method === 'GET' && path === '/healthz') {
746
- return sendJson(res, 200, this.healthBody(req));
747
- }
748
- if (method === 'POST' && path === '/v1/pair') {
749
- this.enforcePairRateLimit(req);
750
- const result = this.state.pair(takeJson());
751
- return sendJson(res, 200, result);
752
- }
753
- /**
754
- * v1.2.4: cancellation over HTTP. state.cancelTask has existed since the task ledger gained a cancel flag, but no route
755
- * reached it -- the capability was real on paper and unreachable on the wire. Same authorization as dispatch: cancelling
756
- * a fleet task is an operator act, not something a device token confers.
757
- */
758
- const cancelMatch = path.match(/^\/v1\/tasks\/([^/]+)\/cancel$/);
759
- if (method === 'POST' && cancelMatch !== null) {
760
- this.authorizeTaskDispatch(req);
761
- this.enforceSignature(req, { method, signedPath, body: rawBody });
762
- const body = takeJson();
763
- const cancelled = this.state.cancelTask(decodeURIComponent(cancelMatch[1]), typeof body.reason === 'string' && body.reason !== '' ? body.reason : undefined);
764
- return sendJson(res, 200, {
765
- protocol_version: PROTOCOL_VERSION,
766
- rabbit_time: this.state.nowIso(),
767
- ok: true,
768
- task: { task_id: cancelled.task_id, cancelled: cancelled.cancelled === true, cancel_reason: cancelled.cancel_reason ?? null },
769
- });
770
- }
771
- /**
772
- * v1.2.5: a role is an operator decision, so this route sits with dispatch — before the device gate, where an operator
773
- * token is accepted.
774
- */
775
- const memberMatch = path.match(/^\/v1\/fleet\/members\/([^/]+)$/);
776
- if (method === 'PUT' && memberMatch !== null) {
777
- this.authorizeTaskDispatch(req);
778
- this.enforceSignature(req, { method, signedPath, body: rawBody });
779
- const body = takeJson();
780
- const changed = this.state.setMemberRole(decodeURIComponent(memberMatch[1]), body.role);
781
- if (changed.ok !== true) {
782
- return sendJson(res, 400, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: changed.code }, detail: changed.detail ?? null });
783
- }
784
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, member: changed.member });
785
- }
786
- /**
787
- * v0.3.3 `/metrics`: machine-readable fleet state for a monitoring system.
788
- *
789
- * Token-free, like `/healthz` and for the same reason: a scraper that needs the operator token
790
- * would put that token in a Prometheus config file, which is a credential in a place nobody
791
- * reviews. It is also exempt from signature checks for the same reason -- there is no secret to
792
- * sign with on the scraper side.
793
- *
794
- * Exposed only when the relay was asked for it (`--metrics`). A monitoring endpoint that appears
795
- * by default is a surface nobody chose, and the whole point of the defaults elsewhere is that the
796
- * relay binds nothing the operator did not ask for.
797
- */
798
- if (method === 'GET' && path === '/metrics') {
799
- if (!this.metricsEnabled) {
800
- // 404 rather than 403: a disabled endpoint should look absent, not forbidden. A 403 tells a
801
- // scanner the surface exists.
802
- throw new ProtocolError('NOT_FOUND', 'metrics are not enabled on this relay', { hint: 'start the relay with --metrics' });
803
- }
804
- const publishedBody = renderMetrics(this.state, { nowMs: this.state.nowMs() });
805
- // v1.3.10-beta.23: the M7 reliability registry is appended. It renders nothing until a call site records something, so
806
- // the existing exposition is unchanged on relays that never instrument.
807
- const registryBody = typeof this.state.metrics?.renderPrometheus === 'function' ? this.state.metrics.renderPrometheus() : '';
808
- const body = registryBody === '' ? publishedBody : publishedBody + '# reliability registry\n' + registryBody;
809
- res.writeHead(200, {
810
- 'content-type': METRICS_CONTENT_TYPE,
811
- 'content-length': Buffer.byteLength(body, 'utf8'),
812
- 'cache-control': 'no-store',
813
- });
814
- return res.end(body);
815
- }
816
- // §5: dispatching tasks needs the operator token, not a device token.
817
- if (method === 'POST' && path === '/v1/task') {
818
- const principal = this.authorizeTaskDispatch(req);
819
- this.enforceSignature(req, { method, signedPath, body: rawBody });
820
- const body = takeJson();
821
- if (principal.machine_id && !body.created_by) body.created_by = principal.machine_id;
822
- const taskGuard = guardTaskBody(body);
823
- if (taskGuard.ok !== true) {
824
- return sendJson(res, taskGuard.status, taskGuard.body);
825
- }
826
- const result = this.state.createTask(body);
827
- return sendJson(res, 200, result);
828
- }
829
-
830
- /* ---- v1.1.6: reachable WITHOUT a device token ---- */
831
- this.joinChallenges ??= createJoinChallenges({
832
- now: () => this.state.nowMs(),
833
- publicKeyFor: (fingerprint) => this.state.accountKeyFor(fingerprint),
834
- });
835
- const admissionPollMatch = /^\/v1\/fleet\/admission\/([^/]+)$/.exec(path);
836
- if (method === 'GET' && admissionPollMatch && url.searchParams.get('poll_token') !== null) {
837
- this.admission ??= createAdmission({ now: () => this.state.nowMs() });
838
- const polled = this.admission.poll(decodeURIComponent(admissionPollMatch[1]), url.searchParams.get('poll_token'));
839
- if (polled.ok !== true) throw new ProtocolError('NOT_FOUND', 'unknown admission request');
840
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...polled });
841
- }
842
- if (method === 'GET' && path === '/v1/fleet/admission/challenge') {
843
- const issued = this.joinChallenges.issue(url.searchParams.get('fingerprint') ?? '', url.searchParams.get('machine_id') ?? '');
844
- if (issued.ok !== true) throw new ProtocolError('BAD_REQUEST', 'challenge refused: ' + issued.code, { code: issued.code });
845
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...issued });
846
- }
847
- if (method === 'POST' && path === '/v1/fleet/admission/request') {
848
- const body = takeJson();
849
- this.admission ??= createAdmission({ now: () => this.state.nowMs() });
850
- const proof = this.joinChallenges.consume({ challenge_id: body.challenge_id, machine_id: body.machine_id, signature: body.signature });
851
- if (proof.ok !== true) throw new ProtocolError('UNAUTHORIZED', 'join proof refused: ' + proof.code, { code: proof.code });
852
- const result = this.admission.requestAdmission({ machine_id: body.machine_id, machine_name: body.machine_name ?? null, account_fingerprint: proof.fingerprint });
853
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...result });
854
- }
855
-
856
- /**
857
- * v1.3.10-beta.14: project-room writes (M4).
858
- *
859
- * Above the shared device guard on purpose: an operator token is not a device token, and these change who may act on
860
- * shared state, so they take the same credential that dispatches tasks.
861
- */
862
- if (method === 'POST' && (path === '/v1/projects' || /^\/v1\/projects\/[^/]+\/members$/.test(path))) {
863
- const auth = this.authorizeTaskDispatch(req);
864
- if (auth.role !== 'operator') throw new ProtocolError('FORBIDDEN_ACTION', 'only the operator may change project membership', { required_role: 'owner' });
865
- const body = takeJson();
866
- const membersMatch = /^\/v1\/projects\/([^/]+)\/members$/.exec(path);
867
- if (membersMatch !== null) {
868
- const target = decodeURIComponent(membersMatch[1]);
869
- const added = this.state.projects.addMember({ projectId: target, member: { kind: body?.member?.kind, id: body?.member?.id, name: body?.member?.name ?? null }, role: body?.role ?? 'worker' });
870
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, member: added.member, snapshot: this.state.projects.snapshot({ projectId: target }) });
871
- }
872
- const created = this.state.projects.createProject({
873
- name: body?.name,
874
- origin: body?.origin ?? null,
875
- createdBy: { kind: 'operator', id: typeof body?.created_by?.id === 'string' ? body.created_by.id : 'operator', name: typeof body?.created_by?.name === 'string' ? body.created_by.name : null },
876
- });
877
- const createdId = created.project.project.project_id;
878
- if (Array.isArray(body?.members)) {
879
- for (const entry of body.members) {
880
- this.state.projects.addMember({ projectId: createdId, member: { kind: entry?.kind, id: entry?.id, name: entry?.name ?? null }, role: entry?.role ?? 'worker' });
881
- }
882
- }
883
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, project: this.state.projects.snapshot({ projectId: createdId }) });
884
- }
885
-
886
-
887
- /**
888
- * v1.3.12-beta.2: grant administration (execution-authorization model).
889
- *
890
- * Revoking is an operator act: it stops one authorisation from being dispatched. The task list is the audit view --
891
- * every task that carried a grant, with the argv hash and key id it was authorised under -- and deliberately returns
892
- * only operator-readable data, never result bodies.
893
- */
894
- /**
895
- * v1.6.2: whole-relay fleet overview for an operator. Aggregate counts are public on /healthz;
896
- * the per-fleet rows are not, because they name other accounts fleets.
897
- */
898
- if (method === 'GET' && path === '/v1/fleets') {
899
- const auth = this.authorizeTaskDispatch(req);
900
- if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'listing fleets is an operator view');
901
- const summary = this.state.fleetSummary();
902
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...summary });
903
- }
904
-
905
- if (path === '/v1/grants/revoke' || path === '/v1/grants') {
906
- const auth = this.authorizeTaskDispatch(req);
907
- if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'only the operator may administer grants');
908
- if (method === 'POST' && path === '/v1/grants/revoke') {
909
- const body = takeJson();
910
- const revoked = this.state.revokeGrant({ grantId: body?.grant_id, reason: body?.reason ?? null });
911
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...revoked });
912
- }
913
- if (method === 'GET' && path === '/v1/grants') {
914
- const audit = this.state.grantedTasks();
915
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...audit });
916
- }
917
- throw new ProtocolError('BAD_REQUEST', 'grants accept GET (audit) or POST /revoke', { method, path });
918
- }
919
-
920
- /**
921
- * v1.3.13-beta.4: project refs and the merge queue (task model: branch, merge, conflict).
922
- *
923
- * Reads take the device token of a paired machine; advancing a ref and settling a queue item are operator acts, because
924
- * they state where the branch is and what a patch did. A merge that would move the head from a base that is no longer
925
- * current is refused and the item stays queued -- the model never silently rewrites history.
926
- */
927
- const refsMatch = /^\/v1\/projects\/([^/]+)\/refs(?:\/advance)?$/.exec(path);
928
- if (refsMatch !== null) {
929
- const projectId = decodeURIComponent(refsMatch[1]);
930
- const wantsAdvance = path.endsWith('/advance');
931
- if (!wantsAdvance && method === 'GET') {
932
- const token = bearerToken(req);
933
- if (token === null || this.state.authenticate(token) === null) throw new ProtocolError('UNAUTHORIZED', 'reading refs needs a paired device token');
934
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, refs: this.state.refs.listRefs({ projectId }) });
935
- }
936
- if (wantsAdvance && method === 'POST') {
937
- const auth = this.authorizeTaskDispatch(req);
938
- if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'advancing a ref is an operator act');
939
- const body = takeJson();
940
- const moved = this.state.refs.advance({ projectId, branch: body?.branch, from: body?.from ?? null, to: body?.to, reason: body?.reason ?? null });
941
- return sendJson(res, moved.ok === true ? 200 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...moved });
942
- }
943
- throw new ProtocolError('BAD_REQUEST', 'refs accept GET or POST /advance', { method, path });
944
- }
945
- const mergeMatch = /^\/v1\/projects\/([^/]+)\/merge-queue(?:\/([^/]+))?(?:\/(complete))?$/.exec(path);
946
- if (mergeMatch !== null) {
947
- const projectId = decodeURIComponent(mergeMatch[1]);
948
- const queueId = mergeMatch[2] === undefined ? null : decodeURIComponent(mergeMatch[2]);
949
- const action = mergeMatch[3] ?? null;
950
- if (method === 'GET' && queueId === null) {
951
- const token = bearerToken(req);
952
- if (token === null || this.state.authenticate(token) === null) throw new ProtocolError('UNAUTHORIZED', 'reading the merge queue needs a paired device token');
953
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, items: this.state.refs.listQueue({ projectId }) });
954
- }
955
- const auth = this.authorizeTaskDispatch(req);
956
- if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'merge queue mutations are operator acts');
957
- if (method === 'POST' && queueId === null) {
958
- const body = takeJson();
959
- const queued = this.state.refs.enqueue({ projectId, branch: body?.branch, patchSha256: body?.patch_sha256, baseCommit: body?.base_commit, sourceTaskId: body?.source_task_id ?? null });
960
- return sendJson(res, queued.ok === true ? 200 : 400, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...queued });
961
- }
962
- if (method === 'POST' && queueId !== null && action === 'complete') {
963
- const body = takeJson();
964
- const settled = this.state.refs.complete({ queueId, outcome: body?.outcome, headCommit: body?.head_commit ?? null, conflictSha256: body?.conflict_sha256 ?? null });
965
- return sendJson(res, settled.ok === true ? 200 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...settled });
966
- }
967
- throw new ProtocolError('BAD_REQUEST', 'merge queue accepts GET, POST enqueue or POST /:id/complete', { method, path });
968
- }
969
-
970
- /**
971
- * v1.3.14-beta.1 (collaboration model): shared objects. Two clients in the same project read the SAME board and the SAME
972
- * artifact index; before this they each filtered the global task list and disagreed as soon as one of them looked late.
973
- */
974
- const sharedMatch = /^\/v1\/projects\/([^/]+)\/(board|artifacts)$/.exec(path);
975
- if (sharedMatch !== null) {
976
- const projectId = decodeURIComponent(sharedMatch[1]);
977
- const resource = sharedMatch[2];
978
- const readDevice = () => {
979
- const token = bearerToken(req);
980
- const caller = token === null ? null : this.state.authenticate(token);
981
- if (caller === null) throw new ProtocolError('UNAUTHORIZED', 'reading a project object needs a paired device token');
982
- return caller;
983
- };
984
- if (method === 'GET') {
985
- const caller = readDevice();
986
- const snapshot = this.state.projectSnapshot(projectId);
987
- const isDefault = snapshot.project.project_id === this.state.defaultProjectId;
988
- if (!isDefault) {
989
- const member = this.state.projects.memberOf({ projectId, member: { kind: 'machine', id: caller.machine_id } });
990
- if (member === null) throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: projectId });
991
- }
992
- if (resource === 'board') return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...this.state.projectBoard({ projectId }) });
993
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, artifacts: this.state.listArtifacts({ projectId }) });
994
- }
995
- if (method === 'POST' && resource === 'artifacts') {
996
- const auth = this.authorizeTaskDispatch(req);
997
- if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'registering a project artifact is an operator act');
998
- const body = takeJson();
999
- const registered = this.state.registerArtifact({ projectId, sha256: body?.sha256, bytes: body?.bytes, kind: body?.kind ?? 'artifact', sourceTaskId: body?.source_task_id ?? null });
1000
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...registered });
1001
- }
1002
- throw new ProtocolError('BAD_REQUEST', 'project objects accept GET, or POST artifacts', { method, path });
1003
- }
1004
-
1005
- /**
1006
- * v1.3.14-beta.2 (collaboration model): decisions and project memory as shared objects. The board answers "what is
1007
- * happening", these answer "what did we agree" -- and both are one object per project, not a per-client copy.
1008
- */
1009
- const knowledgeMatch = /^\/v1\/projects\/([^/]+)\/(decisions|memory)$/.exec(path);
1010
- if (knowledgeMatch !== null) {
1011
- const projectId = decodeURIComponent(knowledgeMatch[1]);
1012
- const resource = knowledgeMatch[2];
1013
- const readDevice = () => {
1014
- const token = bearerToken(req);
1015
- const caller = token === null ? null : this.state.authenticate(token);
1016
- if (caller === null) throw new ProtocolError('UNAUTHORIZED', 'reading a project object needs a paired device token');
1017
- return caller;
1018
- };
1019
- const requireReadable = (caller) => {
1020
- const snapshot = this.state.projectSnapshot(projectId);
1021
- if (snapshot.project.project_id === this.state.defaultProjectId) return;
1022
- const member = this.state.projects.memberOf({ projectId, member: { kind: 'machine', id: caller.machine_id } });
1023
- if (member === null) throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: projectId });
1024
- };
1025
- if (method === 'GET') {
1026
- const caller = readDevice();
1027
- requireReadable(caller);
1028
- if (resource === 'decisions') return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, decisions: this.state.listDecisions({ projectId }) });
1029
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, document: this.state.getProjectMemory({ projectId }) });
1030
- }
1031
- const auth = this.authorizeTaskDispatch(req);
1032
- if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'writing shared project objects is an operator act');
1033
- const body = takeJson();
1034
- if (method === 'POST' && resource === 'decisions') {
1035
- const added = this.state.addDecision({ projectId, title: body?.title, text: body?.text ?? null, actor: { kind: 'operator', id: 'operator' }, refs: body?.refs ?? {}, kind: body?.kind ?? 'decision' });
1036
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...added });
1037
- }
1038
- if (method === 'PUT' && resource === 'memory') {
1039
- const updated = this.state.setProjectMemory({ projectId, text: body?.text, actor: { kind: 'operator', id: 'operator' } });
1040
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...updated });
1041
- }
1042
- throw new ProtocolError('BAD_REQUEST', 'decisions accept GET/POST, memory accepts GET/PUT', { method, path });
1043
- }
1044
-
1045
- /**
1046
- * v1.3.14-beta.3 (collaboration model): notifications for shared-object events. The rows are broadcast to project
1047
- * members, read with a per-reader watermark, and deduplicated inside the notifications module; a client that reconnects
1048
- * asks for everything after its watermark instead of re-reading the whole event stream.
1049
- */
1050
- const notifyMatch = /^\/v1\/projects\/([^/]+)\/notifications(?:\/(unread|read))?$/.exec(path);
1051
- if (notifyMatch !== null) {
1052
- const projectId = decodeURIComponent(notifyMatch[1]);
1053
- const action = notifyMatch[2] ?? null;
1054
- const token = bearerToken(req);
1055
- const caller = token === null ? null : this.state.authenticate(token);
1056
- if (caller === null) throw new ProtocolError('UNAUTHORIZED', 'notifications need a paired device token');
1057
- const snapshot = this.state.projectSnapshot(projectId);
1058
- if (snapshot.project.project_id !== this.state.defaultProjectId) {
1059
- const member = this.state.projects.memberOf({ projectId, member: { kind: 'machine', id: caller.machine_id } });
1060
- if (member === null) throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: projectId });
1061
- }
1062
- const reader = { kind: 'machine', id: caller.machine_id };
1063
- if (method === 'GET' && action === null) {
1064
- const url = new URL(req.url, 'http://relay.invalid');
1065
- const sinceMs = Number(url.searchParams.get('since_ms') ?? 0);
1066
- const limit = Number(url.searchParams.get('limit') ?? 200);
1067
- const listed = this.state.notifications.list({ projectId, sinceMs: Number.isFinite(sinceMs) ? sinceMs : 0, limit: Number.isFinite(limit) ? Math.max(1, Math.min(500, limit)) : 200 });
1068
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...listed });
1069
- }
1070
- if (method === 'GET' && action === 'unread') return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...this.state.notifications.unread({ projectId, reader }) });
1071
- if (method === 'POST' && action === 'read') {
1072
- const body = takeJson();
1073
- const marked = this.state.notifications.markRead({ projectId, reader, watermarkMs: body?.watermark_ms });
1074
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...marked });
1075
- }
1076
- throw new ProtocolError('BAD_REQUEST', 'notifications accept GET, GET /unread or POST /read', { method, path });
1077
- }
1078
-
1079
- /**
1080
- * v1.3.14-beta.4 (collaboration model): a project-scoped SSE stream.
1081
- *
1082
- * `ready` names the project and its last event seq, `since_seq` replays the room events a client missed, then every new
1083
- * room event is pushed as `project.event`. Clients in the same project therefore apply the same ordered events instead
1084
- * of each polling a different view.
1085
- */
1086
- const projectStreamMatch = /^\/v1\/projects\/([^/]+)\/stream$/.exec(path);
1087
- if (projectStreamMatch !== null && method === 'GET') {
1088
- const projectId = decodeURIComponent(projectStreamMatch[1]);
1089
- const token = bearerToken(req);
1090
- const caller = token === null ? null : this.state.authenticate(token);
1091
- if (caller === null) throw new ProtocolError('UNAUTHORIZED', 'project events need a paired device token');
1092
- const snapshot = this.state.projectSnapshot(projectId);
1093
- if (snapshot.project.project_id !== this.state.defaultProjectId) {
1094
- const member = this.state.projects.memberOf({ projectId, member: { kind: 'machine', id: caller.machine_id } });
1095
- if (member === null) throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: projectId });
1096
- }
1097
- const sinceRaw = url.searchParams.get('since_seq');
1098
- const sinceSeq = sinceRaw !== null && /^[0-9]+$/.test(sinceRaw) ? Number(sinceRaw) : 0;
1099
- res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
1100
- const frame = (entry) => {
1101
- try { res.write('id: ' + entry.seq + '\nevent: project.event\ndata: ' + JSON.stringify({ project_id: entry.project_id, seq: entry.seq, type: entry.type, payload: entry.payload }) + '\n\n'); } catch { /* closed */ }
1102
- };
1103
- const replayed = this.state.projects.events({ projectId, sinceSeq });
1104
- res.write('event: ready\ndata: ' + JSON.stringify({ protocol_version: PROTOCOL_VERSION, project_id: projectId, last_event_seq: replayed.next_seq }) + '\n\n');
1105
- for (const entry of replayed.events) frame(entry);
1106
- const unsubscribe = this.state.onProjectEvent((entry) => { if (entry.project_id === projectId) frame(entry); });
1107
- const keepalive = setInterval(() => { try { res.write(': keepalive\n\n'); } catch { /* closed */ } }, this.keepaliveMs);
1108
- let closed = false;
1109
- const done = () => { if (closed) return; closed = true; clearInterval(keepalive); unsubscribe(); try { res.end(); } catch { /* already closed */ } };
1110
- req.on('close', done);
1111
- res.on('close', done);
1112
- return true;
1113
- }
1114
-
1115
- /* ---- everything below requires `Authorization: Bearer <device_token>` ---- */
1116
- const token = bearerToken(req);
1117
- const device = token ? this.state.authenticate(token) : null;
1118
- if (!device) {
1119
- throw new ProtocolError('UNAUTHORIZED', 'missing or invalid device_token', {});
1120
- }
1121
- this.state.touchDevice(device.machine_id);
1122
-
1123
- const projectLockMatch = /^\/v1\/projects\/([^/]+)\/locks(?:\/([^/]+)(?:\/(renew))?)?$/.exec(path);
1124
- if (projectLockMatch !== null && method !== undefined) {
1125
- const projectId = decodeURIComponent(projectLockMatch[1]);
1126
- const lockId = projectLockMatch[2] === undefined ? null : decodeURIComponent(projectLockMatch[2]);
1127
- const action = projectLockMatch[3] ?? null;
1128
- const roomMember = { kind: 'machine', id: device.machine_id };
1129
- const snapshot = this.state.projectSnapshot(projectId);
1130
- const isDefault = snapshot.project.project_id === this.state.defaultProjectId;
1131
- const membership = isDefault ? { role: 'worker' } : this.state.projects.memberOf({ projectId, member: roomMember });
1132
- if (membership === null) return sendJson(res, 404, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: false, error: { code: 'NOT_FOUND', message: 'unknown project' } });
1133
- const roleRank = { observer: 0, worker: 1, maintainer: 2, owner: 3 };
1134
- const canWrite = roleRank[membership.role] >= roleRank.worker;
1135
- if (method === 'GET' && lockId === null) {
1136
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, locks: this.state.locks.list({ projectId }) });
1137
- }
1138
- if (method === 'POST' && lockId === null) {
1139
- if (!canWrite) return sendJson(res, 403, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: false, error: { code: 'FORBIDDEN_ACTION', message: 'acquiring a lock needs worker or above', required_role: 'worker', current_role: membership.role } });
1140
- const body = takeJson();
1141
- const acquired = this.state.locks.acquire({ projectId, kind: body?.kind, target: body?.target, holder: { kind: "machine", id: device.machine_id }, reason: body?.reason ?? null, leaseMs: Number.isFinite(body?.lease_ms) ? body.lease_ms : undefined });
1142
- if (acquired.ok === true) this.state.metrics?.inc('wm_lock_acquired_total', { kind: String(body?.kind ?? '') });
1143
- else if (acquired.code === 'LOCK_HELD') this.state.metrics?.inc('wm_lock_conflict_total', { kind: String(body?.kind ?? '') });
1144
- return sendJson(res, acquired.ok ? 200 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...acquired });
1145
- }
1146
- if (method === 'POST' && lockId !== null && action === 'renew') {
1147
- const renewed = this.state.locks.renew({ projectId, lockId, holder: { kind: "machine", id: device.machine_id } });
1148
- return sendJson(res, renewed.ok ? 200 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...renewed });
1149
- }
1150
- if (method === 'DELETE' && lockId !== null) {
1151
- const force = url.searchParams.get('force') === '1';
1152
- const reason = url.searchParams.get('reason');
1153
- const released = force
1154
- ? this.state.locks.forceRelease({ projectId, lockId, actor: { kind: "machine", id: device.machine_id }, actorRole: membership.role, reason })
1155
- : this.state.locks.release({ projectId, lockId, holder: { kind: "machine", id: device.machine_id } });
1156
- return sendJson(res, released.ok ? 200 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...released });
1157
- }
1158
- return sendJson(res, 405, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'unsupported lock operation' } });
1159
- }
1160
-
1161
- /**
1162
- * v1.3.10-beta.14: project-room reads (M4). Below the device guard, so the device is already authenticated.
1163
- *
1164
- * A machine sees the default project and the projects it is a member of; a project it is not a member of answers
1165
- * NOT_FOUND, so a project existence is not leaked. The snapshot carries last_event_seq, and the events route returns
1166
- * the slice after since_seq, which is what lets a client resume after a disconnect.
1167
- */
1168
- if (method === 'GET' && (path === '/v1/projects' || path.startsWith('/v1/projects/'))) {
1169
- const match = /^\/v1\/projects(?:\/([^/]+))?(?:\/(events|members))?$/.exec(path);
1170
- if (match === null) throw new ProtocolError('NOT_FOUND', 'unknown route', { path });
1171
- const projectId = match[1] === undefined ? null : decodeURIComponent(match[1]);
1172
- const sub = match[2] ?? null;
1173
- const readSnapshot = (id) => {
1174
- let snapshot;
1175
- try {
1176
- snapshot = this.state.projectSnapshot(id);
1177
- } catch (error) {
1178
- if (error?.code === 'BAD_REQUEST' || error?.code === 'PROJECT_NOT_FOUND') throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: id });
1179
- throw error;
1180
- }
1181
- if (snapshot.project.project_id === this.state.defaultProjectId) return snapshot;
1182
- const member = this.state.projects.memberOf({ projectId: id, member: { kind: 'machine', id: device.machine_id } });
1183
- if (member === null) throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: id });
1184
- return snapshot;
1185
- };
1186
- if (projectId === null) {
1187
- const mine = this.state.projects.listProjects({ member: { kind: 'machine', id: device.machine_id } }).projects;
1188
- if (!mine.some((project) => project.project_id === this.state.defaultProjectId)) mine.unshift({ project_id: this.state.defaultProjectId, name: 'default', origin: null });
1189
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, projects: mine });
1190
- }
1191
- if (sub === null) return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...readSnapshot(projectId) });
1192
- if (sub === 'events') {
1193
- readSnapshot(projectId);
1194
- const raw = url.searchParams.get('since_seq');
1195
- const sinceSeq = raw !== null && /^[0-9]+$/.test(raw) ? Number(raw) : 0;
1196
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...this.state.projectEvents(projectId, { sinceSeq }) });
1197
- }
1198
- throw new ProtocolError('BAD_REQUEST', 'unsupported project route', { method, path });
1199
- }
1200
-
1201
-
1202
- // v0.3.0 §9: writes are always checked (tolerating unsigned unless required);
1203
- // reads are only checked when the relay was explicitly told to require
1204
- // signatures. See `enforceSignature` for the reasoning.
1205
- this.enforceSignature(req, { method, signedPath, body: rawBody });
1206
-
1207
- /* ---- v1.2.5: members and read watermarks ---- */
1208
-
1209
- if (method === 'GET' && path === '/v1/fleet/members') {
1210
- return sendJson(res, 200, {
1211
- protocol_version: PROTOCOL_VERSION,
1212
- rabbit_time: this.state.nowIso(),
1213
- ok: true,
1214
- members: this.state.listMembers(),
1215
- });
1216
- }
1217
- const readMatch = path.match(/^\/v1\/fleet\/read\/([^/]+)$/);
1218
- if (readMatch !== null) {
1219
- const sessionId = decodeURIComponent(readMatch[1]);
1220
- if (method === 'PUT') {
1221
- const body = takeJson();
1222
- const marked = this.state.markRead(sessionId, device.machine_id, {
1223
- at_ms: Number.isFinite(body.at_ms) ? body.at_ms : this.state.nowMs(),
1224
- message_id: typeof body.message_id === 'string' ? body.message_id : null,
1225
- });
1226
- if (marked.ok !== true) return sendJson(res, 400, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: marked.code } });
1227
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...marked });
1228
- }
1229
- if (method === 'GET') {
1230
- this.fleetMessages ??= createFleetMessages({ now: () => this.state.nowMs() });
1231
- const mark = this.state.readMark(sessionId, device.machine_id);
1232
- const listed = this.fleetMessages.list({ session_id: sessionId, limit: 200, for_machine_id: device.machine_id });
1233
- const messages = listed.ok === true ? listed.messages : [];
1234
- const floor = mark === null ? 0 : mark.at_ms;
1235
- const unread = messages.filter((message) => Number.isFinite(message?.at_ms) && message.at_ms > floor && message.machine_id !== device.machine_id).length;
1236
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, session_id: sessionId, mark, unread });
1237
- }
1238
- return sendJson(res, 405, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: 'METHOD_NOT_ALLOWED' } });
1239
- }
1240
- if (method === 'GET' && path === '/v1/stream') {
1241
- return this.handleStream(req, res, url, device);
1242
- }
1243
- if (method === 'POST' && path === '/v1/heartbeat') {
1244
- const body = takeJson();
1245
- body.machine_id = body.machine_id ?? device.machine_id;
1246
- const result = this.state.heartbeat(body);
1247
- return sendJson(res, 200, result);
1248
- }
1249
- if (method === 'POST' && path === '/v1/result') {
1250
- const body = takeJson();
1251
- // B1 方案 A:window 模式接收旧信封(含 argv/head)并投影后存储;enforce 才硬拒。
1252
- if (String(process.env.W2M_DATA_PLANE_GUARD ?? 'window') === 'enforce') {
1253
- const resultGuard = guardResultEnvelope(body);
1254
- if (resultGuard.ok !== true) {
1255
- return sendJson(res, resultGuard.status, resultGuard.body);
1256
- }
1257
- }
1258
- const result = this.state.submitResult(body);
1259
- const { _instanceKey, ...clean } = result;
1260
- return sendJson(res, 200, clean);
1261
- }
1262
-
1263
- if (method === 'GET' && path === '/v1/devices') {
1264
- /**
1265
- * v1.5.5: device enumeration is fleet-scoped.
1266
- *
1267
- * This endpoint used to return every device on the relay; state.mjs even recorded the narrowing
1268
- * as a pending step. The observation that forced it: another account's machine (its own fleet id,
1269
- * its own fleet key) appeared in this fleet's machine list -- correct fleet binding, wrong list.
1270
- *
1271
- * Migration rule, the same one task dispatch already obeys: a relay with no fleet configured
1272
- * keeps the historical behaviour for fleetless devices, a scoped relay serves its own fleet, and
1273
- * a fleet-bound device always sees exactly its own fleet and nothing else.
1274
- */
1275
- const callerFleet = typeof device?.fleet_id === 'string' && device.fleet_id !== '' ? device.fleet_id : null;
1276
- const relayFleet = this.state.fleetId ?? null;
1277
- const allDevices = this.state.listDevices();
1278
- const devices = callerFleet === null
1279
- ? (relayFleet === null ? allDevices : allDevices.filter((entry) => entry.fleet_id === relayFleet))
1280
- : allDevices.filter((entry) => entry.fleet_id === callerFleet);
1281
- return sendJson(res, 200, {
1282
- protocol_version: PROTOCOL_VERSION,
1283
- rabbit_time: this.state.nowIso(),
1284
- devices,
1285
- });
1286
- }
1287
- if (method === 'GET' && path === '/v1/tasks') {
1288
- const rawLimit = url.searchParams.get('limit');
1289
- let limit = 50;
1290
- if (rawLimit !== null) {
1291
- limit = Number(rawLimit);
1292
- if (!Number.isInteger(limit) || limit < 1) {
1293
- throw new ProtocolError('BAD_REQUEST', 'limit must be a positive integer', { limit: rawLimit });
1294
- }
1295
- }
1296
- return sendJson(res, 200, {
1297
- protocol_version: PROTOCOL_VERSION,
1298
- rabbit_time: this.state.nowIso(),
1299
- tasks: this.state.listTasks({ limit, fleetId: device.fleet_id ?? null }),
1300
- });
1301
- }
1302
-
1303
- const taskMatch = /^\/v1\/tasks\/([^/]+)$/.exec(path);
1304
- const reportMatch = /^\/v1\/tasks\/([^/]+)\/report$/.exec(path);
1305
- // v0.3.0: cross-machine RTT status. The plugin may run on a different machine
1306
- // than the agent, so this has to come from the relay, not from a local file.
1307
- const agentStatusMatch = /^\/v1\/agents\/([^/]+)\/status$/.exec(path);
1308
- // v0.3.9: P2P candidate rendezvous. `/announce` is checked before the read route
1309
- // so that a machine literally named "announce" cannot shadow it.
1310
- const peerReadMatch = /^\/v1\/peer\/([^/]+)$/.exec(path);
1311
- /* ---- v1.1.6 admission ---- */
1312
- const admissionDecideMatch = /^\/v1\/fleet\/admission\/([^/]+)\/decide$/.exec(path);
1313
- const memberRemoveMatch = /^\/v1\/fleet\/members\/([^/]+)\/remove$/.exec(path);
1314
- const removalVoteMatch = /^\/v1\/fleet\/removals\/([^/]+)\/vote$/.exec(path);
1315
-
1316
- /* ---- v0.3.9 P2P signalling ---- */
1317
- if (method === 'POST' && path === '/v1/peer/announce') {
1318
- const body = takeJson();
1319
- try {
1320
- // `device.machine_id`, never `body.machine_id`: a paired machine may only
1321
- // announce addresses for itself, or it could aim a peer's punch at a third party.
1322
- const result = this.peers.announce(device.machine_id, body);
1323
- return sendJson(res, 200, {
1324
- protocol_version: PROTOCOL_VERSION,
1325
- rabbit_time: this.state.nowIso(),
1326
- ...result,
1327
- });
1328
- } catch (error) {
1329
- throw new ProtocolError('BAD_REQUEST', error.message, { code: error.code ?? 'BAD_ANNOUNCEMENT' });
1330
- }
1331
- }
1332
-
1333
- if (method === 'GET' && peerReadMatch) {
1334
- const machineId = decodeURIComponent(peerReadMatch[1]);
1335
- const entry = this.peers.get(machineId);
1336
- if (!entry) {
1337
- // A named 404 rather than an empty list: "this machine has not announced" and
1338
- // "this machine announced nothing reachable" call for different responses, and
1339
- // an empty candidate list would send the caller into a punch that cannot work.
1340
- throw new ProtocolError('NOT_FOUND', `no live P2P announcement for ${machineId}`, {
1341
- machine_id: machineId,
1342
- hint: 'the peer must POST /v1/peer/announce first, and announcements expire',
1343
- });
1344
- }
1345
- return sendJson(res, 200, {
1346
- protocol_version: PROTOCOL_VERSION,
1347
- rabbit_time: this.state.nowIso(),
1348
- peer: entry,
1349
- });
1350
- }
1351
-
1352
- if (method === 'GET' && agentStatusMatch) {
1353
- const machineId = decodeURIComponent(agentStatusMatch[1]);
1354
- const status = this.state.deviceStatus(machineId);
1355
- if (!status) {
1356
- throw new ProtocolError('NOT_FOUND', 'unknown machine_id', { machine_id: machineId });
1357
- }
1358
- return sendJson(res, 200, {
1359
- protocol_version: PROTOCOL_VERSION,
1360
- rabbit_time: this.state.nowIso(),
1361
- ...status,
1362
- });
1363
- }
1364
-
1365
- if (method === 'GET' && reportMatch) {
1366
- const taskId = decodeURIComponent(reportMatch[1]);
1367
- this.state.sweepExpired();
1368
- const format = url.searchParams.get('format') ?? 'json';
1369
- const report = buildReport(this.state, taskId, { format });
1370
- if (res.writableEnded) return undefined;
1371
- res.writeHead(200, {
1372
- 'content-type': report.contentType,
1373
- 'content-length': Buffer.byteLength(report.body, 'utf8'),
1374
- 'cache-control': 'no-store',
1375
- });
1376
- return res.end(report.body);
1377
- }
1378
-
1379
- if (method === 'GET' && taskMatch) {
1380
- const taskId = decodeURIComponent(taskMatch[1]);
1381
- this.state.sweepExpired();
1382
- const task = this.state.requireTask(taskId);
1383
- const aggregate = aggregateTask(this.state, taskId);
1384
- return sendJson(res, 200, {
1385
- protocol_version: PROTOCOL_VERSION,
1386
- rabbit_time: this.state.nowIso(),
1387
- task: this.state.taskSummary(task),
1388
- leases: [...task.leases.values()],
1389
- aggregate,
1390
- });
1391
- }
1392
-
1393
-
1394
- /* ---- v1.1.6 admission: the caller is always `device.machine_id`, never a body field ---- */
1395
- this.admission ??= createAdmission({ now: () => this.state.nowMs() });
1396
-
1397
- if (method === 'GET' && path === '/v1/fleet/admission/pending') {
1398
- return sendJson(res, 200, {
1399
- protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(),
1400
- pending: this.admission.pendingFor(),
1401
- });
1402
- }
1403
-
1404
- if (method === 'POST' && admissionDecideMatch) {
1405
- const body = takeJson();
1406
- const result = this.admission.decide(decodeURIComponent(admissionDecideMatch[1]), {
1407
- decision: body.decision, decided_by: device.machine_id, auto: body.auto === true,
1408
- });
1409
- if (result.ok !== true) throw new ProtocolError('BAD_REQUEST', 'admission decision refused: ' + result.code, result);
1410
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...result });
1411
- }
1412
-
1413
- if (method === 'POST' && memberRemoveMatch) {
1414
- const body = takeJson();
1415
- const result = this.admission.remove(decodeURIComponent(memberRemoveMatch[1]), {
1416
- requested_by: device.machine_id, reason: typeof body.reason === 'string' ? body.reason : null,
1417
- });
1418
- if (result.ok !== true) throw new ProtocolError('BAD_REQUEST', 'removal refused: ' + result.code, result);
1419
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...result });
1420
- }
1421
-
1422
- if (method === 'POST' && removalVoteMatch) {
1423
- const body = takeJson();
1424
- const result = this.admission.vote(decodeURIComponent(removalVoteMatch[1]), {
1425
- machine_id: device.machine_id, vote: body.vote,
1426
- });
1427
- if (result.ok !== true) throw new ProtocolError('BAD_REQUEST', 'vote refused: ' + result.code, result);
1428
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...result });
1429
- }
1430
-
1431
- /* ---- v1.1.17: fleet chat ---- */
1432
- //
1433
- // Both endpoints sit AFTER the device gate on purpose: a message must be attributable to a paired machine, and
1434
- // "who said this" is exactly the machine_id the token resolves to -- never a body field, which would let one machine
1435
- // speak as another. The refusal codes from the store are passed through unchanged (FLEET_QUOTA_EXCEEDED included)
1436
- // because a caller that cannot tell "too big" from "over quota" from "not text" cannot tell the operator what to do.
1437
- this.fleetMessages ??= createFleetMessages({ now: () => this.state.nowMs() });
1438
- this.fleetMemory ??= createFleetMemory({ now: () => this.state.nowMs() });
1439
-
1440
- if (method === 'POST' && path === '/v1/fleet/messages') {
1441
- /**
1442
- * v1.2.6: posting to the group is a dispatch-class action. An observer may read the flow and may not speak into it, and
1443
- * the refusal names the action and both roles so it is actionable.
1444
- */
1445
- const permitted = mayAct({ role: this.state.roleOf(device.machine_id), action: 'dispatch' });
1446
- if (permitted.ok !== true) {
1447
- return sendJson(res, 403, {
1448
- protocol_version: PROTOCOL_VERSION,
1449
- ok: false,
1450
- error: { code: permitted.code, message: 'role may not post to the fleet group' },
1451
- detail: {
1452
- action: permitted.action,
1453
- required_level: permitted.required_level,
1454
- current_level: permitted.current_level,
1455
- required_role: permitted.required_role,
1456
- current_role: permitted.current_role,
1457
- },
1458
- });
1459
- }
1460
- const body = takeJson();
1461
- const posted = this.fleetMessages.post({
1462
- session_id: body.session_id,
1463
- fleet_id: device.fleet_id ?? null,
1464
- machine_id: device.machine_id,
1465
- to: body.to,
1466
- thread_id: body.thread_id,
1467
- reply_to: body.reply_to,
1468
- text: body.text,
1469
- mentions: body.mentions,
1470
- enc: body.enc,
1471
- reply_state: body.reply_state,
1472
- idempotency_key: body.idempotency_key,
1473
- });
1474
- if (posted.ok !== true) {
1475
- throw new ProtocolError('BAD_REQUEST', 'message refused: ' + posted.code, { code: posted.code, ...(posted.detail ?? {}) });
1476
- }
1477
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), message: posted.message, ...(posted.deduped === true ? { deduped: true } : {}) });
1478
- }
1479
-
1480
- if (method === 'GET' && path === '/v1/fleet/messages/stream') {
1481
- /**
1482
- * v1.1.17: a notification channel, not a message channel.
1483
- *
1484
- * The event carries the id and the session -- never the text -- so a client that connects learns "something was
1485
- * said" and then fetches it through GET /v1/fleet/messages, which is where membership is actually checked. A
1486
- * broadcast carrying text would hand every message to every connected listener and make the check decorative.
1487
- *
1488
- * The stream sits behind the device gate: an unpaired client cannot listen.
1489
- */
1490
- const sessionId = url.searchParams.get('session_id');
1491
- res.writeHead(200, {
1492
- 'content-type': 'text/event-stream; charset=utf-8',
1493
- 'cache-control': 'no-store',
1494
- connection: 'keep-alive',
1495
- 'x-accel-buffering': 'no',
1496
- });
1497
- res.write(': connected\n\n');
1498
- if (typeof res.flushHeaders === 'function') res.flushHeaders();
1499
- const keepalive = setInterval(() => { try { res.write(': keepalive\n\n'); } catch { /* closed */ } }, 15_000);
1500
- if (typeof keepalive.unref === 'function') keepalive.unref();
1501
- const unsubscribe = this.fleetMessages.subscribe(sessionId, (event) => {
1502
- try {
1503
- // v1.2.5: a listener learns whether IT was named, and nothing else about the text.
1504
- const mentioned = Array.isArray(event.mentions) && event.mentions.includes(device.machine_id);
1505
- const payload = {
1506
- session_id: event.session_id,
1507
- message_id: event.message_id,
1508
- machine_id: event.machine_id,
1509
- at_ms: event.at_ms,
1510
- to: event.to ?? 'all',
1511
- ...(mentioned ? { mention: true } : {}),
1512
- };
1513
- res.write('event: new_message\ndata: ' + JSON.stringify(payload) + '\n\n');
1514
- } catch { /* the reader went away; close() below cleans up */ }
1515
- });
1516
- const done = () => { clearInterval(keepalive); unsubscribe(); try { res.end(); } catch { /* already closed */ } };
1517
- req.on('close', done);
1518
- return undefined;
1519
- }
1520
-
1521
- if (method === 'GET' && path === '/v1/fleet/messages') {
1522
- const sinceRaw = url.searchParams.get('since_ms');
1523
- const limitRaw = url.searchParams.get('limit');
1524
- const listed = this.fleetMessages.list({
1525
- session_id: url.searchParams.get('session_id') ?? '',
1526
- since_ms: sinceRaw === null ? null : Number(sinceRaw),
1527
- limit: limitRaw === null ? 50 : Number(limitRaw),
1528
- // Addressing/correlation filters. `for_me=1` is answered from the authenticated device identity, never from a
1529
- // caller-supplied machine id: a reader must not be able to ask for another machine's private messages.
1530
- // v1.5.5: a reader sees the chat of the fleet it belongs to; a device without a fleet keeps the
1531
- // historical unqualified view.
1532
- fleet_id: device.fleet_id ?? null,
1533
- for_machine_id: url.searchParams.get('for_me') === '1' ? device.machine_id : null,
1534
- thread_id: url.searchParams.get('thread_id'),
1535
- to: url.searchParams.get('to'),
1536
- });
1537
- return sendJson(res, 200, {
1538
- protocol_version: PROTOCOL_VERSION,
1539
- rabbit_time: this.state.nowIso(),
1540
- ...listed,
1541
- // v1.5.5: only the caller's fleet messages leave the process.
1542
- messages: (listed.messages ?? []).filter((m) => (m.fleet_id ?? null) === (device.fleet_id ?? null)),
1543
- usage: this.fleetMessages.usage(), // shown with every read so a client can warn before the quota refuses a post
1544
- });
1545
- }
1546
-
1547
- // v1.2.2: shared memory writes. Compare-and-swap on a revision, because two machines appending to one document is
1548
- // otherwise last-writer-wins over the whole thing -- a record one machine added would vanish because the other had an
1549
- // older copy. A stale writer gets 409 and the records it has not read; it is NOT retried here, because retrying in
1550
- // the transport turns a visible conflict into a silent overwrite.
1551
- if (path.startsWith('/v1/fleet/memory/')) {
1552
- const doc = decodeURIComponent(path.slice('/v1/fleet/memory/'.length));
1553
- if (doc === '') throw new ProtocolError('BAD_REQUEST', 'memory doc is required', { method, path });
1554
- // v1.5.5: a fleet's memory must not be addressable by another fleet on the same relay.
1555
- const docKey = typeof device.fleet_id === 'string' && device.fleet_id !== '' ? device.fleet_id + '::' + doc : doc;
1556
- this.fleetMemory ??= createFleetMemory({ now: () => this.state.nowMs() });
1557
- if (method === 'GET') {
1558
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...this.fleetMemory.get(docKey) });
1559
- }
1560
- if (method === 'PUT') {
1561
- const body = takeJson();
1562
- const appended = this.fleetMemory.append({
1563
- doc: docKey,
1564
- if_revision: Number.isInteger(body.if_revision) ? body.if_revision : null,
1565
- author_machine_id: device.machine_id,
1566
- records: Array.isArray(body.records) ? body.records : (typeof body.text === 'string' ? [body.text] : []),
1567
- });
1568
- if (appended.ok !== true) {
1569
- const status = appended.code === 'REVISION_CONFLICT' ? 409 : 400;
1570
- return sendJson(res, status, {
1571
- protocol_version: PROTOCOL_VERSION, ok: false,
1572
- error: { code: appended.code }, detail: appended.detail ?? null,
1573
- });
1574
- }
1575
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...appended });
1576
- }
1577
- throw new ProtocolError('BAD_REQUEST', 'memory accepts GET or PUT', { method, path });
1578
- }
1579
-
1580
- throw new ProtocolError('NOT_FOUND', `no route for ${method} ${path}`, { method, path });
1581
- }
1582
-
1583
- /** §4: the operations answer — "is the relay there, did it restart, are my machines connected". */
1584
- healthBody(req) {
1585
- return {
1586
- ...this.state.stats(),
1587
- // v0.3.9: how many machines currently have a live P2P announcement. Ephemeral,
1588
- // so this drops to 0 by itself when nobody is refreshing.
1589
- ...this.peers.stats(),
1590
- ok: true,
1591
- /**
1592
- * v0.6.2: hoisted out of `persistence` so it cannot be missed by a reader that only looks at
1593
- * the top level. `degraded: true` means this relay is answering requests while failing to record
1594
- * them -- the one relay failure that presents as health. `ok` deliberately stays true: the relay
1595
- * still serves the fleet, and flipping it would turn a recording problem into an outage.
1596
- */
1597
- degraded: this.persistence.enabled && this.persistence.degraded,
1598
- degraded_reason: this.persistence.degraded
1599
- ? `ledger appends are failing (${this.persistence.lastAppendError ?? 'unknown error'}); results are served but not durably recorded`
1600
- : null,
1601
- protocol_version: PROTOCOL_VERSION,
1602
- rabbit_time: this.state.nowIso(),
1603
- uptime_ms: this.state.nowMs() - this._startedAtMs,
1604
- relay_id: this.relayId,
1605
- version: RELAY_VERSION,
1606
- relay_version: RELAY_VERSION,
1607
- features: RELAY_FEATURES,
1608
- started_at: rfc3339(this._startedAtMs),
1609
- effective_scheme: this.effectiveScheme(req),
1610
- base_path: this.basePath,
1611
- operator_token_required: this.operatorTokenRequired,
1612
- pair_rate_limit: this.pairRateLimitPerMinute,
1613
- // v0.3.0: fleet-wide latency. Every numeric field is null (never 0) when no
1614
- // machine has reported a FRESH measurement.
1615
- rtt: this.state.rttSummary(),
1616
- // v0.3.0 §9: signature status only — never the secret, not even a prefix.
1617
- signing: {
1618
- configured: this.signingConfigured,
1619
- required: this.requireSignature,
1620
- previous_secret_accepted: this.signingSecrets.length > 1,
1621
- skew_seconds: this.signatureSkewSeconds,
1622
- },
1623
- persistence: this.persistence.describe(),
1624
- };
1625
- }
1626
-
1627
- /* ---------------- request signing (v0.3.0 §9) ---------------- */
1628
-
1629
- /**
1630
- * Should this request be signature-checked at all?
1631
- *
1632
- * RULING (see PROTOCOL-v0.3.0.md §9.4): every WRITE is checked; READS are
1633
- * checked only when `--require-signature` is on.
1634
- *
1635
- * Reads are not checked in the default modes because unsigned reads are
1636
- * accepted anyway, so "verify when a signature happens to be present" would be
1637
- * theatre -- an attacker simply omits the headers -- while adding a brand new
1638
- * way for a poll or an SSE reconnect to fail. What a read leaks is topology,
1639
- * which the device token already gates; what a write can do is change state,
1640
- * which is what replay and tampering actually threaten.
1641
- */
1642
- shouldCheckSignature(method) {
1643
- if (this.requireSignature) return true; // explicit hard mode: nothing is exempt
1644
- const isRead = method === 'GET' || method === 'HEAD' || method === 'OPTIONS';
1645
- return !isRead;
1646
- }
1647
-
1648
- /** Run `verifyRequest` and throw the mapped §7 error on rejection. */
1649
- enforceSignature(req, { method, signedPath, body }) {
1650
- if (!this.shouldCheckSignature(method)) return { skipped: true };
1651
- const decision = this.verifySignature(req, { method, signedPath, body });
1652
- if (decision.ok !== true) throw signatureError(decision);
1653
- return decision;
1654
- }
1655
-
1656
- /**
1657
- * Thin adapter over the frozen `signing.mjs` contract.
1658
- *
1659
- * `path` is the ROUTED path with the query string, NOT the raw request URL: with
1660
- * `--base-path /w2m` the same client must produce the same signature whether a
1661
- * proxy passes `/w2m/v1/result` through or strips it to `/v1/result`. The routed
1662
- * path is the only value both sides agree on in all three deployment forms, so
1663
- * clients sign `/v1/result` and never `https://host/w2m/v1/result`.
1664
- */
1665
- /**
1666
- * Verify a request's signature (v0.7.11).
1667
- *
1668
- * Two mechanisms, and the order between them is the whole design:
1669
- *
1670
- * **1. Per-device Ed25519, when the sender is a device with a stored public key.** This is the
1671
- * one that proves *which* device signed. It is tried **first** because a device key is strictly
1672
- * stronger evidence than a shared secret, and because a device that registered a key must not
1673
- * fall back to the weaker mechanism by accident.
1674
- *
1675
- * **2. HMAC over a shared secret, otherwise.** Every device that paired before v0.7.10 arrives
1676
- * here — which is every device today — and nothing about its behaviour changes.
1677
- *
1678
- * ## Why this converges with no second deployment
1679
- *
1680
- * A device that has not been updated sends no signature header, so mechanism 1 finds no key and
1681
- * mechanism 2 answers exactly as before. A device that *has* been updated sends a signature, its
1682
- * public key is on file from pairing, and it is verified with it. So a fleet updates one machine
1683
- * at a time and each machine gets the strongest mechanism available **to it** the moment it
1684
- * starts sending one — no flag, no rollout step, no coordination.
1685
- *
1686
- * ## What this deliberately does not do
1687
- *
1688
- * It does not **require** a device key. `requireSignature` still means "a signature must be
1689
- * present", and either mechanism satisfies it. Making a *device key* mandatory is a separate,
1690
- * later switch, because turning it on today would refuse all four machines at once.
1691
- */
1692
- verifySignature(req, { method, signedPath, body }) {
1693
- const viaDevice = this.verifyWithDeviceKey(req, { method, signedPath, body });
1694
- if (viaDevice !== null) return viaDevice;
1695
-
1696
- return verifyRequest({
1697
- headers: req.headers, // plain Node object; signing.mjs reads it case-insensitively
1698
- method,
1699
- path: signedPath,
1700
- body: body ?? '',
1701
- secrets: this.signingSecrets,
1702
- nonces: this.nonceCache,
1703
- nowMs: this.state.nowMs(),
1704
- skewSeconds: this.signatureSkewSeconds,
1705
- required: this.requireSignature,
1706
- });
1707
- }
1708
-
1709
- /**
1710
- * Try the per-device Ed25519 path.
1711
- *
1712
- * Returns `null` when the mechanism does not apply — no signature presented, or the signing
1713
- * machine is not a device whose public key we hold — so the caller falls through. Returns a
1714
- * decision when it *does* apply, **including when it fails**: a device that registered a key and
1715
- * then presented a bad signature must not get a second chance through the shared secret, since
1716
- * that would make the stronger mechanism optional for anyone able to produce a malformed one.
1717
- */
1718
- verifyWithDeviceKey(req, { method, signedPath, body }) {
1719
- const header = readHeader(req.headers, DEVICE_SIGNATURE_HEADER);
1720
- if (typeof header !== 'string' || header === '') return null;
1721
-
1722
- const machineId = this.machineIdForRequest(req);
1723
- if (machineId === null) return null;
1724
- const publicKey = this.state.devices.get(machineId)?.public_key;
1725
- if (typeof publicKey !== 'string' || publicKey === '') return null;
1726
-
1727
- const decision = verifyDeviceSignature({
1728
- headers: req.headers,
1729
- publicKeyPem: publicKey,
1730
- method,
1731
- path: signedPath,
1732
- body: body ?? '',
1733
- nowMs: this.state.nowMs(),
1734
- skewSeconds: this.signatureSkewSeconds,
1735
- // `accept`, not `remember` -- the name this class actually exposes. It reports whether the
1736
- // nonce was new, which is exactly the contract `verifyDeviceSignature` expects.
1737
- nonces: (nonce, at) => this.nonceCache.accept(nonce, at),
1738
- });
1739
- if (decision.ok) return { ...decision, machine_id: machineId, keyIndex: null };
1740
- return decision;
1741
- }
1742
-
1743
- /**
1744
- * Which machine a request claims to be, from either credential.
1745
- *
1746
- * An **operator token is not a machine**, so it resolves to `null` and the device path does not
1747
- * apply — consistent with `authorizeTaskDispatch`, which reports `machine_id: null` for an
1748
- * operator. That is why an operator-token request keeps using the shared secret.
1749
- */
1750
- machineIdForRequest(req) {
1751
- const token = bearerToken(req);
1752
- if (!token) return null;
1753
- if (this.operatorToken !== null && token === this.operatorToken) return null;
1754
- return this.state.authenticate(token)?.machine_id ?? null;
1755
- }
1756
-
1757
- /** Periodic maintenance, called by the sweep timer. */
1758
- maintenance() {
1759
- const expired = this.sweep();
1760
- /**
1761
- * v0.9.1: presence expiry, alongside lease expiry.
1762
- *
1763
- * `this.sweep()` writes off lease *holds*; this writes off *presence*. They were conflated, so a
1764
- * machine that stopped existing was only ever noticed when its SSE stream detached — and a
1765
- * half-open stream never detaches.
1766
- */
1767
- const silent = this.state.sweepSilentDevices();
1768
- if (silent.length > 0) {
1769
- this._log(`[w2m-rabbit] marked ${silent.length} silent device(s) offline: `
1770
- + silent.map((s) => `${s.machine_name} (${Math.round(s.silent_ms / 1000)}s)`).join(', '));
1771
- }
1772
- this.pairLimiter.prune();
1773
- this.peers.sweep();
1774
- // Keep the nonce cache from growing without bound in a long-running process.
1775
- this.nonceCache.prune(this.nonceRetentionMs, this.state.nowMs());
1776
- return expired;
1777
- }
1778
-
1779
- /* ---------------- auth helpers ---------------- */
1780
-
1781
- /** §6: every /v1/pair attempt counts, success included. */
1782
- enforcePairRateLimit(req) {
1783
- if (this.pairRateLimitPerMinute <= 0) return;
1784
- const ip = this.clientIp(req);
1785
- const verdict = this.pairLimiter.hit(ip);
1786
- if (verdict.allowed) return;
1787
- const err = new ProtocolError(
1788
- 'RATE_LIMITED',
1789
- `too many /v1/pair attempts from ${ip}: limit is ${verdict.limit} per ${verdict.window_seconds}s`,
1790
- {
1791
- retry_after_seconds: verdict.retry_after_seconds,
1792
- limit: verdict.limit,
1793
- window_seconds: verdict.window_seconds,
1794
- scope: 'pair',
1795
- },
1796
- );
1797
- err.headers = { 'retry-after': String(verdict.retry_after_seconds) };
1798
- throw err;
1799
- }
1800
-
1801
- /**
1802
- * §5: `POST /v1/task` needs the operator token.
1803
- * - required (default): operator token, else 401 OPERATOR_REQUIRED
1804
- * - disabled (`--operator-token ''`): falls back to v1 semantics, i.e. a paired device token
1805
- */
1806
- authorizeTaskDispatch(req) {
1807
- const token = bearerToken(req);
1808
- if (this.operatorTokenRequired) {
1809
- if (!token) {
1810
- throw new ProtocolError(
1811
- 'OPERATOR_REQUIRED',
1812
- 'POST /v1/task requires the operator token. A device_token is machine credentials and cannot '
1813
- + `dispatch tasks; read the operator token from ${this.persistence.operatorTokenFile ?? '<state>/operator-token.txt'} `
1814
- + 'or pass --operator-token / W2M_OPERATOR_TOKEN.',
1815
- { operator_token_file: this.persistence.operatorTokenFile ?? null },
1816
- );
1817
- }
1818
- if (token !== this.operatorToken) {
1819
- const asDevice = this.state.authenticate(token);
1820
- throw new ProtocolError(
1821
- 'OPERATOR_REQUIRED',
1822
- asDevice
1823
- ? 'this is a device_token (machine credentials); dispatching tasks requires the operator_token — '
1824
- + 'they are two different credentials'
1825
- : 'invalid operator token: it does not match the relay operator token',
1826
- {
1827
- device_token_detected: Boolean(asDevice),
1828
- machine_id: asDevice?.machine_id ?? null,
1829
- operator_token_file: this.persistence.operatorTokenFile ?? null,
1830
- },
1831
- );
1832
- }
1833
- return { role: 'operator', machine_id: null };
1834
- }
1835
- const device = token ? this.state.authenticate(token) : null;
1836
- if (!device) {
1837
- throw new ProtocolError(
1838
- 'UNAUTHORIZED',
1839
- 'the operator token requirement is disabled on this relay, but /v1/task still requires a valid device_token',
1840
- {},
1841
- );
1842
- }
1843
- return { role: 'device', machine_id: device.machine_id };
1844
- }
1845
-
1846
- /* ---------------- SSE (§3) ---------------- */
1847
-
1848
- handleStream(req, res, url, device) {
1849
- const machineId = device.machine_id;
1850
- const requested = url.searchParams.get('machine_id');
1851
- if (requested !== null && requested !== machineId) {
1852
- throw new ProtocolError('BAD_REQUEST', 'machine_id does not match the device_token', {
1853
- machine_id: requested, token_machine_id: machineId,
1854
- });
1855
- }
1856
-
1857
- let from = null;
1858
- const seqParam = url.searchParams.get('seq');
1859
- const lastEventId = req.headers['last-event-id'];
1860
- if (seqParam !== null) {
1861
- from = Number(seqParam);
1862
- if (!Number.isInteger(from) || from < 1) {
1863
- throw new ProtocolError('BAD_REQUEST', 'seq must be a positive integer', { seq: seqParam });
1864
- }
1865
- } else if (typeof lastEventId === 'string' && lastEventId.trim() !== '') {
1866
- const last = Number(lastEventId);
1867
- if (!Number.isInteger(last) || last < 0) {
1868
- throw new ProtocolError('BAD_REQUEST', 'Last-Event-ID must be a non-negative integer', { last_event_id: lastEventId });
1869
- }
1870
- from = last + 1;
1871
- }
1872
-
1873
- // §8.5: these two headers are the fix for "connects but no events" behind
1874
- // nginx-style proxies and tunnels, which buffer event streams by default.
1875
- res.writeHead(200, {
1876
- 'content-type': 'text/event-stream; charset=utf-8',
1877
- 'cache-control': 'no-cache, no-transform',
1878
- connection: 'keep-alive',
1879
- 'x-accel-buffering': 'no',
1880
- });
1881
- if (typeof res.flushHeaders === 'function') res.flushHeaders();
1882
-
1883
- const sub = { machineId, res, write: null, timer: null, closed: false };
1884
-
1885
- const writeEvent = (event) => {
1886
- if (res.writableEnded) return;
1887
- res.write(`id: ${event.seq}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
1888
- };
1889
-
1890
- let live = false;
1891
- const queue = [];
1892
- const deliver = (entry) => {
1893
- if (entry.target && entry.target !== machineId) return;
1894
- writeEvent(entry.event);
1895
- };
1896
- const unsubscribe = this.state.onEvent((entry) => {
1897
- if (!live) { queue.push(entry); return; }
1898
- deliver(entry);
1899
- });
1900
-
1901
- // §3.1: the first frame MUST be `ready`; §8.3 adds `relay_id`.
1902
- const readySeq = this.state.reserveSeq();
1903
- writeEvent({
1904
- type: 'ready',
1905
- seq: readySeq,
1906
- protocol_version: PROTOCOL_VERSION,
1907
- rabbit_time: this.state.nowIso(),
1908
- machine_id: machineId,
1909
- relay_id: this.relayId,
1910
- });
1911
-
1912
- if (from !== null) {
1913
- const oldest = this.state.oldestBufferedSeq();
1914
- const lastBuffered = this.state.lastBufferedSeq();
1915
- // `fromFuture` is judged against EVERY seq this process has allocated (a client
1916
- // can legitimately hold a `ready` seq, which is not buffered), while the
1917
- // reported window bounds describe the replay ring the client can actually use.
1918
- const lastSeq = this.state.lastSeq();
1919
- // Two distinct ways for a cursor to be out of sync with THIS process:
1920
- //
1921
- // from < oldest the cursor is older than the replay window (buffer rot)
1922
- // from > lastSeq+1 the cursor cannot exist here at all. Sequence numbers
1923
- // restart at 1 in every process, so a cursor from a
1924
- // previous relay process is AHEAD of a freshly started
1925
- // relay. This was the silent case: `13 < 1` is false, the
1926
- // client got no notice, `replayEntries(13)` matched
1927
- // nothing, and the agent then reset its cursor to the
1928
- // `ready` seq -- stepping over every event it had missed.
1929
- const tooOld = from < oldest && oldest > 1;
1930
- const fromFuture = from > lastSeq + 1;
1931
- if (tooOld || fromFuture) {
1932
- // §8.4: tell the client where to re-align, not just that replay failed.
1933
- writeEvent({
1934
- type: 'notice',
1935
- seq: this.state.reserveSeq(),
1936
- rabbit_time: this.state.nowIso(),
1937
- level: 'warn',
1938
- code: 'REPLAY_TRUNCATED',
1939
- message: tooOld
1940
- ? `requested seq ${from} is older than the buffer head ${oldest}`
1941
- : `requested seq ${from} is beyond this process' last seq ${lastSeq}: the cursor came from a `
1942
- + 'previous relay process, whose sequence numbering restarted at 1',
1943
- machine_id: machineId,
1944
- requested_seq: from,
1945
- oldest_available_seq: oldest,
1946
- last_available_seq: lastBuffered,
1947
- relay_id: this.relayId,
1948
- reason: tooOld ? 'buffer_truncated' : 'cursor_ahead_of_relay',
1949
- });
1950
- }
1951
- // A cursor from a previous process is meaningless here, so honouring it
1952
- // literally (replay from `from`) would deliver NOTHING and also lose every
1953
- // event that fell into the gap -- not only offers, but `task.cancel`,
1954
- // `notice`, `peer.*`. Re-align to the head of the window instead: the client
1955
- // asked to be resynchronised, and the ring is exactly what we still have.
1956
- const replayFrom = fromFuture ? oldest : from;
1957
- for (const entry of this.state.replayEntries(replayFrom)) deliver(entry);
1958
- }
1959
-
1960
- live = true;
1961
- for (const entry of queue) deliver(entry);
1962
- queue.length = 0;
1963
-
1964
- // §8.6: re-state work this machine was handed while its stream was down.
1965
- //
1966
- // An offer is a single event; if the stream is not connected at that instant
1967
- // -- after a relay restart, a tunnel drop, or any interruption -- the event
1968
- // is lost and the lease waits out its whole window before being swept. The
1969
- // machine is meanwhile online and idle. Re-delivering on connect is what
1970
- // makes a reconnect recover work instead of silently losing it.
1971
- //
1972
- // Only unclaimed leases are re-offered; `running` is excluded so a task that
1973
- // has already begun cannot be handed out a second time and executed twice.
1974
- const redelivered = this.state.redeliverPendingOffers(machineId);
1975
- if (redelivered.count > 0) {
1976
- this._log(`[w2m-rabbit] re-offered ${redelivered.count} pending task(s) to ${machineId} on reconnect`);
1977
- }
1978
-
1979
- // §3.2: `: keepalive` comment every 15s.
1980
- sub.timer = setInterval(() => {
1981
- if (res.writableEnded) return;
1982
- res.write(': keepalive\n\n');
1983
- }, this.keepaliveMs);
1984
- sub.timer.unref?.();
1985
-
1986
- sub.write = writeEvent;
1987
- sub.unsubscribe = unsubscribe;
1988
- this.subscribers.add(sub);
1989
- device.streams = (device.streams ?? 0) + 1;
1990
- // v0.3.0: relay-observed reconnect counter. A first attach is not a reconnect,
1991
- // so the reported `reconnect_attempts` is (attaches - 1).
1992
- this.state.noteStreamConnect(machineId);
1993
-
1994
- const onClose = () => this._closeSubscriber(sub);
1995
- req.on('close', onClose);
1996
- res.on('close', onClose);
1997
- res.on('error', onClose);
1998
- return undefined;
1999
- }
2000
-
2001
- _closeSubscriber(sub) {
2002
- if (!sub || sub.closed) return;
2003
- sub.closed = true;
2004
- this.subscribers.delete(sub);
2005
- if (sub.timer) clearInterval(sub.timer);
2006
- sub.timer = null;
2007
- sub.unsubscribe?.();
2008
- const device = this.state.devices.get(sub.machineId);
2009
- if (device) {
2010
- device.streams = Math.max(0, (device.streams ?? 1) - 1);
2011
- if (device.streams === 0) this.state.setOnline(sub.machineId, false, 'stream_closed');
2012
- }
2013
- if (!sub.res.writableEnded) {
2014
- try { sub.res.end(); } catch { /* already gone */ }
2015
- }
2016
- }
2017
- }
2018
-
2019
- /** Convenience factory. */
2020
- export function createRelayServer(options = {}) {
2021
- return new RelayServer(options);
2022
- }
2023
-
2024
- /** Create + listen in one call. */
2025
- export async function startRelayServer(options = {}) {
2026
- const relay = createRelayServer(options);
2027
- await relay.listen(options);
2028
- return relay;
2029
- }
2030
-
2031
- export {
2032
- Persistence,
2033
- ProtocolError,
2034
- RabbitState,
2035
- MAX_FRAME_BYTES,
2036
- PROTOCOL_VERSION,
2037
- rfc3339,
2038
- };
2039
- export default createRelayServer;
35
+ 'chat-text-only-v1',
36
+ ]);
37
+ import fs from 'node:fs';
38
+ import os from 'node:os';
39
+ import path from 'node:path';
40
+ import { randomBytes } from 'node:crypto';
41
+
42
+ import {
43
+ MAX_FRAME_BYTES,
44
+ PROTOCOL_VERSION,
45
+ ProtocolError,
46
+ RabbitState,
47
+ rfc3339,
48
+ } from './state.mjs';
49
+
50
+ // v1.2.7: the content-addressed blob channel carries ARTIFACTS, so its frame limit cannot be the JSON frame limit.
51
+ //
52
+ // MAX_FRAME_BYTES (64 KiB) is exactly the inline threshold: any blob is larger than an inline result by construction, so with
53
+ // one shared limit the blob channel could never accept a single byte -- a pipeline blob upload from a real relay failed with
54
+ // FRAME_TOO_LARGE while the fake relay in the tests accepted it. The dataflow hard cap stays the agent-side bound; this is the
55
+ // transport bound, and it is still bounded.
56
+ export const BLOB_FRAME_BYTES = 16 * 1024 * 1024; // 16 MiB per blob frame -- the same cap the agent's dataflow plan uses.
57
+ //
58
+ // v1.2.8-beta.1: it was 1 MiB while the agent's hard cap is 16 MiB, so an artifact between the two failed its upload with
59
+ // BLOB_UPLOAD_FAILED even though the agent had accepted it. One number for "how large may one artifact be" is the honest shape;
60
+ // the JSON frame limit stays 64 KiB for every other route.
61
+ import { createFleetMessages } from './fleet-messages.mjs';
62
+ import { can as mayAct } from '../util/policy-matrix.mjs';
63
+ import { createFleetMemory } from './fleet-memory.mjs';
64
+ import { createJoinChallenges } from './join-challenge.mjs';
65
+ import { createAdmission } from './admission.mjs';
66
+ import { Persistence } from './persistence.mjs';
67
+ import { PeerRegistry } from './peers.mjs';
68
+ import { aggregateTask, buildReport, taskProgress } from './report.mjs';
69
+ import { renderMetrics, METRICS_CONTENT_TYPE } from './metrics.mjs';
70
+ // v0.3.0 §9: request signing. This import is the entire point of the module --
71
+ // until the HTTP layer calls it, HMAC support does not exist for any real request.
72
+ import { DEFAULT_SKEW_SECONDS, NonceCache, verifyRequest } from '../signing.mjs';
73
+ // v0.7.11: per-device Ed25519 verification, tried before the shared secret.
74
+ import {
75
+ SIGNATURE_HEADER as DEVICE_SIGNATURE_HEADER,
76
+ verifyDeviceSignature,
77
+ } from '../chatops/device-signing.mjs';
78
+
79
+ export const DEFAULT_SSE_KEEPALIVE_MS = 15_000; // §3.2: every 15s a `: keepalive` line
80
+ export const DEFAULT_SWEEP_INTERVAL_MS = 1_000;
81
+ export const DEFAULT_PAIR_RATE_LIMIT_PER_MINUTE = 5; // v0.1.2 §6
82
+ // The window length is a unit and lives with the limiter. Both names are kept: `DEFAULT_WINDOW_MS` is the
83
+ // local binding this module uses, and `RATE_LIMIT_WINDOW_MS` is the name this module has always exported.
84
+ // `export ... from` would NOT have worked here -- it re-exports without creating a local binding, so the
85
+ // constructor below saw `RATE_LIMIT_WINDOW_MS is not defined` while the export itself looked fine.
86
+ import { DEFAULT_WINDOW_MS, SlidingWindowRateLimiter } from '../util/rate-limit.mjs';
87
+ export { DEFAULT_WINDOW_MS as RATE_LIMIT_WINDOW_MS };
88
+ export const DEFAULT_STATE_DIR = path.join(os.homedir(), '.w2m');
89
+
90
+ /**
91
+ * The environment variable that opts in to printing the operator token on a start that did not
92
+ * generate it (MEDIUM-6 of the v0.5.9 review).
93
+ *
94
+ * The value is printed **once, on the start that generated it**, because that is the only moment at
95
+ * which it exists nowhere else yet; every other start has `<state>/operator-token.txt` to read it
96
+ * from, and a relay's stdout ends up in journald, a container log or a CI transcript — where a
97
+ * credential that authorises dispatching to the whole fleet does not belong. `W2M_PRINT_OPERATOR_TOKEN=1`
98
+ * (or `printOperatorToken: true`, which is the same switch for an embedder) restores the old
99
+ * behaviour for an operator who genuinely needs it in the log, and says so in the banner when it does.
100
+ */
101
+ export const PRINT_OPERATOR_TOKEN_ENV = 'W2M_PRINT_OPERATOR_TOKEN';
102
+
103
+ /**
104
+ * Resolve that opt-in, so the relay and the CLI agree on one answer.
105
+ *
106
+ * @param {object} [options] Relay options; `printOperatorToken: true` is the programmatic spelling.
107
+ * @returns {'option'|'env'|null} Why printing was asked for, or null.
108
+ */
109
+ export function printOperatorTokenOptIn(options = {}) {
110
+ if (options.printOperatorToken === true) return 'option';
111
+ const raw = process.env[PRINT_OPERATOR_TOKEN_ENV];
112
+ if (raw === '1' || raw === 'true') return 'env';
113
+ return null;
114
+ }
115
+
116
+ /* ------------------------------------------------------------------ */
117
+ /* helpers */
118
+ /* ------------------------------------------------------------------ */
119
+
120
+ /** v0.1.2 §3: `/w2m/` → `/w2m`; '' / '/' → '/'. Never returns a trailing slash. */
121
+ export function normalizeBasePath(value) {
122
+ if (value === undefined || value === null || value === '') return '/';
123
+ let p = String(value).trim();
124
+ if (p === '' || p === '/') return '/';
125
+ if (!p.startsWith('/')) p = `/${p}`;
126
+ p = p.replace(/\/{2,}/g, '/').replace(/\/+$/, '');
127
+ return p === '' ? '/' : p;
128
+ }
129
+
130
+ function normalizePath(pathname) {
131
+ if (!pathname) return '/';
132
+ return pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname;
133
+ }
134
+
135
+ function sendJson(res, status, body, extraHeaders = {}) {
136
+ if (res.writableEnded) return undefined;
137
+ const payload = Buffer.from(JSON.stringify(body), 'utf8');
138
+ res.writeHead(status, {
139
+ 'content-type': 'application/json; charset=utf-8',
140
+ 'content-length': payload.length,
141
+ 'cache-control': 'no-store',
142
+ ...extraHeaders,
143
+ });
144
+ return res.end(payload);
145
+ }
146
+
147
+ function sendError(res, err) {
148
+ if (err instanceof ProtocolError) {
149
+ const body = err.toBody();
150
+ // §6 just says the 429 "carries retry_after_seconds" without pinning the
151
+ // location, so it is emitted both inside error.detail (the §7 shape) and as a
152
+ // top-level convenience field. Both are additive.
153
+ const retryAfter = err.detail?.retry_after_seconds;
154
+ if (typeof retryAfter === 'number') body.retry_after_seconds = retryAfter;
155
+ sendJson(res, err.status, body, err.headers ?? {});
156
+ return;
157
+ }
158
+ sendJson(res, 500, {
159
+ error: {
160
+ code: 'INTERNAL',
161
+ message: err?.message ? String(err.message) : 'internal error',
162
+ detail: {},
163
+ },
164
+ });
165
+ }
166
+
167
+ function bearerToken(req) {
168
+ const header = req.headers.authorization;
169
+ if (typeof header !== 'string') return null;
170
+ const m = /^Bearer\s+(.+)$/i.exec(header.trim());
171
+ return m ? m[1].trim() : null;
172
+ }
173
+
174
+ function firstHeaderValue(value) {
175
+ const raw = Array.isArray(value) ? value[0] : value;
176
+ if (typeof raw !== 'string') return null;
177
+ const first = raw.split(',')[0].trim();
178
+ return first === '' ? null : first;
179
+ }
180
+
181
+ /**
182
+ * Read one header case-insensitively from a plain Node headers object.
183
+ *
184
+ * `signing.mjs` does this internally, but the device-key path needs it before deciding whether that
185
+ * module applies at all, so it is needed here too. Node lower-cases incoming header names, and a
186
+ * test that builds a request by hand may not, so both spellings are tried.
187
+ */
188
+ function readHeader(headers, name) {
189
+ if (headers === null || headers === undefined) return null;
190
+ const found = Object.keys(headers).find((k) => k.toLowerCase() === name);
191
+ return found === undefined ? null : firstHeaderValue(headers[found]);
192
+ }
193
+
194
+ /**
195
+ * Read a request body with a hard byte cap.
196
+ * @returns {Promise<{tooLarge?:boolean, buffer?:Buffer, aborted?:boolean}>}
197
+ */
198
+ function readBody(req, limit) {
199
+ return new Promise((resolve) => {
200
+ const declared = Number(req.headers['content-length']);
201
+ if (Number.isFinite(declared) && declared > limit) {
202
+ req.resume();
203
+ resolve({ tooLarge: true });
204
+ return;
205
+ }
206
+ const chunks = [];
207
+ let total = 0;
208
+ let settled = false;
209
+ const finish = (value) => {
210
+ if (settled) return;
211
+ settled = true;
212
+ resolve(value);
213
+ };
214
+ req.on('data', (chunk) => {
215
+ if (settled) return;
216
+ total += chunk.length;
217
+ if (total > limit) {
218
+ finish({ tooLarge: true });
219
+ req.resume();
220
+ return;
221
+ }
222
+ chunks.push(chunk);
223
+ });
224
+ req.on('end', () => finish({ buffer: Buffer.concat(chunks) }));
225
+ req.on('error', () => finish({ aborted: true }));
226
+ req.on('aborted', () => finish({ aborted: true }));
227
+ });
228
+ }
229
+
230
+ /**
231
+ * Read the raw body bytes with a hard byte cap.
232
+ *
233
+ * v0.3.0 §9: the body is read exactly ONCE per request and the bytes are kept,
234
+ * because an HMAC covers the exact received octets -- re-serialising parsed JSON
235
+ * would produce different bytes (key order, whitespace) and break every signature.
236
+ */
237
+ async function readRawBody(req, limit) {
238
+ const body = await readBody(req, limit);
239
+ if (body.tooLarge) {
240
+ throw new ProtocolError('FRAME_TOO_LARGE', `request frame exceeds ${limit} bytes`, { limit_bytes: limit });
241
+ }
242
+ if (body.aborted) throw new ProtocolError('BAD_REQUEST', 'request aborted before the body was complete');
243
+ return body.buffer;
244
+ }
245
+
246
+ /** Parse an already-read body buffer as a JSON object. */
247
+ function parseJsonBody(buffer) {
248
+ const raw = buffer === null || buffer === undefined ? '' : buffer.toString('utf8');
249
+ if (raw.trim() === '') return {};
250
+ try {
251
+ const parsed = JSON.parse(raw);
252
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
253
+ throw new ProtocolError('BAD_REQUEST', 'request body must be a JSON object');
254
+ }
255
+ return parsed;
256
+ } catch (err) {
257
+ if (err instanceof ProtocolError) throw err;
258
+ throw new ProtocolError('BAD_REQUEST', 'request body is not valid JSON', { parse_error: String(err.message) });
259
+ }
260
+ }
261
+
262
+ /** Methods that can carry a body the signature must cover. */
263
+ function methodHasBody(method) {
264
+ return method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE';
265
+ }
266
+
267
+ /* ------------------------------------------------------------------ */
268
+ /* request signing (v0.3.0 §9) */
269
+ /* ------------------------------------------------------------------ */
270
+
271
+ /**
272
+ * Operator-facing hints. The `code` says what happened; the hint says what to do
273
+ * about it, because "signature mismatch" without a direction is a support ticket.
274
+ */
275
+ const SIGNATURE_HINTS = Object.freeze({
276
+ SIGNATURE_REQUIRED: 'this relay runs with --require-signature; sign the request (PROTOCOL-v0.3.0.md §9)',
277
+ SIGNATURE_INCOMPLETE: 'all three of X-W2M-Signature, X-W2M-Timestamp and X-W2M-Nonce must be present together',
278
+ SIGNATURE_MISMATCH: 'the signature matches no configured secret — check for a stale or half-rotated secret',
279
+ SIGNATURE_EXPIRED: 'the timestamp is outside the accepted clock-skew window — check the sending machine clock',
280
+ SIGNATURE_REPLAY: 'this nonce was already used — generate a fresh nonce for every request',
281
+ SIGNATURE_BAD_TIMESTAMP: 'X-W2M-Timestamp must be whole unix seconds',
282
+ SIGNATURE_BAD_NONCE: 'X-W2M-Nonce must be at least 8 characters',
283
+ SIGNING_NOT_CONFIGURED:
284
+ 'the relay has --require-signature enabled but no --signing-secret configured; '
285
+ + 'this is a RELAY configuration error, not a client problem',
286
+ });
287
+
288
+ const SIGNATURE_PROTOCOL_CODES = new Set(Object.keys(SIGNATURE_HINTS));
289
+
290
+ /** Turn a `verifyRequest` rejection into the §7 error envelope.
291
+ * `ProtocolError` resolves the HTTP status from `ERROR_STATUS`, so the mapping
292
+ * table lives in exactly one place (state.mjs) instead of being duplicated here. */
293
+ function signatureError(decision) {
294
+ const code = SIGNATURE_PROTOCOL_CODES.has(decision.code) ? decision.code : 'SIGNATURE_MISMATCH';
295
+ return new ProtocolError(code, decision.error, {
296
+ code,
297
+ reason: decision.error,
298
+ hint: SIGNATURE_HINTS[code] ?? null,
299
+ });
300
+ }
301
+
302
+ /**
303
+ * Read an optional secret, failing loudly on a value that is present but unusable.
304
+ *
305
+ * `signing.mjs` calls a silently-disabled secret the failure mode it exists to
306
+ * prevent. Treating `signingSecret: 12345` (or `{}`) as "not configured" would do
307
+ * exactly that at the wiring layer, so it is an error instead.
308
+ */
309
+ function readSecretValue(value, label) {
310
+ if (value === undefined || value === null || value === '') return null;
311
+ if (typeof value !== 'string') {
312
+ throw new ProtocolError('BAD_REQUEST',
313
+ `${label} must be a non-empty string when provided (got ${typeof value}); `
314
+ + 'refusing to silently run without the signing you asked for', { option: label, received_type: typeof value });
315
+ }
316
+ return value;
317
+ }
318
+
319
+ /* ------------------------------------------------------------------ */
320
+ /* pair rate limiting (v0.1.2 §6) */
321
+ /* ------------------------------------------------------------------ */
322
+
323
+ /**
324
+ * Per-IP sliding window. Successful pairs count too, otherwise a working pairing
325
+ * code could be used to flush the failure budget.
326
+ */
327
+ /*
328
+ * The limiter MOVED to `src/util/rate-limit.mjs` in v0.30.0.
329
+ *
330
+ * It never used anything from this file, and when the approval gate needed the same window, importing it
331
+ * from here made an `agent` module depend on the `relay` layer -- which `scripts/layer-model.mjs` refused,
332
+ * correctly. `agent` is rank 2 and `relay` is rank 3, so the edge points up; moving the class to `util`
333
+ * (rank 0, a leaf) lets both depend on it in the only direction the model allows.
334
+ *
335
+ * Re-exported rather than merely imported, because `SlidingWindowRateLimiter` and `RATE_LIMIT_WINDOW_MS` are
336
+ * part of this module's published surface and `test/relay.test.mjs` imports the class from here.
337
+ */
338
+ export { SlidingWindowRateLimiter };
339
+
340
+ /* ------------------------------------------------------------------ */
341
+ /* server */
342
+ /* ------------------------------------------------------------------ */
343
+
344
+ export class RelayServer {
345
+ constructor(options = {}) {
346
+ this.options = options;
347
+ this.basePath = normalizeBasePath(options.basePath);
348
+ this.trustProxy = options.trustProxy === true;
349
+ this.frameLimitBytes = options.frameLimitBytes ?? MAX_FRAME_BYTES;
350
+ this.keepaliveMs = options.keepaliveMs ?? DEFAULT_SSE_KEEPALIVE_MS;
351
+ this.sweepIntervalMs = options.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
352
+ this.pairRateLimitPerMinute = Number.isFinite(options.pairRateLimitPerMinute)
353
+ ? options.pairRateLimitPerMinute : DEFAULT_PAIR_RATE_LIMIT_PER_MINUTE;
354
+
355
+ // §3: `logger: null` silences. The default writes to STDERR on purpose —
356
+ // stdout belongs to protocol output (e.g. the CLI's `--json` line), and mixing
357
+ // prose into it breaks `| ConvertFrom-Json` / `| jq`.
358
+ this.logger = options.logger === undefined
359
+ ? ((msg) => process.stderr.write(`${msg}\n`))
360
+ : options.logger;
361
+ this.startupMessages = [];
362
+
363
+ /* ---- persistence (§7) ---- */
364
+ const rawStateDir = options.stateDir ?? (typeof options.state === 'string' ? options.state : null);
365
+ const persistEnabled = options.persist !== false && rawStateDir !== '';
366
+ const stateDir = persistEnabled
367
+ ? path.resolve(rawStateDir === null || rawStateDir === undefined ? DEFAULT_STATE_DIR : String(rawStateDir))
368
+ : null;
369
+ this.persistence = new Persistence({
370
+ dir: stateDir,
371
+ now: () => (this.state ? this.state.nowMs() : Date.now()),
372
+ });
373
+
374
+ /* ---- state ---- */
375
+ const injected = options.state && typeof options.state === 'object' ? options.state : null;
376
+ this.state = injected ?? new RabbitState({ ...options, persistence: this.persistence });
377
+ this.state.setPersistence(this.persistence);
378
+
379
+ // v0.3.9: P2P signalling. Ephemeral by design -- see src/relay/peers.mjs.
380
+ this.peers = options.peers ?? new PeerRegistry({ nowMs: () => this.state.nowMs() });
381
+
382
+ this.relayId = this.state.relayId;
383
+ this._startedAtMs = this.state.nowMs();
384
+
385
+ /* ---- startup recovery (§7) ---- */
386
+ if (this.persistence.enabled) {
387
+ const snapshot = this.persistence.loadDevices();
388
+ this.persistence.devicesMissing = snapshot.missing;
389
+ const ledger = this.persistence.loadLedger();
390
+ const revived = this.state.restore({ devices: snapshot.devices, ledger });
391
+ this.persistence.revivedDevices = revived.revived_devices;
392
+ this.persistence.revivedTasks = revived.revived_tasks;
393
+ }
394
+
395
+ /* ---- operator token (§5) ---- */
396
+ this._initOperatorToken(options);
397
+
398
+ /* ---- v0.3.0 §9 request signing ---- */
399
+ this.signingSecrets = [
400
+ readSecretValue(options.signingSecret, 'signingSecret'),
401
+ readSecretValue(options.signingSecretPrevious, 'signingSecretPrevious'),
402
+ ].filter((s) => s !== null); // newest first: keyIndex 0 = current, 1 = previous
403
+ this.signingConfigured = this.signingSecrets.length > 0;
404
+ this.requireSignature = options.requireSignature === true;
405
+ /**
406
+ * v0.3.3: whether `GET /metrics` exists.
407
+ *
408
+ * Defaults to off. A monitoring endpoint that appears without being asked for is a surface nobody
409
+ * chose, and the operator can turn it on with `--metrics` when a scraper exists.
410
+ */
411
+ this.metricsEnabled = options.metrics === true;
412
+ this.signatureSkewSeconds = Number.isFinite(options.signatureSkewSeconds)
413
+ ? options.signatureSkewSeconds : DEFAULT_SKEW_SECONDS;
414
+ // One cache per relay instance: replay defence is stateful, and two relays
415
+ // sharing a cache would let a nonce blocked on one be replayed on the other.
416
+ this.nonceCache = options.nonceCache ?? new NonceCache({
417
+ max: options.nonceCacheMax ?? 20_000,
418
+ retentionMs: options.nonceCacheRetentionMs ?? 10 * 60_000,
419
+ });
420
+ this.nonceRetentionMs = options.nonceCacheRetentionMs ?? 10 * 60_000;
421
+
422
+ /* ---- /v1/pair rate limit (§6) ---- */
423
+ this.pairLimiter = new SlidingWindowRateLimiter({
424
+ limit: this.pairRateLimitPerMinute,
425
+ windowMs: DEFAULT_WINDOW_MS,
426
+ now: () => this.state.nowMs(),
427
+ });
428
+
429
+ /* ---- transport ---- */
430
+ this.tlsCertPath = options.tlsCert ?? null;
431
+ this.tlsKeyPath = options.tlsKey ?? null;
432
+ if ((this.tlsCertPath && !this.tlsKeyPath) || (!this.tlsCertPath && this.tlsKeyPath)) {
433
+ throw new ProtocolError('BAD_REQUEST', 'tlsCert and tlsKey must be provided together', {
434
+ tls_cert: this.tlsCertPath, tls_key: this.tlsKeyPath,
435
+ });
436
+ }
437
+ const handler = (req, res) => {
438
+ this.handle(req, res).catch((err) => this.reportError(req, res, err));
439
+ };
440
+ if (this.tlsCertPath) {
441
+ let cert;
442
+ let key;
443
+ try {
444
+ cert = fs.readFileSync(this.tlsCertPath);
445
+ key = fs.readFileSync(this.tlsKeyPath);
446
+ } catch (err) {
447
+ // Never silently fall back to plain HTTP: that would be a security downgrade.
448
+ throw new ProtocolError('BAD_REQUEST', `cannot read TLS material: ${err.message}`, {
449
+ tls_cert: this.tlsCertPath, tls_key: this.tlsKeyPath,
450
+ });
451
+ }
452
+ this.server = https.createServer({ cert, key }, handler);
453
+ this.scheme = 'https';
454
+ } else {
455
+ this.server = http.createServer(handler);
456
+ this.scheme = 'http';
457
+ }
458
+
459
+ // v0.1.2 BUG-1: an externally supplied code must be REGISTERED, not merely
460
+ // assigned — otherwise the banner advertises a code /v1/pair does not know.
461
+ this.pairingCode = typeof options.pairingCode === 'string' && options.pairingCode.trim() !== ''
462
+ ? this.state.registerPairingCode(options.pairingCode, { ttlMs: options.pairingCodeTtlMs })
463
+ : this.state.createPairingCode();
464
+ this.subscribers = new Set();
465
+ this.host = null;
466
+ this.port = null;
467
+ this._sweepTimer = null;
468
+
469
+ // SSE connections are long-lived: never let Node time the request out.
470
+ this.server.requestTimeout = options.requestTimeoutMs ?? 0;
471
+ this.server.headersTimeout = options.headersTimeoutMs ?? 60_000;
472
+
473
+ this._buildStartupMessages();
474
+ }
475
+
476
+ /**
477
+ * Diagnostics sink. `logger: null` is the documented "silent" value, and a
478
+ * caller-supplied logger may throw — neither may break a request. (A direct
479
+ * `this.logger(...)` call after `res.writeHead()` once threw a TypeError that
480
+ * surfaced as "Cannot write headers after they are sent to the client".)
481
+ */
482
+ _log(message) {
483
+ if (typeof this.logger !== 'function') return;
484
+ try {
485
+ this.logger(message);
486
+ } catch { /* a broken logger must never break the relay */ }
487
+ }
488
+
489
+ /**
490
+ * Last-resort error path for a request whose handler rejected.
491
+ * Once the status line is on the wire (SSE, streamed report) the response can no
492
+ * longer be rewritten — the only honest action is to end the stream, loudly.
493
+ */
494
+ reportError(req, res, err) {
495
+ if (res.headersSent) {
496
+ this._log(`[w2m-rabbit] !! error after the response had started on ${req.method} ${req.url}: `
497
+ + `${err?.stack ?? err}`);
498
+ try { res.end(); } catch { /* already gone */ }
499
+ return;
500
+ }
501
+ sendError(res, err);
502
+ }
503
+
504
+ _initOperatorToken(options) {
505
+ const wantsRequired = options.operatorTokenRequired !== false;
506
+ const explicit = options.operatorToken;
507
+ const persisted = this.persistence.enabled ? this.persistence.readOperatorToken() : null;
508
+ /**
509
+ * Why the token may be printed, when it may be: `'generated'` is the one start whose token exists
510
+ * nowhere else until this process writes it, and `'option'`/`'env'` are the documented opt-ins.
511
+ * @type {'generated'|'option'|'env'|null}
512
+ */
513
+ this.operatorTokenPrintReason = printOperatorTokenOptIn(options);
514
+
515
+ if (!wantsRequired || explicit === '') {
516
+ this.operatorToken = null;
517
+ this.operatorTokenRequired = false;
518
+ this.operatorTokenSource = 'disabled';
519
+ this.operatorTokenPrintable = false;
520
+ this.operatorTokenPrintReason = null;
521
+ return;
522
+ }
523
+ this.operatorTokenRequired = true;
524
+ if (typeof explicit === 'string' && explicit.length > 0) {
525
+ this.operatorToken = explicit;
526
+ this.operatorTokenSource = 'option';
527
+ this.persistence.writeOperatorToken(explicit);
528
+ } else if (persisted) {
529
+ // §5.1 + §7: keep the token stable across restarts, otherwise every restart
530
+ // would silently invalidate the plugin's configured operatorToken.
531
+ this.operatorToken = persisted;
532
+ this.operatorTokenSource = 'restored';
533
+ } else {
534
+ this.operatorToken = randomBytes(32).toString('hex');
535
+ this.operatorTokenSource = 'generated';
536
+ this.persistence.writeOperatorToken(this.operatorToken);
537
+ }
538
+ // MEDIUM-6: the material is printed only by the start that generated it, or when the operator has
539
+ // explicitly asked for it. "The posture, never the material" still holds everywhere else.
540
+ if (this.operatorTokenSource === 'generated') {
541
+ this.operatorTokenPrintReason = this.operatorTokenPrintReason ?? 'generated';
542
+ }
543
+ this.operatorTokenPrintable = this.operatorTokenPrintReason !== null;
544
+ }
545
+
546
+ _buildStartupMessages() {
547
+ const m = this.startupMessages;
548
+ m.push(`[w2m-rabbit] relay_id=${this.relayId}`);
549
+ m.push(`[w2m-rabbit] base_path=${this.basePath}${this.basePath === '/' ? ' (root)' : ''}`);
550
+ m.push(`[w2m-rabbit] trust_proxy=${this.trustProxy}${this.trustProxy ? '' : ' (X-Forwarded-* ignored)'}`);
551
+ m.push(`[w2m-rabbit] pair_rate_limit=${this.pairRateLimitPerMinute === 0 ? 'disabled' : `${this.pairRateLimitPerMinute}/min/IP`}`);
552
+ // §9: report the posture, never the material.
553
+ if (this.signingConfigured) {
554
+ m.push(`[w2m-rabbit] request signing=ON (${this.signingSecrets.length} secret(s)`
555
+ + `${this.signingSecrets.length > 1 ? ', rotation active' : ''})`
556
+ + `${this.requireSignature ? ' - REQUIRED: unsigned /v1/* is rejected with 401' : ' - optional: unsigned requests are still accepted'}`);
557
+ } else if (this.requireSignature) {
558
+ m.push('[w2m-rabbit] !! CONFIGURATION ERROR: --require-signature is set but no --signing-secret was provided. '
559
+ + 'Every /v1/* request will fail with 500 SIGNING_NOT_CONFIGURED (this is the RELAY\'s fault, not the client\'s). '
560
+ + 'Either set --signing-secret or drop --require-signature.');
561
+ } else {
562
+ m.push('[w2m-rabbit] request signing=off (no signing secret configured; behaviour is identical to v0.2.3)');
563
+ }
564
+ if (this.persistence.enabled) {
565
+ m.push(`[w2m-rabbit] persistence=on dir=${this.persistence.dir} `
566
+ + `revived_devices=${this.persistence.revivedDevices} revived_tasks=${this.persistence.revivedTasks}`);
567
+ } else {
568
+ m.push('[w2m-rabbit] persistence=off (in-memory only; devices and tasks are lost on restart)');
569
+ }
570
+ if (this.persistence.devicesCorrupt) {
571
+ m.push('[w2m-rabbit] !! WARNING: devices.json was CORRUPT — started from an EMPTY device table; '
572
+ + 'every machine must pair again. The damaged file was left untouched.');
573
+ }
574
+ for (const w of this.persistence.warnings) {
575
+ m.push(`[w2m-rabbit] !! ${w.file}: ${w.message}`);
576
+ }
577
+ if (this.operatorTokenRequired) {
578
+ // MEDIUM-6: the token is material, not posture, so it is printed as rarely as it can be — on the
579
+ // start that generated it (`operatorTokenSource === 'generated'`) or when `printOperatorToken` /
580
+ // `W2M_PRINT_OPERATOR_TOKEN=1` asked for it — and once, never twice. Where it is stored is said in
581
+ // every case, including the case where there is nowhere to store it.
582
+ //
583
+ // DELIBERATE, do not "fix": on that one start the CLI prints the value a second time, in its own
584
+ // stdout banner. Two sinks for one start is not a leak — the defect was printing it on *every*
585
+ // start, restored ones included — and the box is the documented first-start handoff a human reads
586
+ // (docs/DEPLOY.md §2.4, test/crossnetwork.test.mjs). Collapsing the pair would trade a documented
587
+ // behaviour for one saved line on one start.
588
+ const tokenFile = this.persistence.operatorTokenFile ?? null;
589
+ const stored = tokenFile === null
590
+ ? 'persistence is off, so this token exists only in this process and cannot be read back later'
591
+ : `stored at ${tokenFile} (0600)`;
592
+ if (this.operatorTokenPrintable) {
593
+ m.push(`[w2m-rabbit] operator token (${this.operatorTokenSource}) - required for POST /v1/task:`);
594
+ m.push(`[w2m-rabbit] ${this.operatorToken}`);
595
+ m.push(`[w2m-rabbit] printed once because ${this.operatorTokenPrintReason === 'generated'
596
+ ? 'this start generated it' : `${this.operatorTokenPrintReason} asked for it (${PRINT_OPERATOR_TOKEN_ENV})`}`
597
+ + ` - and ${stored}`);
598
+ } else {
599
+ m.push(`[w2m-rabbit] operator token (${this.operatorTokenSource}) - required for POST /v1/task, NOT printed`);
600
+ m.push(`[w2m-rabbit] read it from ${tokenFile ?? '<state>/operator-token.txt'} - ${stored}`);
601
+ m.push(`[w2m-rabbit] set ${PRINT_OPERATOR_TOKEN_ENV}=1 to print it on this start as well`);
602
+ }
603
+ } else {
604
+ m.push('[w2m-rabbit] !! WARNING: OPERATOR TOKEN DISABLED (--operator-token "") - '
605
+ + 'any device that can reach this relay can dispatch tasks to the whole group. '
606
+ + 'Only acceptable on a trusted LAN / for debugging; /v1/task then requires a device token.');
607
+ }
608
+ }
609
+
610
+ get url() {
611
+ if (this.host === null || this.port === null) return null;
612
+ const host = this.host.includes(':') ? `[${this.host}]` : this.host;
613
+ const prefix = this.basePath === '/' ? '' : this.basePath;
614
+ return `${this.scheme}://${host}:${this.port}${prefix}`;
615
+ }
616
+
617
+ listen({ host = '127.0.0.1', port = 0 } = {}) {
618
+ return new Promise((resolve, reject) => {
619
+ const onError = (err) => reject(err);
620
+ this.server.once('error', onError);
621
+ this.server.listen(port, host, () => {
622
+ this.server.removeListener('error', onError);
623
+ const addr = this.server.address();
624
+ this.host = addr.address;
625
+ this.port = addr.port;
626
+ this._sweepTimer = setInterval(() => {
627
+ this.maintenance();
628
+ }, this.sweepIntervalMs);
629
+ this._sweepTimer.unref?.();
630
+ if (this.logger) {
631
+ this._log(`[w2m-rabbit] listening on ${this.url} pairing code: ${this.pairingCode}`);
632
+ for (const message of this.startupMessages) this._log(message);
633
+ }
634
+ resolve({
635
+ host: this.host,
636
+ port: this.port,
637
+ url: this.url,
638
+ pairingCode: this.pairingCode,
639
+ relayId: this.relayId,
640
+ basePath: this.basePath,
641
+ operatorToken: this.operatorToken,
642
+ operatorTokenRequired: this.operatorTokenRequired,
643
+ // The CLI prints its own banner, so it has to reach the same conclusion this class did rather
644
+ // than re-deriving one from the token's presence (MEDIUM-6).
645
+ operatorTokenSource: this.operatorTokenSource,
646
+ operatorTokenPrintable: this.operatorTokenPrintable === true,
647
+ operatorTokenPrintReason: this.operatorTokenPrintReason ?? null,
648
+ scheme: this.scheme,
649
+ });
650
+ });
651
+ });
652
+ }
653
+
654
+ sweep() {
655
+ return this.state.sweepExpired();
656
+ }
657
+
658
+ close() {
659
+ return new Promise((resolve) => {
660
+ if (this._sweepTimer) clearInterval(this._sweepTimer);
661
+ this._sweepTimer = null;
662
+ // v0.4.0: a deferred P2P offer is a pending timer, and a closed relay must not fire one.
663
+ this.state.clearPendingOffers?.();
664
+ for (const sub of [...this.subscribers]) this._closeSubscriber(sub);
665
+ this.server.close(() => resolve());
666
+ this.server.closeAllConnections?.();
667
+ });
668
+ }
669
+
670
+ /* ---------------- proxy awareness (v0.1.2 §3) ---------------- */
671
+
672
+ /** `X-Forwarded-For` first hop is honoured ONLY under `trustProxy`. */
673
+ clientIp(req) {
674
+ if (this.trustProxy) {
675
+ const forwarded = firstHeaderValue(req.headers['x-forwarded-for']);
676
+ if (forwarded) return forwarded;
677
+ }
678
+ return req.socket?.remoteAddress ?? 'unknown';
679
+ }
680
+
681
+ /** `X-Forwarded-Proto` is honoured ONLY under `trustProxy`. */
682
+ effectiveScheme(req) {
683
+ if (this.trustProxy) {
684
+ const proto = firstHeaderValue(req.headers['x-forwarded-proto']);
685
+ if (proto) return proto.toLowerCase();
686
+ }
687
+ return this.scheme;
688
+ }
689
+
690
+ /* ---------------- request dispatch ---------------- */
691
+
692
+ async handle(req, res) {
693
+ let url;
694
+ try {
695
+ url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
696
+ } catch {
697
+ throw new ProtocolError('BAD_REQUEST', 'malformed request URL');
698
+ }
699
+ const rawPath = url.pathname;
700
+ const method = (req.method ?? 'GET').toUpperCase();
701
+
702
+ // §3: strip the deployment prefix, then route. `/healthz` also answers at the
703
+ // root because health checks are usually pointed straight at the container.
704
+ // `stripped` keeps the literal request path (no trailing-slash normalisation)
705
+ // because that is what a signature covers; `path` is the normalised route key.
706
+ let stripped;
707
+ if (this.basePath === '/') {
708
+ stripped = rawPath;
709
+ } else if (rawPath === this.basePath) {
710
+ stripped = '/';
711
+ } else if (rawPath.startsWith(`${this.basePath}/`)) {
712
+ stripped = rawPath.slice(this.basePath.length);
713
+ } else if (rawPath === '/healthz') {
714
+ stripped = '/healthz';
715
+ } else {
716
+ // Outside the prefix: an explicit 404, never a gateway-style 502.
717
+ throw new ProtocolError('NOT_FOUND', `path is outside the configured base path ${this.basePath}`, {
718
+ path: rawPath, base_path: this.basePath,
719
+ });
720
+ }
721
+ const path = normalizePath(stripped);
722
+ // B3/V12:basePath 剥离与 URL 规范化之后立即拒绝数据面 blob 路由,
723
+ // 覆盖 `..`、`%2e%2e`、basePath 前缀等变形;在读取 body 之前返回。
724
+ // Percent-encoded dot segments are not resolved by WHATWG URL, so the gate resolves them itself:
725
+ const gatePath = (() => {
726
+ try { return normalizePath(new URL(decodeURIComponent(path), 'http://gate.invalid').pathname); } catch { return path; }
727
+ })();
728
+ if (gatePath === '/v1/fleet/blob-sessions' || gatePath.startsWith('/v1/fleet/blob-sessions/')
729
+ || gatePath === '/v1/fleet/blob-latest' || gatePath.startsWith('/v1/fleet/blobs')) {
730
+ const refusal = guardBlobRoute(method);
731
+ return sendJson(res, refusal.status, refusal.body);
732
+ }
733
+ const signedPath = `${stripped}${url.search}`;
734
+
735
+ // v0.3.0 §9: read the body ONCE, before routing. An HMAC covers the exact
736
+ // received bytes, so no route may consume the stream first and re-serialise.
737
+ /** v1.2.7: only the blob channel gets the larger frame cap; every JSON route keeps the 64 KiB frame limit. */
738
+ const blobFrameLimit = method === 'PUT' && path.startsWith('/v1/fleet/blobs/') ? BLOB_FRAME_BYTES : this.frameLimitBytes;
739
+ const rawBody = methodHasBody(method) ? await readRawBody(req, blobFrameLimit) : null;
740
+ const takeJson = () => parseJsonBody(rawBody);
741
+
742
+ /* ---- public endpoints ---- */
743
+ // `/healthz` and `/v1/pair` are permanently exempt from signature checks:
744
+ // the first is an operations probe that must not need credentials, the second
745
+ // is the bootstrap step that happens before the two sides share a secret.
746
+ if (method === 'GET' && path === '/healthz') {
747
+ return sendJson(res, 200, this.healthBody(req));
748
+ }
749
+ if (method === 'POST' && path === '/v1/pair') {
750
+ this.enforcePairRateLimit(req);
751
+ const result = this.state.pair(takeJson());
752
+ return sendJson(res, 200, result);
753
+ }
754
+ /**
755
+ * v1.2.4: cancellation over HTTP. state.cancelTask has existed since the task ledger gained a cancel flag, but no route
756
+ * reached it -- the capability was real on paper and unreachable on the wire. Same authorization as dispatch: cancelling
757
+ * a fleet task is an operator act, not something a device token confers.
758
+ */
759
+ const cancelMatch = path.match(/^\/v1\/tasks\/([^/]+)\/cancel$/);
760
+ if (method === 'POST' && cancelMatch !== null) {
761
+ this.authorizeTaskDispatch(req);
762
+ this.enforceSignature(req, { method, signedPath, body: rawBody });
763
+ const body = takeJson();
764
+ const cancelled = this.state.cancelTask(decodeURIComponent(cancelMatch[1]), typeof body.reason === 'string' && body.reason !== '' ? body.reason : undefined);
765
+ return sendJson(res, 200, {
766
+ protocol_version: PROTOCOL_VERSION,
767
+ rabbit_time: this.state.nowIso(),
768
+ ok: true,
769
+ task: { task_id: cancelled.task_id, cancelled: cancelled.cancelled === true, cancel_reason: cancelled.cancel_reason ?? null },
770
+ });
771
+ }
772
+ /**
773
+ * v1.2.5: a role is an operator decision, so this route sits with dispatch — before the device gate, where an operator
774
+ * token is accepted.
775
+ */
776
+ const memberMatch = path.match(/^\/v1\/fleet\/members\/([^/]+)$/);
777
+ if (method === 'PUT' && memberMatch !== null) {
778
+ this.authorizeTaskDispatch(req);
779
+ this.enforceSignature(req, { method, signedPath, body: rawBody });
780
+ const body = takeJson();
781
+ const changed = this.state.setMemberRole(decodeURIComponent(memberMatch[1]), body.role);
782
+ if (changed.ok !== true) {
783
+ return sendJson(res, 400, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: changed.code }, detail: changed.detail ?? null });
784
+ }
785
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, member: changed.member });
786
+ }
787
+ /**
788
+ * v0.3.3 `/metrics`: machine-readable fleet state for a monitoring system.
789
+ *
790
+ * Token-free, like `/healthz` and for the same reason: a scraper that needs the operator token
791
+ * would put that token in a Prometheus config file, which is a credential in a place nobody
792
+ * reviews. It is also exempt from signature checks for the same reason -- there is no secret to
793
+ * sign with on the scraper side.
794
+ *
795
+ * Exposed only when the relay was asked for it (`--metrics`). A monitoring endpoint that appears
796
+ * by default is a surface nobody chose, and the whole point of the defaults elsewhere is that the
797
+ * relay binds nothing the operator did not ask for.
798
+ */
799
+ if (method === 'GET' && path === '/metrics') {
800
+ if (!this.metricsEnabled) {
801
+ // 404 rather than 403: a disabled endpoint should look absent, not forbidden. A 403 tells a
802
+ // scanner the surface exists.
803
+ throw new ProtocolError('NOT_FOUND', 'metrics are not enabled on this relay', { hint: 'start the relay with --metrics' });
804
+ }
805
+ const publishedBody = renderMetrics(this.state, { nowMs: this.state.nowMs() });
806
+ // v1.3.10-beta.23: the M7 reliability registry is appended. It renders nothing until a call site records something, so
807
+ // the existing exposition is unchanged on relays that never instrument.
808
+ const registryBody = typeof this.state.metrics?.renderPrometheus === 'function' ? this.state.metrics.renderPrometheus() : '';
809
+ const body = registryBody === '' ? publishedBody : publishedBody + '# reliability registry\n' + registryBody;
810
+ res.writeHead(200, {
811
+ 'content-type': METRICS_CONTENT_TYPE,
812
+ 'content-length': Buffer.byteLength(body, 'utf8'),
813
+ 'cache-control': 'no-store',
814
+ });
815
+ return res.end(body);
816
+ }
817
+ // §5: dispatching tasks needs the operator token, not a device token.
818
+ if (method === 'POST' && path === '/v1/task') {
819
+ const principal = this.authorizeTaskDispatch(req);
820
+ this.enforceSignature(req, { method, signedPath, body: rawBody });
821
+ const body = takeJson();
822
+ if (principal.machine_id && !body.created_by) body.created_by = principal.machine_id;
823
+ const taskGuard = guardTaskBody(body);
824
+ if (taskGuard.ok !== true) {
825
+ return sendJson(res, taskGuard.status, taskGuard.body);
826
+ }
827
+ const result = this.state.createTask(body);
828
+ return sendJson(res, 200, result);
829
+ }
830
+
831
+ /* ---- v1.1.6: reachable WITHOUT a device token ---- */
832
+ this.joinChallenges ??= createJoinChallenges({
833
+ now: () => this.state.nowMs(),
834
+ publicKeyFor: (fingerprint) => this.state.accountKeyFor(fingerprint),
835
+ });
836
+ const admissionPollMatch = /^\/v1\/fleet\/admission\/([^/]+)$/.exec(path);
837
+ if (method === 'GET' && admissionPollMatch && url.searchParams.get('poll_token') !== null) {
838
+ this.admission ??= createAdmission({ now: () => this.state.nowMs() });
839
+ const polled = this.admission.poll(decodeURIComponent(admissionPollMatch[1]), url.searchParams.get('poll_token'));
840
+ if (polled.ok !== true) throw new ProtocolError('NOT_FOUND', 'unknown admission request');
841
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...polled });
842
+ }
843
+ if (method === 'GET' && path === '/v1/fleet/admission/challenge') {
844
+ const issued = this.joinChallenges.issue(url.searchParams.get('fingerprint') ?? '', url.searchParams.get('machine_id') ?? '');
845
+ if (issued.ok !== true) throw new ProtocolError('BAD_REQUEST', 'challenge refused: ' + issued.code, { code: issued.code });
846
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...issued });
847
+ }
848
+ if (method === 'POST' && path === '/v1/fleet/admission/request') {
849
+ const body = takeJson();
850
+ this.admission ??= createAdmission({ now: () => this.state.nowMs() });
851
+ const proof = this.joinChallenges.consume({ challenge_id: body.challenge_id, machine_id: body.machine_id, signature: body.signature });
852
+ if (proof.ok !== true) throw new ProtocolError('UNAUTHORIZED', 'join proof refused: ' + proof.code, { code: proof.code });
853
+ const result = this.admission.requestAdmission({ machine_id: body.machine_id, machine_name: body.machine_name ?? null, account_fingerprint: proof.fingerprint });
854
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...result });
855
+ }
856
+
857
+ /**
858
+ * v1.3.10-beta.14: project-room writes (M4).
859
+ *
860
+ * Above the shared device guard on purpose: an operator token is not a device token, and these change who may act on
861
+ * shared state, so they take the same credential that dispatches tasks.
862
+ */
863
+ if (method === 'POST' && (path === '/v1/projects' || /^\/v1\/projects\/[^/]+\/members$/.test(path))) {
864
+ const auth = this.authorizeTaskDispatch(req);
865
+ if (auth.role !== 'operator') throw new ProtocolError('FORBIDDEN_ACTION', 'only the operator may change project membership', { required_role: 'owner' });
866
+ const body = takeJson();
867
+ const membersMatch = /^\/v1\/projects\/([^/]+)\/members$/.exec(path);
868
+ if (membersMatch !== null) {
869
+ const target = decodeURIComponent(membersMatch[1]);
870
+ const added = this.state.projects.addMember({ projectId: target, member: { kind: body?.member?.kind, id: body?.member?.id, name: body?.member?.name ?? null }, role: body?.role ?? 'worker' });
871
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, member: added.member, snapshot: this.state.projects.snapshot({ projectId: target }) });
872
+ }
873
+ const created = this.state.projects.createProject({
874
+ name: body?.name,
875
+ origin: body?.origin ?? null,
876
+ createdBy: { kind: 'operator', id: typeof body?.created_by?.id === 'string' ? body.created_by.id : 'operator', name: typeof body?.created_by?.name === 'string' ? body.created_by.name : null },
877
+ });
878
+ const createdId = created.project.project.project_id;
879
+ if (Array.isArray(body?.members)) {
880
+ for (const entry of body.members) {
881
+ this.state.projects.addMember({ projectId: createdId, member: { kind: entry?.kind, id: entry?.id, name: entry?.name ?? null }, role: entry?.role ?? 'worker' });
882
+ }
883
+ }
884
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, project: this.state.projects.snapshot({ projectId: createdId }) });
885
+ }
886
+
887
+
888
+ /**
889
+ * v1.3.12-beta.2: grant administration (execution-authorization model).
890
+ *
891
+ * Revoking is an operator act: it stops one authorisation from being dispatched. The task list is the audit view --
892
+ * every task that carried a grant, with the argv hash and key id it was authorised under -- and deliberately returns
893
+ * only operator-readable data, never result bodies.
894
+ */
895
+ /**
896
+ * v1.6.2: whole-relay fleet overview for an operator. Aggregate counts are public on /healthz;
897
+ * the per-fleet rows are not, because they name other accounts fleets.
898
+ */
899
+ if (method === 'GET' && path === '/v1/fleets') {
900
+ const auth = this.authorizeTaskDispatch(req);
901
+ if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'listing fleets is an operator view');
902
+ const summary = this.state.fleetSummary();
903
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...summary });
904
+ }
905
+
906
+ if (path === '/v1/grants/revoke' || path === '/v1/grants') {
907
+ const auth = this.authorizeTaskDispatch(req);
908
+ if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'only the operator may administer grants');
909
+ if (method === 'POST' && path === '/v1/grants/revoke') {
910
+ const body = takeJson();
911
+ const revoked = this.state.revokeGrant({ grantId: body?.grant_id, reason: body?.reason ?? null });
912
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...revoked });
913
+ }
914
+ if (method === 'GET' && path === '/v1/grants') {
915
+ const audit = this.state.grantedTasks();
916
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...audit });
917
+ }
918
+ throw new ProtocolError('BAD_REQUEST', 'grants accept GET (audit) or POST /revoke', { method, path });
919
+ }
920
+
921
+ /**
922
+ * v1.3.13-beta.4: project refs and the merge queue (task model: branch, merge, conflict).
923
+ *
924
+ * Reads take the device token of a paired machine; advancing a ref and settling a queue item are operator acts, because
925
+ * they state where the branch is and what a patch did. A merge that would move the head from a base that is no longer
926
+ * current is refused and the item stays queued -- the model never silently rewrites history.
927
+ */
928
+ const refsMatch = /^\/v1\/projects\/([^/]+)\/refs(?:\/advance)?$/.exec(path);
929
+ if (refsMatch !== null) {
930
+ const projectId = decodeURIComponent(refsMatch[1]);
931
+ const wantsAdvance = path.endsWith('/advance');
932
+ if (!wantsAdvance && method === 'GET') {
933
+ const token = bearerToken(req);
934
+ if (token === null || this.state.authenticate(token) === null) throw new ProtocolError('UNAUTHORIZED', 'reading refs needs a paired device token');
935
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, refs: this.state.refs.listRefs({ projectId }) });
936
+ }
937
+ if (wantsAdvance && method === 'POST') {
938
+ const auth = this.authorizeTaskDispatch(req);
939
+ if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'advancing a ref is an operator act');
940
+ const body = takeJson();
941
+ const moved = this.state.refs.advance({ projectId, branch: body?.branch, from: body?.from ?? null, to: body?.to, reason: body?.reason ?? null });
942
+ return sendJson(res, moved.ok === true ? 200 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...moved });
943
+ }
944
+ throw new ProtocolError('BAD_REQUEST', 'refs accept GET or POST /advance', { method, path });
945
+ }
946
+ const mergeMatch = /^\/v1\/projects\/([^/]+)\/merge-queue(?:\/([^/]+))?(?:\/(complete))?$/.exec(path);
947
+ if (mergeMatch !== null) {
948
+ const projectId = decodeURIComponent(mergeMatch[1]);
949
+ const queueId = mergeMatch[2] === undefined ? null : decodeURIComponent(mergeMatch[2]);
950
+ const action = mergeMatch[3] ?? null;
951
+ if (method === 'GET' && queueId === null) {
952
+ const token = bearerToken(req);
953
+ if (token === null || this.state.authenticate(token) === null) throw new ProtocolError('UNAUTHORIZED', 'reading the merge queue needs a paired device token');
954
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, items: this.state.refs.listQueue({ projectId }) });
955
+ }
956
+ const auth = this.authorizeTaskDispatch(req);
957
+ if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'merge queue mutations are operator acts');
958
+ if (method === 'POST' && queueId === null) {
959
+ const body = takeJson();
960
+ const queued = this.state.refs.enqueue({ projectId, branch: body?.branch, patchSha256: body?.patch_sha256, baseCommit: body?.base_commit, sourceTaskId: body?.source_task_id ?? null });
961
+ return sendJson(res, queued.ok === true ? 200 : 400, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...queued });
962
+ }
963
+ if (method === 'POST' && queueId !== null && action === 'complete') {
964
+ const body = takeJson();
965
+ const settled = this.state.refs.complete({ queueId, outcome: body?.outcome, headCommit: body?.head_commit ?? null, conflictSha256: body?.conflict_sha256 ?? null });
966
+ return sendJson(res, settled.ok === true ? 200 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...settled });
967
+ }
968
+ throw new ProtocolError('BAD_REQUEST', 'merge queue accepts GET, POST enqueue or POST /:id/complete', { method, path });
969
+ }
970
+
971
+ /**
972
+ * v1.3.14-beta.1 (collaboration model): shared objects. Two clients in the same project read the SAME board and the SAME
973
+ * artifact index; before this they each filtered the global task list and disagreed as soon as one of them looked late.
974
+ */
975
+ const sharedMatch = /^\/v1\/projects\/([^/]+)\/(board|artifacts)$/.exec(path);
976
+ if (sharedMatch !== null) {
977
+ const projectId = decodeURIComponent(sharedMatch[1]);
978
+ const resource = sharedMatch[2];
979
+ const readDevice = () => {
980
+ const token = bearerToken(req);
981
+ const caller = token === null ? null : this.state.authenticate(token);
982
+ if (caller === null) throw new ProtocolError('UNAUTHORIZED', 'reading a project object needs a paired device token');
983
+ return caller;
984
+ };
985
+ if (method === 'GET') {
986
+ const caller = readDevice();
987
+ const snapshot = this.state.projectSnapshot(projectId);
988
+ const isDefault = snapshot.project.project_id === this.state.defaultProjectId;
989
+ if (!isDefault) {
990
+ const member = this.state.projects.memberOf({ projectId, member: { kind: 'machine', id: caller.machine_id } });
991
+ if (member === null) throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: projectId });
992
+ }
993
+ if (resource === 'board') return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...this.state.projectBoard({ projectId }) });
994
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, artifacts: this.state.listArtifacts({ projectId }) });
995
+ }
996
+ if (method === 'POST' && resource === 'artifacts') {
997
+ const auth = this.authorizeTaskDispatch(req);
998
+ if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'registering a project artifact is an operator act');
999
+ const body = takeJson();
1000
+ const registered = this.state.registerArtifact({ projectId, sha256: body?.sha256, bytes: body?.bytes, kind: body?.kind ?? 'artifact', sourceTaskId: body?.source_task_id ?? null });
1001
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...registered });
1002
+ }
1003
+ throw new ProtocolError('BAD_REQUEST', 'project objects accept GET, or POST artifacts', { method, path });
1004
+ }
1005
+
1006
+ /**
1007
+ * v1.3.14-beta.2 (collaboration model): decisions and project memory as shared objects. The board answers "what is
1008
+ * happening", these answer "what did we agree" -- and both are one object per project, not a per-client copy.
1009
+ */
1010
+ const knowledgeMatch = /^\/v1\/projects\/([^/]+)\/(decisions|memory)$/.exec(path);
1011
+ if (knowledgeMatch !== null) {
1012
+ const projectId = decodeURIComponent(knowledgeMatch[1]);
1013
+ const resource = knowledgeMatch[2];
1014
+ const readDevice = () => {
1015
+ const token = bearerToken(req);
1016
+ const caller = token === null ? null : this.state.authenticate(token);
1017
+ if (caller === null) throw new ProtocolError('UNAUTHORIZED', 'reading a project object needs a paired device token');
1018
+ return caller;
1019
+ };
1020
+ const requireReadable = (caller) => {
1021
+ const snapshot = this.state.projectSnapshot(projectId);
1022
+ if (snapshot.project.project_id === this.state.defaultProjectId) return;
1023
+ const member = this.state.projects.memberOf({ projectId, member: { kind: 'machine', id: caller.machine_id } });
1024
+ if (member === null) throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: projectId });
1025
+ };
1026
+ if (method === 'GET') {
1027
+ const caller = readDevice();
1028
+ requireReadable(caller);
1029
+ if (resource === 'decisions') return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, decisions: this.state.listDecisions({ projectId }) });
1030
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, document: this.state.getProjectMemory({ projectId }) });
1031
+ }
1032
+ const auth = this.authorizeTaskDispatch(req);
1033
+ if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'writing shared project objects is an operator act');
1034
+ const body = takeJson();
1035
+ if (method === 'POST' && resource === 'decisions') {
1036
+ const added = this.state.addDecision({ projectId, title: body?.title, text: body?.text ?? null, actor: { kind: 'operator', id: 'operator' }, refs: body?.refs ?? {}, kind: body?.kind ?? 'decision' });
1037
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...added });
1038
+ }
1039
+ if (method === 'PUT' && resource === 'memory') {
1040
+ const updated = this.state.setProjectMemory({ projectId, text: body?.text, actor: { kind: 'operator', id: 'operator' } });
1041
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...updated });
1042
+ }
1043
+ throw new ProtocolError('BAD_REQUEST', 'decisions accept GET/POST, memory accepts GET/PUT', { method, path });
1044
+ }
1045
+
1046
+ /**
1047
+ * v1.3.14-beta.3 (collaboration model): notifications for shared-object events. The rows are broadcast to project
1048
+ * members, read with a per-reader watermark, and deduplicated inside the notifications module; a client that reconnects
1049
+ * asks for everything after its watermark instead of re-reading the whole event stream.
1050
+ */
1051
+ const notifyMatch = /^\/v1\/projects\/([^/]+)\/notifications(?:\/(unread|read))?$/.exec(path);
1052
+ if (notifyMatch !== null) {
1053
+ const projectId = decodeURIComponent(notifyMatch[1]);
1054
+ const action = notifyMatch[2] ?? null;
1055
+ const token = bearerToken(req);
1056
+ const caller = token === null ? null : this.state.authenticate(token);
1057
+ if (caller === null) throw new ProtocolError('UNAUTHORIZED', 'notifications need a paired device token');
1058
+ const snapshot = this.state.projectSnapshot(projectId);
1059
+ if (snapshot.project.project_id !== this.state.defaultProjectId) {
1060
+ const member = this.state.projects.memberOf({ projectId, member: { kind: 'machine', id: caller.machine_id } });
1061
+ if (member === null) throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: projectId });
1062
+ }
1063
+ const reader = { kind: 'machine', id: caller.machine_id };
1064
+ if (method === 'GET' && action === null) {
1065
+ const url = new URL(req.url, 'http://relay.invalid');
1066
+ const sinceMs = Number(url.searchParams.get('since_ms') ?? 0);
1067
+ const limit = Number(url.searchParams.get('limit') ?? 200);
1068
+ const listed = this.state.notifications.list({ projectId, sinceMs: Number.isFinite(sinceMs) ? sinceMs : 0, limit: Number.isFinite(limit) ? Math.max(1, Math.min(500, limit)) : 200 });
1069
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...listed });
1070
+ }
1071
+ if (method === 'GET' && action === 'unread') return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...this.state.notifications.unread({ projectId, reader }) });
1072
+ if (method === 'POST' && action === 'read') {
1073
+ const body = takeJson();
1074
+ const marked = this.state.notifications.markRead({ projectId, reader, watermarkMs: body?.watermark_ms });
1075
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...marked });
1076
+ }
1077
+ throw new ProtocolError('BAD_REQUEST', 'notifications accept GET, GET /unread or POST /read', { method, path });
1078
+ }
1079
+
1080
+ /**
1081
+ * v1.3.14-beta.4 (collaboration model): a project-scoped SSE stream.
1082
+ *
1083
+ * `ready` names the project and its last event seq, `since_seq` replays the room events a client missed, then every new
1084
+ * room event is pushed as `project.event`. Clients in the same project therefore apply the same ordered events instead
1085
+ * of each polling a different view.
1086
+ */
1087
+ const projectStreamMatch = /^\/v1\/projects\/([^/]+)\/stream$/.exec(path);
1088
+ if (projectStreamMatch !== null && method === 'GET') {
1089
+ const projectId = decodeURIComponent(projectStreamMatch[1]);
1090
+ const token = bearerToken(req);
1091
+ const caller = token === null ? null : this.state.authenticate(token);
1092
+ if (caller === null) throw new ProtocolError('UNAUTHORIZED', 'project events need a paired device token');
1093
+ const snapshot = this.state.projectSnapshot(projectId);
1094
+ if (snapshot.project.project_id !== this.state.defaultProjectId) {
1095
+ const member = this.state.projects.memberOf({ projectId, member: { kind: 'machine', id: caller.machine_id } });
1096
+ if (member === null) throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: projectId });
1097
+ }
1098
+ const sinceRaw = url.searchParams.get('since_seq');
1099
+ const sinceSeq = sinceRaw !== null && /^[0-9]+$/.test(sinceRaw) ? Number(sinceRaw) : 0;
1100
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
1101
+ const frame = (entry) => {
1102
+ try { res.write('id: ' + entry.seq + '\nevent: project.event\ndata: ' + JSON.stringify({ project_id: entry.project_id, seq: entry.seq, type: entry.type, payload: entry.payload }) + '\n\n'); } catch { /* closed */ }
1103
+ };
1104
+ const replayed = this.state.projects.events({ projectId, sinceSeq });
1105
+ res.write('event: ready\ndata: ' + JSON.stringify({ protocol_version: PROTOCOL_VERSION, project_id: projectId, last_event_seq: replayed.next_seq }) + '\n\n');
1106
+ for (const entry of replayed.events) frame(entry);
1107
+ const unsubscribe = this.state.onProjectEvent((entry) => { if (entry.project_id === projectId) frame(entry); });
1108
+ const keepalive = setInterval(() => { try { res.write(': keepalive\n\n'); } catch { /* closed */ } }, this.keepaliveMs);
1109
+ let closed = false;
1110
+ const done = () => { if (closed) return; closed = true; clearInterval(keepalive); unsubscribe(); try { res.end(); } catch { /* already closed */ } };
1111
+ req.on('close', done);
1112
+ res.on('close', done);
1113
+ return true;
1114
+ }
1115
+
1116
+ /* ---- everything below requires `Authorization: Bearer <device_token>` ---- */
1117
+ const token = bearerToken(req);
1118
+ const device = token ? this.state.authenticate(token) : null;
1119
+ if (!device) {
1120
+ throw new ProtocolError('UNAUTHORIZED', 'missing or invalid device_token', {});
1121
+ }
1122
+ this.state.touchDevice(device.machine_id);
1123
+
1124
+ const projectLockMatch = /^\/v1\/projects\/([^/]+)\/locks(?:\/([^/]+)(?:\/(renew))?)?$/.exec(path);
1125
+ if (projectLockMatch !== null && method !== undefined) {
1126
+ const projectId = decodeURIComponent(projectLockMatch[1]);
1127
+ const lockId = projectLockMatch[2] === undefined ? null : decodeURIComponent(projectLockMatch[2]);
1128
+ const action = projectLockMatch[3] ?? null;
1129
+ const roomMember = { kind: 'machine', id: device.machine_id };
1130
+ const snapshot = this.state.projectSnapshot(projectId);
1131
+ const isDefault = snapshot.project.project_id === this.state.defaultProjectId;
1132
+ const membership = isDefault ? { role: 'worker' } : this.state.projects.memberOf({ projectId, member: roomMember });
1133
+ if (membership === null) return sendJson(res, 404, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: false, error: { code: 'NOT_FOUND', message: 'unknown project' } });
1134
+ const roleRank = { observer: 0, worker: 1, maintainer: 2, owner: 3 };
1135
+ const canWrite = roleRank[membership.role] >= roleRank.worker;
1136
+ if (method === 'GET' && lockId === null) {
1137
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, locks: this.state.locks.list({ projectId }) });
1138
+ }
1139
+ if (method === 'POST' && lockId === null) {
1140
+ if (!canWrite) return sendJson(res, 403, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: false, error: { code: 'FORBIDDEN_ACTION', message: 'acquiring a lock needs worker or above', required_role: 'worker', current_role: membership.role } });
1141
+ const body = takeJson();
1142
+ const acquired = this.state.locks.acquire({ projectId, kind: body?.kind, target: body?.target, holder: { kind: "machine", id: device.machine_id }, reason: body?.reason ?? null, leaseMs: Number.isFinite(body?.lease_ms) ? body.lease_ms : undefined });
1143
+ if (acquired.ok === true) this.state.metrics?.inc('wm_lock_acquired_total', { kind: String(body?.kind ?? '') });
1144
+ else if (acquired.code === 'LOCK_HELD') this.state.metrics?.inc('wm_lock_conflict_total', { kind: String(body?.kind ?? '') });
1145
+ return sendJson(res, acquired.ok ? 200 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...acquired });
1146
+ }
1147
+ if (method === 'POST' && lockId !== null && action === 'renew') {
1148
+ const renewed = this.state.locks.renew({ projectId, lockId, holder: { kind: "machine", id: device.machine_id } });
1149
+ return sendJson(res, renewed.ok ? 200 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...renewed });
1150
+ }
1151
+ if (method === 'DELETE' && lockId !== null) {
1152
+ const force = url.searchParams.get('force') === '1';
1153
+ const reason = url.searchParams.get('reason');
1154
+ const released = force
1155
+ ? this.state.locks.forceRelease({ projectId, lockId, actor: { kind: "machine", id: device.machine_id }, actorRole: membership.role, reason })
1156
+ : this.state.locks.release({ projectId, lockId, holder: { kind: "machine", id: device.machine_id } });
1157
+ return sendJson(res, released.ok ? 200 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...released });
1158
+ }
1159
+ return sendJson(res, 405, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'unsupported lock operation' } });
1160
+ }
1161
+
1162
+ /**
1163
+ * v1.3.10-beta.14: project-room reads (M4). Below the device guard, so the device is already authenticated.
1164
+ *
1165
+ * A machine sees the default project and the projects it is a member of; a project it is not a member of answers
1166
+ * NOT_FOUND, so a project existence is not leaked. The snapshot carries last_event_seq, and the events route returns
1167
+ * the slice after since_seq, which is what lets a client resume after a disconnect.
1168
+ */
1169
+ if (method === 'GET' && (path === '/v1/projects' || path.startsWith('/v1/projects/'))) {
1170
+ const match = /^\/v1\/projects(?:\/([^/]+))?(?:\/(events|members))?$/.exec(path);
1171
+ if (match === null) throw new ProtocolError('NOT_FOUND', 'unknown route', { path });
1172
+ const projectId = match[1] === undefined ? null : decodeURIComponent(match[1]);
1173
+ const sub = match[2] ?? null;
1174
+ const readSnapshot = (id) => {
1175
+ let snapshot;
1176
+ try {
1177
+ snapshot = this.state.projectSnapshot(id);
1178
+ } catch (error) {
1179
+ if (error?.code === 'BAD_REQUEST' || error?.code === 'PROJECT_NOT_FOUND') throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: id });
1180
+ throw error;
1181
+ }
1182
+ if (snapshot.project.project_id === this.state.defaultProjectId) return snapshot;
1183
+ const member = this.state.projects.memberOf({ projectId: id, member: { kind: 'machine', id: device.machine_id } });
1184
+ if (member === null) throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: id });
1185
+ return snapshot;
1186
+ };
1187
+ if (projectId === null) {
1188
+ const mine = this.state.projects.listProjects({ member: { kind: 'machine', id: device.machine_id } }).projects;
1189
+ if (!mine.some((project) => project.project_id === this.state.defaultProjectId)) mine.unshift({ project_id: this.state.defaultProjectId, name: 'default', origin: null });
1190
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, projects: mine });
1191
+ }
1192
+ if (sub === null) return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...readSnapshot(projectId) });
1193
+ if (sub === 'events') {
1194
+ readSnapshot(projectId);
1195
+ const raw = url.searchParams.get('since_seq');
1196
+ const sinceSeq = raw !== null && /^[0-9]+$/.test(raw) ? Number(raw) : 0;
1197
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...this.state.projectEvents(projectId, { sinceSeq }) });
1198
+ }
1199
+ throw new ProtocolError('BAD_REQUEST', 'unsupported project route', { method, path });
1200
+ }
1201
+
1202
+
1203
+ // v0.3.0 §9: writes are always checked (tolerating unsigned unless required);
1204
+ // reads are only checked when the relay was explicitly told to require
1205
+ // signatures. See `enforceSignature` for the reasoning.
1206
+ this.enforceSignature(req, { method, signedPath, body: rawBody });
1207
+
1208
+ /* ---- v1.2.5: members and read watermarks ---- */
1209
+
1210
+ if (method === 'GET' && path === '/v1/fleet/members') {
1211
+ return sendJson(res, 200, {
1212
+ protocol_version: PROTOCOL_VERSION,
1213
+ rabbit_time: this.state.nowIso(),
1214
+ ok: true,
1215
+ members: this.state.listMembers(),
1216
+ });
1217
+ }
1218
+ const readMatch = path.match(/^\/v1\/fleet\/read\/([^/]+)$/);
1219
+ if (readMatch !== null) {
1220
+ const sessionId = decodeURIComponent(readMatch[1]);
1221
+ if (method === 'PUT') {
1222
+ const body = takeJson();
1223
+ const marked = this.state.markRead(sessionId, device.machine_id, {
1224
+ at_ms: Number.isFinite(body.at_ms) ? body.at_ms : this.state.nowMs(),
1225
+ message_id: typeof body.message_id === 'string' ? body.message_id : null,
1226
+ });
1227
+ if (marked.ok !== true) return sendJson(res, 400, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: marked.code } });
1228
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...marked });
1229
+ }
1230
+ if (method === 'GET') {
1231
+ this.fleetMessages ??= createFleetMessages({ now: () => this.state.nowMs() });
1232
+ const mark = this.state.readMark(sessionId, device.machine_id);
1233
+ const listed = this.fleetMessages.list({ session_id: sessionId, limit: 200, for_machine_id: device.machine_id });
1234
+ const messages = listed.ok === true ? listed.messages : [];
1235
+ const floor = mark === null ? 0 : mark.at_ms;
1236
+ const unread = messages.filter((message) => Number.isFinite(message?.at_ms) && message.at_ms > floor && message.machine_id !== device.machine_id).length;
1237
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, session_id: sessionId, mark, unread });
1238
+ }
1239
+ return sendJson(res, 405, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: 'METHOD_NOT_ALLOWED' } });
1240
+ }
1241
+ if (method === 'GET' && path === '/v1/stream') {
1242
+ return this.handleStream(req, res, url, device);
1243
+ }
1244
+ if (method === 'POST' && path === '/v1/heartbeat') {
1245
+ const body = takeJson();
1246
+ body.machine_id = body.machine_id ?? device.machine_id;
1247
+ const result = this.state.heartbeat(body);
1248
+ return sendJson(res, 200, result);
1249
+ }
1250
+ if (method === 'POST' && path === '/v1/result') {
1251
+ const body = takeJson();
1252
+ // B1 方案 A:window 模式接收旧信封(含 argv/head)并投影后存储;enforce 才硬拒。
1253
+ const dataPlaneGuardMode = String(process.env.W2M_DATA_PLANE_GUARD ?? 'window');
1254
+ // decision 5: enforce is not allowed. The flag is read and reported, but never changes behaviour.
1255
+ if (dataPlaneGuardMode === 'enforce' && this.allowDataPlaneGuardEnforce === true) {
1256
+ const resultGuard = guardResultEnvelope(body);
1257
+ if (resultGuard.ok !== true) {
1258
+ return sendJson(res, resultGuard.status, resultGuard.body);
1259
+ }
1260
+ }
1261
+ const result = this.state.submitResult(body);
1262
+ const { _instanceKey, ...clean } = result;
1263
+ return sendJson(res, 200, clean);
1264
+ }
1265
+
1266
+ if (method === 'GET' && path === '/v1/devices') {
1267
+ /**
1268
+ * v1.5.5: device enumeration is fleet-scoped.
1269
+ *
1270
+ * This endpoint used to return every device on the relay; state.mjs even recorded the narrowing
1271
+ * as a pending step. The observation that forced it: another account's machine (its own fleet id,
1272
+ * its own fleet key) appeared in this fleet's machine list -- correct fleet binding, wrong list.
1273
+ *
1274
+ * Migration rule, the same one task dispatch already obeys: a relay with no fleet configured
1275
+ * keeps the historical behaviour for fleetless devices, a scoped relay serves its own fleet, and
1276
+ * a fleet-bound device always sees exactly its own fleet and nothing else.
1277
+ */
1278
+ const callerFleet = typeof device?.fleet_id === 'string' && device.fleet_id !== '' ? device.fleet_id : null;
1279
+ const relayFleet = this.state.fleetId ?? null;
1280
+ const allDevices = this.state.listDevices();
1281
+ const devices = callerFleet === null
1282
+ ? (relayFleet === null ? allDevices : allDevices.filter((entry) => entry.fleet_id === relayFleet))
1283
+ : allDevices.filter((entry) => entry.fleet_id === callerFleet);
1284
+ return sendJson(res, 200, {
1285
+ protocol_version: PROTOCOL_VERSION,
1286
+ rabbit_time: this.state.nowIso(),
1287
+ devices,
1288
+ });
1289
+ }
1290
+ if (method === 'GET' && path === '/v1/tasks') {
1291
+ const rawLimit = url.searchParams.get('limit');
1292
+ let limit = 50;
1293
+ if (rawLimit !== null) {
1294
+ limit = Number(rawLimit);
1295
+ if (!Number.isInteger(limit) || limit < 1) {
1296
+ throw new ProtocolError('BAD_REQUEST', 'limit must be a positive integer', { limit: rawLimit });
1297
+ }
1298
+ }
1299
+ return sendJson(res, 200, {
1300
+ protocol_version: PROTOCOL_VERSION,
1301
+ rabbit_time: this.state.nowIso(),
1302
+ tasks: this.state.listTasks({ limit, fleetId: device.fleet_id ?? null }),
1303
+ });
1304
+ }
1305
+
1306
+ const taskMatch = /^\/v1\/tasks\/([^/]+)$/.exec(path);
1307
+ const reportMatch = /^\/v1\/tasks\/([^/]+)\/report$/.exec(path);
1308
+ // v0.3.0: cross-machine RTT status. The plugin may run on a different machine
1309
+ // than the agent, so this has to come from the relay, not from a local file.
1310
+ const agentStatusMatch = /^\/v1\/agents\/([^/]+)\/status$/.exec(path);
1311
+ // v0.3.9: P2P candidate rendezvous. `/announce` is checked before the read route
1312
+ // so that a machine literally named "announce" cannot shadow it.
1313
+ const peerReadMatch = /^\/v1\/peer\/([^/]+)$/.exec(path);
1314
+ /* ---- v1.1.6 admission ---- */
1315
+ const admissionDecideMatch = /^\/v1\/fleet\/admission\/([^/]+)\/decide$/.exec(path);
1316
+ const memberRemoveMatch = /^\/v1\/fleet\/members\/([^/]+)\/remove$/.exec(path);
1317
+ const removalVoteMatch = /^\/v1\/fleet\/removals\/([^/]+)\/vote$/.exec(path);
1318
+
1319
+ /* ---- v0.3.9 P2P signalling ---- */
1320
+ if (method === 'POST' && path === '/v1/peer/announce') {
1321
+ const body = takeJson();
1322
+ try {
1323
+ // `device.machine_id`, never `body.machine_id`: a paired machine may only
1324
+ // announce addresses for itself, or it could aim a peer's punch at a third party.
1325
+ const result = this.peers.announce(device.machine_id, body);
1326
+ return sendJson(res, 200, {
1327
+ protocol_version: PROTOCOL_VERSION,
1328
+ rabbit_time: this.state.nowIso(),
1329
+ ...result,
1330
+ });
1331
+ } catch (error) {
1332
+ throw new ProtocolError('BAD_REQUEST', error.message, { code: error.code ?? 'BAD_ANNOUNCEMENT' });
1333
+ }
1334
+ }
1335
+
1336
+ if (method === 'GET' && peerReadMatch) {
1337
+ const machineId = decodeURIComponent(peerReadMatch[1]);
1338
+ const entry = this.peers.get(machineId);
1339
+ if (!entry) {
1340
+ // A named 404 rather than an empty list: "this machine has not announced" and
1341
+ // "this machine announced nothing reachable" call for different responses, and
1342
+ // an empty candidate list would send the caller into a punch that cannot work.
1343
+ throw new ProtocolError('NOT_FOUND', `no live P2P announcement for ${machineId}`, {
1344
+ machine_id: machineId,
1345
+ hint: 'the peer must POST /v1/peer/announce first, and announcements expire',
1346
+ });
1347
+ }
1348
+ return sendJson(res, 200, {
1349
+ protocol_version: PROTOCOL_VERSION,
1350
+ rabbit_time: this.state.nowIso(),
1351
+ peer: entry,
1352
+ });
1353
+ }
1354
+
1355
+ if (method === 'GET' && agentStatusMatch) {
1356
+ const machineId = decodeURIComponent(agentStatusMatch[1]);
1357
+ const status = this.state.deviceStatus(machineId);
1358
+ if (!status) {
1359
+ throw new ProtocolError('NOT_FOUND', 'unknown machine_id', { machine_id: machineId });
1360
+ }
1361
+ return sendJson(res, 200, {
1362
+ protocol_version: PROTOCOL_VERSION,
1363
+ rabbit_time: this.state.nowIso(),
1364
+ ...status,
1365
+ });
1366
+ }
1367
+
1368
+ if (method === 'GET' && reportMatch) {
1369
+ const taskId = decodeURIComponent(reportMatch[1]);
1370
+ this.state.sweepExpired();
1371
+ const format = url.searchParams.get('format') ?? 'json';
1372
+ const report = buildReport(this.state, taskId, { format });
1373
+ if (res.writableEnded) return undefined;
1374
+ res.writeHead(200, {
1375
+ 'content-type': report.contentType,
1376
+ 'content-length': Buffer.byteLength(report.body, 'utf8'),
1377
+ 'cache-control': 'no-store',
1378
+ });
1379
+ return res.end(report.body);
1380
+ }
1381
+
1382
+ if (method === 'GET' && taskMatch) {
1383
+ const taskId = decodeURIComponent(taskMatch[1]);
1384
+ this.state.sweepExpired();
1385
+ const task = this.state.requireTask(taskId);
1386
+ const aggregate = aggregateTask(this.state, taskId);
1387
+ return sendJson(res, 200, {
1388
+ protocol_version: PROTOCOL_VERSION,
1389
+ rabbit_time: this.state.nowIso(),
1390
+ task: this.state.taskSummary(task),
1391
+ leases: [...task.leases.values()],
1392
+ aggregate,
1393
+ progress: taskProgress(this.state, taskId),
1394
+ });
1395
+ }
1396
+
1397
+
1398
+ /* ---- v1.1.6 admission: the caller is always `device.machine_id`, never a body field ---- */
1399
+ this.admission ??= createAdmission({ now: () => this.state.nowMs() });
1400
+
1401
+ if (method === 'GET' && path === '/v1/fleet/admission/pending') {
1402
+ return sendJson(res, 200, {
1403
+ protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(),
1404
+ pending: this.admission.pendingFor(),
1405
+ });
1406
+ }
1407
+
1408
+ if (method === 'POST' && admissionDecideMatch) {
1409
+ const body = takeJson();
1410
+ const result = this.admission.decide(decodeURIComponent(admissionDecideMatch[1]), {
1411
+ decision: body.decision, decided_by: device.machine_id, auto: body.auto === true,
1412
+ });
1413
+ if (result.ok !== true) throw new ProtocolError('BAD_REQUEST', 'admission decision refused: ' + result.code, result);
1414
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...result });
1415
+ }
1416
+
1417
+ if (method === 'POST' && memberRemoveMatch) {
1418
+ const body = takeJson();
1419
+ const result = this.admission.remove(decodeURIComponent(memberRemoveMatch[1]), {
1420
+ requested_by: device.machine_id, reason: typeof body.reason === 'string' ? body.reason : null,
1421
+ });
1422
+ if (result.ok !== true) throw new ProtocolError('BAD_REQUEST', 'removal refused: ' + result.code, result);
1423
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...result });
1424
+ }
1425
+
1426
+ if (method === 'POST' && removalVoteMatch) {
1427
+ const body = takeJson();
1428
+ const result = this.admission.vote(decodeURIComponent(removalVoteMatch[1]), {
1429
+ machine_id: device.machine_id, vote: body.vote,
1430
+ });
1431
+ if (result.ok !== true) throw new ProtocolError('BAD_REQUEST', 'vote refused: ' + result.code, result);
1432
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...result });
1433
+ }
1434
+
1435
+ /* ---- v1.1.17: fleet chat ---- */
1436
+ //
1437
+ // Both endpoints sit AFTER the device gate on purpose: a message must be attributable to a paired machine, and
1438
+ // "who said this" is exactly the machine_id the token resolves to -- never a body field, which would let one machine
1439
+ // speak as another. The refusal codes from the store are passed through unchanged (FLEET_QUOTA_EXCEEDED included)
1440
+ // because a caller that cannot tell "too big" from "over quota" from "not text" cannot tell the operator what to do.
1441
+ this.fleetMessages ??= createFleetMessages({ now: () => this.state.nowMs() });
1442
+ this.fleetMemory ??= createFleetMemory({ now: () => this.state.nowMs() });
1443
+
1444
+ if (method === 'POST' && path === '/v1/fleet/messages') {
1445
+ /**
1446
+ * v1.2.6: posting to the group is a dispatch-class action. An observer may read the flow and may not speak into it, and
1447
+ * the refusal names the action and both roles so it is actionable.
1448
+ */
1449
+ const permitted = mayAct({ role: this.state.roleOf(device.machine_id), action: 'dispatch' });
1450
+ if (permitted.ok !== true) {
1451
+ return sendJson(res, 403, {
1452
+ protocol_version: PROTOCOL_VERSION,
1453
+ ok: false,
1454
+ error: { code: permitted.code, message: 'role may not post to the fleet group' },
1455
+ detail: {
1456
+ action: permitted.action,
1457
+ required_level: permitted.required_level,
1458
+ current_level: permitted.current_level,
1459
+ required_role: permitted.required_role,
1460
+ current_role: permitted.current_role,
1461
+ },
1462
+ });
1463
+ }
1464
+ const body = takeJson();
1465
+ const posted = this.fleetMessages.post({
1466
+ session_id: body.session_id,
1467
+ fleet_id: device.fleet_id ?? null,
1468
+ machine_id: device.machine_id,
1469
+ to: body.to,
1470
+ thread_id: body.thread_id,
1471
+ reply_to: body.reply_to,
1472
+ text: body.text,
1473
+ mentions: body.mentions,
1474
+ enc: body.enc,
1475
+ reply_state: body.reply_state,
1476
+ idempotency_key: body.idempotency_key,
1477
+ });
1478
+ if (posted.ok !== true) {
1479
+ throw new ProtocolError('BAD_REQUEST', 'message refused: ' + posted.code, { code: posted.code, ...(posted.detail ?? {}) });
1480
+ }
1481
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), message: posted.message, ...(posted.deduped === true ? { deduped: true } : {}) });
1482
+ }
1483
+
1484
+ if (method === 'GET' && path === '/v1/fleet/messages/stream') {
1485
+ /**
1486
+ * v1.1.17: a notification channel, not a message channel.
1487
+ *
1488
+ * The event carries the id and the session -- never the text -- so a client that connects learns "something was
1489
+ * said" and then fetches it through GET /v1/fleet/messages, which is where membership is actually checked. A
1490
+ * broadcast carrying text would hand every message to every connected listener and make the check decorative.
1491
+ *
1492
+ * The stream sits behind the device gate: an unpaired client cannot listen.
1493
+ */
1494
+ const sessionId = url.searchParams.get('session_id');
1495
+ res.writeHead(200, {
1496
+ 'content-type': 'text/event-stream; charset=utf-8',
1497
+ 'cache-control': 'no-store',
1498
+ connection: 'keep-alive',
1499
+ 'x-accel-buffering': 'no',
1500
+ });
1501
+ res.write(': connected\n\n');
1502
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
1503
+ const keepalive = setInterval(() => { try { res.write(': keepalive\n\n'); } catch { /* closed */ } }, 15_000);
1504
+ if (typeof keepalive.unref === 'function') keepalive.unref();
1505
+ const unsubscribe = this.fleetMessages.subscribe(sessionId, (event) => {
1506
+ try {
1507
+ // v1.2.5: a listener learns whether IT was named, and nothing else about the text.
1508
+ const mentioned = Array.isArray(event.mentions) && event.mentions.includes(device.machine_id);
1509
+ const payload = {
1510
+ session_id: event.session_id,
1511
+ message_id: event.message_id,
1512
+ machine_id: event.machine_id,
1513
+ at_ms: event.at_ms,
1514
+ to: event.to ?? 'all',
1515
+ ...(mentioned ? { mention: true } : {}),
1516
+ };
1517
+ res.write('event: new_message\ndata: ' + JSON.stringify(payload) + '\n\n');
1518
+ } catch { /* the reader went away; close() below cleans up */ }
1519
+ });
1520
+ const done = () => { clearInterval(keepalive); unsubscribe(); try { res.end(); } catch { /* already closed */ } };
1521
+ req.on('close', done);
1522
+ return undefined;
1523
+ }
1524
+
1525
+ if (method === 'GET' && path === '/v1/fleet/messages') {
1526
+ const sinceRaw = url.searchParams.get('since_ms');
1527
+ const limitRaw = url.searchParams.get('limit');
1528
+ const listed = this.fleetMessages.list({
1529
+ session_id: url.searchParams.get('session_id') ?? '',
1530
+ since_ms: sinceRaw === null ? null : Number(sinceRaw),
1531
+ limit: limitRaw === null ? 50 : Number(limitRaw),
1532
+ // Addressing/correlation filters. `for_me=1` is answered from the authenticated device identity, never from a
1533
+ // caller-supplied machine id: a reader must not be able to ask for another machine's private messages.
1534
+ // v1.5.5: a reader sees the chat of the fleet it belongs to; a device without a fleet keeps the
1535
+ // historical unqualified view.
1536
+ fleet_id: device.fleet_id ?? null,
1537
+ for_machine_id: url.searchParams.get('for_me') === '1' ? device.machine_id : null,
1538
+ thread_id: url.searchParams.get('thread_id'),
1539
+ to: url.searchParams.get('to'),
1540
+ });
1541
+ return sendJson(res, 200, {
1542
+ protocol_version: PROTOCOL_VERSION,
1543
+ rabbit_time: this.state.nowIso(),
1544
+ ...listed,
1545
+ // v1.5.5: only the caller's fleet messages leave the process.
1546
+ messages: (listed.messages ?? []).filter((m) => (m.fleet_id ?? null) === (device.fleet_id ?? null)),
1547
+ usage: this.fleetMessages.usage(), // shown with every read so a client can warn before the quota refuses a post
1548
+ });
1549
+ }
1550
+
1551
+ // v1.2.2: shared memory writes. Compare-and-swap on a revision, because two machines appending to one document is
1552
+ // otherwise last-writer-wins over the whole thing -- a record one machine added would vanish because the other had an
1553
+ // older copy. A stale writer gets 409 and the records it has not read; it is NOT retried here, because retrying in
1554
+ // the transport turns a visible conflict into a silent overwrite.
1555
+ if (path.startsWith('/v1/fleet/memory/')) {
1556
+ const doc = decodeURIComponent(path.slice('/v1/fleet/memory/'.length));
1557
+ if (doc === '') throw new ProtocolError('BAD_REQUEST', 'memory doc is required', { method, path });
1558
+ // v1.5.5: a fleet's memory must not be addressable by another fleet on the same relay.
1559
+ const docKey = typeof device.fleet_id === 'string' && device.fleet_id !== '' ? device.fleet_id + '::' + doc : doc;
1560
+ this.fleetMemory ??= createFleetMemory({ now: () => this.state.nowMs() });
1561
+ if (method === 'GET') {
1562
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...this.fleetMemory.get(docKey) });
1563
+ }
1564
+ if (method === 'PUT') {
1565
+ const body = takeJson();
1566
+ const appended = this.fleetMemory.append({
1567
+ doc: docKey,
1568
+ if_revision: Number.isInteger(body.if_revision) ? body.if_revision : null,
1569
+ author_machine_id: device.machine_id,
1570
+ records: Array.isArray(body.records) ? body.records : (typeof body.text === 'string' ? [body.text] : []),
1571
+ });
1572
+ if (appended.ok !== true) {
1573
+ const status = appended.code === 'REVISION_CONFLICT' ? 409 : 400;
1574
+ return sendJson(res, status, {
1575
+ protocol_version: PROTOCOL_VERSION, ok: false,
1576
+ error: { code: appended.code }, detail: appended.detail ?? null,
1577
+ });
1578
+ }
1579
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...appended });
1580
+ }
1581
+ throw new ProtocolError('BAD_REQUEST', 'memory accepts GET or PUT', { method, path });
1582
+ }
1583
+
1584
+ throw new ProtocolError('NOT_FOUND', `no route for ${method} ${path}`, { method, path });
1585
+ }
1586
+
1587
+ /** §4: the operations answer — "is the relay there, did it restart, are my machines connected". */
1588
+ healthBody(req) {
1589
+ return {
1590
+ ...this.state.stats(),
1591
+ // v0.3.9: how many machines currently have a live P2P announcement. Ephemeral,
1592
+ // so this drops to 0 by itself when nobody is refreshing.
1593
+ ...this.peers.stats(),
1594
+ ok: true,
1595
+ /**
1596
+ * v0.6.2: hoisted out of `persistence` so it cannot be missed by a reader that only looks at
1597
+ * the top level. `degraded: true` means this relay is answering requests while failing to record
1598
+ * them -- the one relay failure that presents as health. `ok` deliberately stays true: the relay
1599
+ * still serves the fleet, and flipping it would turn a recording problem into an outage.
1600
+ */
1601
+ degraded: this.persistence.enabled && this.persistence.degraded,
1602
+ degraded_reason: this.persistence.degraded
1603
+ ? `ledger appends are failing (${this.persistence.lastAppendError ?? 'unknown error'}); results are served but not durably recorded`
1604
+ : null,
1605
+ protocol_version: PROTOCOL_VERSION,
1606
+ rabbit_time: this.state.nowIso(),
1607
+ uptime_ms: this.state.nowMs() - this._startedAtMs,
1608
+ relay_id: this.relayId,
1609
+ version: RELAY_VERSION,
1610
+ relay_version: RELAY_VERSION,
1611
+ features: RELAY_FEATURES,
1612
+ started_at: rfc3339(this._startedAtMs),
1613
+ effective_scheme: this.effectiveScheme(req),
1614
+ base_path: this.basePath,
1615
+ operator_token_required: this.operatorTokenRequired,
1616
+ pair_rate_limit: this.pairRateLimitPerMinute,
1617
+ // v0.3.0: fleet-wide latency. Every numeric field is null (never 0) when no
1618
+ // machine has reported a FRESH measurement.
1619
+ rtt: this.state.rttSummary(),
1620
+ // v0.3.0 §9: signature status only — never the secret, not even a prefix.
1621
+ signing: {
1622
+ configured: this.signingConfigured,
1623
+ required: this.requireSignature,
1624
+ previous_secret_accepted: this.signingSecrets.length > 1,
1625
+ skew_seconds: this.signatureSkewSeconds,
1626
+ },
1627
+ persistence: this.persistence.describe(),
1628
+ };
1629
+ }
1630
+
1631
+ /* ---------------- request signing (v0.3.0 §9) ---------------- */
1632
+
1633
+ /**
1634
+ * Should this request be signature-checked at all?
1635
+ *
1636
+ * RULING (see PROTOCOL-v0.3.0.md §9.4): every WRITE is checked; READS are
1637
+ * checked only when `--require-signature` is on.
1638
+ *
1639
+ * Reads are not checked in the default modes because unsigned reads are
1640
+ * accepted anyway, so "verify when a signature happens to be present" would be
1641
+ * theatre -- an attacker simply omits the headers -- while adding a brand new
1642
+ * way for a poll or an SSE reconnect to fail. What a read leaks is topology,
1643
+ * which the device token already gates; what a write can do is change state,
1644
+ * which is what replay and tampering actually threaten.
1645
+ */
1646
+ shouldCheckSignature(method) {
1647
+ if (this.requireSignature) return true; // explicit hard mode: nothing is exempt
1648
+ const isRead = method === 'GET' || method === 'HEAD' || method === 'OPTIONS';
1649
+ return !isRead;
1650
+ }
1651
+
1652
+ /** Run `verifyRequest` and throw the mapped §7 error on rejection. */
1653
+ enforceSignature(req, { method, signedPath, body }) {
1654
+ if (!this.shouldCheckSignature(method)) return { skipped: true };
1655
+ const decision = this.verifySignature(req, { method, signedPath, body });
1656
+ if (decision.ok !== true) throw signatureError(decision);
1657
+ return decision;
1658
+ }
1659
+
1660
+ /**
1661
+ * Thin adapter over the frozen `signing.mjs` contract.
1662
+ *
1663
+ * `path` is the ROUTED path with the query string, NOT the raw request URL: with
1664
+ * `--base-path /w2m` the same client must produce the same signature whether a
1665
+ * proxy passes `/w2m/v1/result` through or strips it to `/v1/result`. The routed
1666
+ * path is the only value both sides agree on in all three deployment forms, so
1667
+ * clients sign `/v1/result` and never `https://host/w2m/v1/result`.
1668
+ */
1669
+ /**
1670
+ * Verify a request's signature (v0.7.11).
1671
+ *
1672
+ * Two mechanisms, and the order between them is the whole design:
1673
+ *
1674
+ * **1. Per-device Ed25519, when the sender is a device with a stored public key.** This is the
1675
+ * one that proves *which* device signed. It is tried **first** because a device key is strictly
1676
+ * stronger evidence than a shared secret, and because a device that registered a key must not
1677
+ * fall back to the weaker mechanism by accident.
1678
+ *
1679
+ * **2. HMAC over a shared secret, otherwise.** Every device that paired before v0.7.10 arrives
1680
+ * here — which is every device today — and nothing about its behaviour changes.
1681
+ *
1682
+ * ## Why this converges with no second deployment
1683
+ *
1684
+ * A device that has not been updated sends no signature header, so mechanism 1 finds no key and
1685
+ * mechanism 2 answers exactly as before. A device that *has* been updated sends a signature, its
1686
+ * public key is on file from pairing, and it is verified with it. So a fleet updates one machine
1687
+ * at a time and each machine gets the strongest mechanism available **to it** the moment it
1688
+ * starts sending one — no flag, no rollout step, no coordination.
1689
+ *
1690
+ * ## What this deliberately does not do
1691
+ *
1692
+ * It does not **require** a device key. `requireSignature` still means "a signature must be
1693
+ * present", and either mechanism satisfies it. Making a *device key* mandatory is a separate,
1694
+ * later switch, because turning it on today would refuse all four machines at once.
1695
+ */
1696
+ verifySignature(req, { method, signedPath, body }) {
1697
+ const viaDevice = this.verifyWithDeviceKey(req, { method, signedPath, body });
1698
+ if (viaDevice !== null) return viaDevice;
1699
+
1700
+ return verifyRequest({
1701
+ headers: req.headers, // plain Node object; signing.mjs reads it case-insensitively
1702
+ method,
1703
+ path: signedPath,
1704
+ body: body ?? '',
1705
+ secrets: this.signingSecrets,
1706
+ nonces: this.nonceCache,
1707
+ nowMs: this.state.nowMs(),
1708
+ skewSeconds: this.signatureSkewSeconds,
1709
+ required: this.requireSignature,
1710
+ });
1711
+ }
1712
+
1713
+ /**
1714
+ * Try the per-device Ed25519 path.
1715
+ *
1716
+ * Returns `null` when the mechanism does not apply — no signature presented, or the signing
1717
+ * machine is not a device whose public key we hold — so the caller falls through. Returns a
1718
+ * decision when it *does* apply, **including when it fails**: a device that registered a key and
1719
+ * then presented a bad signature must not get a second chance through the shared secret, since
1720
+ * that would make the stronger mechanism optional for anyone able to produce a malformed one.
1721
+ */
1722
+ verifyWithDeviceKey(req, { method, signedPath, body }) {
1723
+ const header = readHeader(req.headers, DEVICE_SIGNATURE_HEADER);
1724
+ if (typeof header !== 'string' || header === '') return null;
1725
+
1726
+ const machineId = this.machineIdForRequest(req);
1727
+ if (machineId === null) return null;
1728
+ const publicKey = this.state.devices.get(machineId)?.public_key;
1729
+ if (typeof publicKey !== 'string' || publicKey === '') return null;
1730
+
1731
+ const decision = verifyDeviceSignature({
1732
+ headers: req.headers,
1733
+ publicKeyPem: publicKey,
1734
+ method,
1735
+ path: signedPath,
1736
+ body: body ?? '',
1737
+ nowMs: this.state.nowMs(),
1738
+ skewSeconds: this.signatureSkewSeconds,
1739
+ // `accept`, not `remember` -- the name this class actually exposes. It reports whether the
1740
+ // nonce was new, which is exactly the contract `verifyDeviceSignature` expects.
1741
+ nonces: (nonce, at) => this.nonceCache.accept(nonce, at),
1742
+ });
1743
+ if (decision.ok) return { ...decision, machine_id: machineId, keyIndex: null };
1744
+ return decision;
1745
+ }
1746
+
1747
+ /**
1748
+ * Which machine a request claims to be, from either credential.
1749
+ *
1750
+ * An **operator token is not a machine**, so it resolves to `null` and the device path does not
1751
+ * apply — consistent with `authorizeTaskDispatch`, which reports `machine_id: null` for an
1752
+ * operator. That is why an operator-token request keeps using the shared secret.
1753
+ */
1754
+ machineIdForRequest(req) {
1755
+ const token = bearerToken(req);
1756
+ if (!token) return null;
1757
+ if (this.operatorToken !== null && token === this.operatorToken) return null;
1758
+ return this.state.authenticate(token)?.machine_id ?? null;
1759
+ }
1760
+
1761
+ /** Periodic maintenance, called by the sweep timer. */
1762
+ maintenance() {
1763
+ const expired = this.sweep();
1764
+ /**
1765
+ * v0.9.1: presence expiry, alongside lease expiry.
1766
+ *
1767
+ * `this.sweep()` writes off lease *holds*; this writes off *presence*. They were conflated, so a
1768
+ * machine that stopped existing was only ever noticed when its SSE stream detached — and a
1769
+ * half-open stream never detaches.
1770
+ */
1771
+ const silent = this.state.sweepSilentDevices();
1772
+ if (silent.length > 0) {
1773
+ this._log(`[w2m-rabbit] marked ${silent.length} silent device(s) offline: `
1774
+ + silent.map((s) => `${s.machine_name} (${Math.round(s.silent_ms / 1000)}s)`).join(', '));
1775
+ }
1776
+ this.pairLimiter.prune();
1777
+ this.peers.sweep();
1778
+ // Keep the nonce cache from growing without bound in a long-running process.
1779
+ this.nonceCache.prune(this.nonceRetentionMs, this.state.nowMs());
1780
+ return expired;
1781
+ }
1782
+
1783
+ /* ---------------- auth helpers ---------------- */
1784
+
1785
+ /** §6: every /v1/pair attempt counts, success included. */
1786
+ enforcePairRateLimit(req) {
1787
+ if (this.pairRateLimitPerMinute <= 0) return;
1788
+ const ip = this.clientIp(req);
1789
+ const verdict = this.pairLimiter.hit(ip);
1790
+ if (verdict.allowed) return;
1791
+ const err = new ProtocolError(
1792
+ 'RATE_LIMITED',
1793
+ `too many /v1/pair attempts from ${ip}: limit is ${verdict.limit} per ${verdict.window_seconds}s`,
1794
+ {
1795
+ retry_after_seconds: verdict.retry_after_seconds,
1796
+ limit: verdict.limit,
1797
+ window_seconds: verdict.window_seconds,
1798
+ scope: 'pair',
1799
+ },
1800
+ );
1801
+ err.headers = { 'retry-after': String(verdict.retry_after_seconds) };
1802
+ throw err;
1803
+ }
1804
+
1805
+ /**
1806
+ * §5: `POST /v1/task` needs the operator token.
1807
+ * - required (default): operator token, else 401 OPERATOR_REQUIRED
1808
+ * - disabled (`--operator-token ''`): falls back to v1 semantics, i.e. a paired device token
1809
+ */
1810
+ authorizeTaskDispatch(req) {
1811
+ const token = bearerToken(req);
1812
+ if (this.operatorTokenRequired) {
1813
+ if (!token) {
1814
+ throw new ProtocolError(
1815
+ 'OPERATOR_REQUIRED',
1816
+ 'POST /v1/task requires the operator token. A device_token is machine credentials and cannot '
1817
+ + `dispatch tasks; read the operator token from ${this.persistence.operatorTokenFile ?? '<state>/operator-token.txt'} `
1818
+ + 'or pass --operator-token / W2M_OPERATOR_TOKEN.',
1819
+ { operator_token_file: this.persistence.operatorTokenFile ?? null },
1820
+ );
1821
+ }
1822
+ if (token !== this.operatorToken) {
1823
+ const asDevice = this.state.authenticate(token);
1824
+ throw new ProtocolError(
1825
+ 'OPERATOR_REQUIRED',
1826
+ asDevice
1827
+ ? 'this is a device_token (machine credentials); dispatching tasks requires the operator_token — '
1828
+ + 'they are two different credentials'
1829
+ : 'invalid operator token: it does not match the relay operator token',
1830
+ {
1831
+ device_token_detected: Boolean(asDevice),
1832
+ machine_id: asDevice?.machine_id ?? null,
1833
+ operator_token_file: this.persistence.operatorTokenFile ?? null,
1834
+ },
1835
+ );
1836
+ }
1837
+ return { role: 'operator', machine_id: null };
1838
+ }
1839
+ const device = token ? this.state.authenticate(token) : null;
1840
+ if (!device) {
1841
+ throw new ProtocolError(
1842
+ 'UNAUTHORIZED',
1843
+ 'the operator token requirement is disabled on this relay, but /v1/task still requires a valid device_token',
1844
+ {},
1845
+ );
1846
+ }
1847
+ return { role: 'device', machine_id: device.machine_id };
1848
+ }
1849
+
1850
+ /* ---------------- SSE (§3) ---------------- */
1851
+
1852
+ handleStream(req, res, url, device) {
1853
+ const machineId = device.machine_id;
1854
+ const requested = url.searchParams.get('machine_id');
1855
+ if (requested !== null && requested !== machineId) {
1856
+ throw new ProtocolError('BAD_REQUEST', 'machine_id does not match the device_token', {
1857
+ machine_id: requested, token_machine_id: machineId,
1858
+ });
1859
+ }
1860
+
1861
+ let from = null;
1862
+ const seqParam = url.searchParams.get('seq');
1863
+ const lastEventId = req.headers['last-event-id'];
1864
+ if (seqParam !== null) {
1865
+ from = Number(seqParam);
1866
+ if (!Number.isInteger(from) || from < 1) {
1867
+ throw new ProtocolError('BAD_REQUEST', 'seq must be a positive integer', { seq: seqParam });
1868
+ }
1869
+ } else if (typeof lastEventId === 'string' && lastEventId.trim() !== '') {
1870
+ const last = Number(lastEventId);
1871
+ if (!Number.isInteger(last) || last < 0) {
1872
+ throw new ProtocolError('BAD_REQUEST', 'Last-Event-ID must be a non-negative integer', { last_event_id: lastEventId });
1873
+ }
1874
+ from = last + 1;
1875
+ }
1876
+
1877
+ // §8.5: these two headers are the fix for "connects but no events" behind
1878
+ // nginx-style proxies and tunnels, which buffer event streams by default.
1879
+ res.writeHead(200, {
1880
+ 'content-type': 'text/event-stream; charset=utf-8',
1881
+ 'cache-control': 'no-cache, no-transform',
1882
+ connection: 'keep-alive',
1883
+ 'x-accel-buffering': 'no',
1884
+ });
1885
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
1886
+
1887
+ const sub = { machineId, res, write: null, timer: null, closed: false };
1888
+
1889
+ const writeEvent = (event) => {
1890
+ if (res.writableEnded) return;
1891
+ res.write(`id: ${event.seq}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
1892
+ };
1893
+
1894
+ let live = false;
1895
+ const queue = [];
1896
+ const deliver = (entry) => {
1897
+ if (entry.target && entry.target !== machineId) return;
1898
+ writeEvent(entry.event);
1899
+ };
1900
+ const unsubscribe = this.state.onEvent((entry) => {
1901
+ if (!live) { queue.push(entry); return; }
1902
+ deliver(entry);
1903
+ });
1904
+
1905
+ // §3.1: the first frame MUST be `ready`; §8.3 adds `relay_id`.
1906
+ const readySeq = this.state.reserveSeq();
1907
+ writeEvent({
1908
+ type: 'ready',
1909
+ seq: readySeq,
1910
+ protocol_version: PROTOCOL_VERSION,
1911
+ rabbit_time: this.state.nowIso(),
1912
+ machine_id: machineId,
1913
+ relay_id: this.relayId,
1914
+ });
1915
+
1916
+ if (from !== null) {
1917
+ const oldest = this.state.oldestBufferedSeq();
1918
+ const lastBuffered = this.state.lastBufferedSeq();
1919
+ // `fromFuture` is judged against EVERY seq this process has allocated (a client
1920
+ // can legitimately hold a `ready` seq, which is not buffered), while the
1921
+ // reported window bounds describe the replay ring the client can actually use.
1922
+ const lastSeq = this.state.lastSeq();
1923
+ // Two distinct ways for a cursor to be out of sync with THIS process:
1924
+ //
1925
+ // from < oldest the cursor is older than the replay window (buffer rot)
1926
+ // from > lastSeq+1 the cursor cannot exist here at all. Sequence numbers
1927
+ // restart at 1 in every process, so a cursor from a
1928
+ // previous relay process is AHEAD of a freshly started
1929
+ // relay. This was the silent case: `13 < 1` is false, the
1930
+ // client got no notice, `replayEntries(13)` matched
1931
+ // nothing, and the agent then reset its cursor to the
1932
+ // `ready` seq -- stepping over every event it had missed.
1933
+ const tooOld = from < oldest && oldest > 1;
1934
+ const fromFuture = from > lastSeq + 1;
1935
+ if (tooOld || fromFuture) {
1936
+ // §8.4: tell the client where to re-align, not just that replay failed.
1937
+ writeEvent({
1938
+ type: 'notice',
1939
+ seq: this.state.reserveSeq(),
1940
+ rabbit_time: this.state.nowIso(),
1941
+ level: 'warn',
1942
+ code: 'REPLAY_TRUNCATED',
1943
+ message: tooOld
1944
+ ? `requested seq ${from} is older than the buffer head ${oldest}`
1945
+ : `requested seq ${from} is beyond this process' last seq ${lastSeq}: the cursor came from a `
1946
+ + 'previous relay process, whose sequence numbering restarted at 1',
1947
+ machine_id: machineId,
1948
+ requested_seq: from,
1949
+ oldest_available_seq: oldest,
1950
+ last_available_seq: lastBuffered,
1951
+ relay_id: this.relayId,
1952
+ reason: tooOld ? 'buffer_truncated' : 'cursor_ahead_of_relay',
1953
+ });
1954
+ }
1955
+ // A cursor from a previous process is meaningless here, so honouring it
1956
+ // literally (replay from `from`) would deliver NOTHING and also lose every
1957
+ // event that fell into the gap -- not only offers, but `task.cancel`,
1958
+ // `notice`, `peer.*`. Re-align to the head of the window instead: the client
1959
+ // asked to be resynchronised, and the ring is exactly what we still have.
1960
+ const replayFrom = fromFuture ? oldest : from;
1961
+ for (const entry of this.state.replayEntries(replayFrom)) deliver(entry);
1962
+ }
1963
+
1964
+ live = true;
1965
+ for (const entry of queue) deliver(entry);
1966
+ queue.length = 0;
1967
+
1968
+ // §8.6: re-state work this machine was handed while its stream was down.
1969
+ //
1970
+ // An offer is a single event; if the stream is not connected at that instant
1971
+ // -- after a relay restart, a tunnel drop, or any interruption -- the event
1972
+ // is lost and the lease waits out its whole window before being swept. The
1973
+ // machine is meanwhile online and idle. Re-delivering on connect is what
1974
+ // makes a reconnect recover work instead of silently losing it.
1975
+ //
1976
+ // Only unclaimed leases are re-offered; `running` is excluded so a task that
1977
+ // has already begun cannot be handed out a second time and executed twice.
1978
+ const redelivered = this.state.redeliverPendingOffers(machineId);
1979
+ if (redelivered.count > 0) {
1980
+ this._log(`[w2m-rabbit] re-offered ${redelivered.count} pending task(s) to ${machineId} on reconnect`);
1981
+ }
1982
+
1983
+ // §3.2: `: keepalive` comment every 15s.
1984
+ sub.timer = setInterval(() => {
1985
+ if (res.writableEnded) return;
1986
+ res.write(': keepalive\n\n');
1987
+ }, this.keepaliveMs);
1988
+ sub.timer.unref?.();
1989
+
1990
+ sub.write = writeEvent;
1991
+ sub.unsubscribe = unsubscribe;
1992
+ this.subscribers.add(sub);
1993
+ device.streams = (device.streams ?? 0) + 1;
1994
+ // v0.3.0: relay-observed reconnect counter. A first attach is not a reconnect,
1995
+ // so the reported `reconnect_attempts` is (attaches - 1).
1996
+ this.state.noteStreamConnect(machineId);
1997
+
1998
+ const onClose = () => this._closeSubscriber(sub);
1999
+ req.on('close', onClose);
2000
+ res.on('close', onClose);
2001
+ res.on('error', onClose);
2002
+ return undefined;
2003
+ }
2004
+
2005
+ _closeSubscriber(sub) {
2006
+ if (!sub || sub.closed) return;
2007
+ sub.closed = true;
2008
+ this.subscribers.delete(sub);
2009
+ if (sub.timer) clearInterval(sub.timer);
2010
+ sub.timer = null;
2011
+ sub.unsubscribe?.();
2012
+ const device = this.state.devices.get(sub.machineId);
2013
+ if (device) {
2014
+ device.streams = Math.max(0, (device.streams ?? 1) - 1);
2015
+ if (device.streams === 0) this.state.setOnline(sub.machineId, false, 'stream_closed');
2016
+ }
2017
+ if (!sub.res.writableEnded) {
2018
+ try { sub.res.end(); } catch { /* already gone */ }
2019
+ }
2020
+ }
2021
+ }
2022
+
2023
+ /** Convenience factory. */
2024
+ export function createRelayServer(options = {}) {
2025
+ return new RelayServer(options);
2026
+ }
2027
+
2028
+ /** Create + listen in one call. */
2029
+ export async function startRelayServer(options = {}) {
2030
+ const relay = createRelayServer(options);
2031
+ await relay.listen(options);
2032
+ return relay;
2033
+ }
2034
+
2035
+ export {
2036
+ Persistence,
2037
+ ProtocolError,
2038
+ RabbitState,
2039
+ MAX_FRAME_BYTES,
2040
+ PROTOCOL_VERSION,
2041
+ rfc3339,
2042
+ };
2043
+ export default createRelayServer;
2044
+