fieldlog 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/bin/fieldlog.js +18 -0
- package/bin/fieldlog.ts +145 -0
- package/package.json +35 -0
- package/src/auth.ts +297 -0
- package/src/cas.ts +357 -0
- package/src/deltasync.ts +306 -0
- package/src/hashchain.ts +106 -0
- package/src/index.ts +41 -0
- package/src/kernel.ts +333 -0
- package/src/log.ts +344 -0
- package/src/quota.ts +122 -0
- package/src/relay.ts +1027 -0
- package/src/retain.ts +267 -0
- package/src/revokelog.ts +291 -0
- package/src/store.ts +706 -0
- package/src/sync.ts +828 -0
- package/src/tombstone.ts +306 -0
package/src/relay.ts
ADDED
|
@@ -0,0 +1,1027 @@
|
|
|
1
|
+
// relay.ts — real ws transport over Bun.serve, no extra deps.
|
|
2
|
+
// The relay stays simple: accept raw log, broadcast, store. No business logic.
|
|
3
|
+
// Crash model: the server persists every stored event to a JSONL file BEFORE
|
|
4
|
+
// acking, so kill+restart + client resume from the ack cursor is exact-once
|
|
5
|
+
// by UUID. Live broadcast is a hint only — pull is the source of truth.
|
|
6
|
+
import { existsSync, fsyncSync, mkdirSync, openSync, closeSync, readFileSync, statSync, writeSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname } from 'node:path';
|
|
8
|
+
import type { Server, ServerWebSocket } from 'bun';
|
|
9
|
+
import type { LogEvent } from './log.js';
|
|
10
|
+
import { backoffMs, type PushAck, type Relay } from './sync.js';
|
|
11
|
+
import { verifyCapToken, type CapToken } from './auth.js';
|
|
12
|
+
import { RevokeLog, type RevokeEvent, type RevokeInput } from './revokelog.js';
|
|
13
|
+
|
|
14
|
+
/** DoS budgets (RvSec-1): the relay accepts raw logs from token holders, so
|
|
15
|
+
* every unbounded surface needs a cap enforced BEFORE store/persist/broadcast.
|
|
16
|
+
* Defaults are generous (steady-state sync pushes chunks of ~10); per-instance
|
|
17
|
+
* overrides exist so tests can pin each guard with tiny values. */
|
|
18
|
+
export const MAX_BATCH_EVENTS = 1000;
|
|
19
|
+
export const MAX_EVENT_BYTES = 256 * 1024;
|
|
20
|
+
export const MAX_PULL_EVENTS = 5000;
|
|
21
|
+
export const MAX_PULL_TOTAL_BYTES = 4 * 1024 * 1024;
|
|
22
|
+
export const MAX_REVOKE_BATCH_EVENTS = 1000;
|
|
23
|
+
export const MAX_RAW_MESSAGE_BYTES = 4 * 1024 * 1024;
|
|
24
|
+
export const MAX_RELAY_FILE_BYTES = 512 * 1024 * 1024;
|
|
25
|
+
|
|
26
|
+
/** UTF-8 byte length (not UTF-16 .length): multibyte payloads must not undercount. */
|
|
27
|
+
export function utf8Bytes(s: string): number {
|
|
28
|
+
if (typeof Buffer !== 'undefined' && typeof Buffer.byteLength === 'function') {
|
|
29
|
+
try { return Buffer.byteLength(s, 'utf8'); } catch { /* fall through */ }
|
|
30
|
+
}
|
|
31
|
+
return new TextEncoder().encode(s).length;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WsRelayServerOpts {
|
|
35
|
+
port?: number; // 0 = ephemeral (read back via .port)
|
|
36
|
+
file?: string; // JSONL persistence; reloaded on boot
|
|
37
|
+
hbMs?: number; // server ping interval
|
|
38
|
+
dropRate?: number; // chaos 0..1: fraction of inbound msgs dropped (deterministic)
|
|
39
|
+
seed?: number; // chaos rng seed
|
|
40
|
+
/** Pre-trusted devices: deviceId -> ed25519 publicKeyPem. Enforcement is on when non-empty. */
|
|
41
|
+
trustedDevices?: Record<string, string>;
|
|
42
|
+
/** Admin registry for the convergent revoke log (deviceId -> ed25519 publicKeyPem). */
|
|
43
|
+
revokeAdmins?: Record<string, string>;
|
|
44
|
+
/** Refuse to serve without a device registry (default true = legacy open
|
|
45
|
+
* relay for library/dev use). The CLI passes false unless --unsigned. */
|
|
46
|
+
allowUnsigned?: boolean;
|
|
47
|
+
/** Budget overrides (default the MAX_* constants above). */
|
|
48
|
+
maxBatchEvents?: number;
|
|
49
|
+
maxEventBytes?: number;
|
|
50
|
+
maxRevokeBatchEvents?: number;
|
|
51
|
+
maxPullEvents?: number;
|
|
52
|
+
maxRawMessageBytes?: number;
|
|
53
|
+
maxPullTotalBytes?: number;
|
|
54
|
+
maxFileBytes?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type ToServer =
|
|
58
|
+
| { op: 'push'; req: number; events: LogEvent[]; token?: CapToken }
|
|
59
|
+
| { op: 'pull'; req: number; since: number; token?: CapToken }
|
|
60
|
+
| { op: 'revoke_pull'; req: number; cursor: number }
|
|
61
|
+
| { op: 'revoke_push'; req: number; events: RevokeEvent[] }
|
|
62
|
+
| { op: 'pong' };
|
|
63
|
+
|
|
64
|
+
type ToClient =
|
|
65
|
+
| { op: 'push_ack'; req: number; acked: string[]; server_time: number }
|
|
66
|
+
| { op: 'pull_res'; req: number; events: LogEvent[]; cursor: number }
|
|
67
|
+
| { op: 'revoke_res'; req: number; events: RevokeEvent[]; cursor: number }
|
|
68
|
+
| { op: 'revoke_ack'; req: number; added: number; skipped: number; rejected: number; cursor: number }
|
|
69
|
+
| { op: 'live'; events: LogEvent[] }
|
|
70
|
+
| { op: 'revoked'; deviceId: string }
|
|
71
|
+
| { op: 'error'; req: number; code: string; message: string }
|
|
72
|
+
| { op: 'ping' };
|
|
73
|
+
|
|
74
|
+
/** Deterministic rng so chaos tests reproduce. */
|
|
75
|
+
export function mulberry32(seed: number): () => number {
|
|
76
|
+
let a = seed >>> 0;
|
|
77
|
+
return () => {
|
|
78
|
+
a |= 0;
|
|
79
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
80
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
81
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
82
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface SockState {
|
|
87
|
+
lastPong: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class WsRelayServer {
|
|
91
|
+
private byId = new Map<string, LogEvent>();
|
|
92
|
+
private order: LogEvent[] = [];
|
|
93
|
+
private server: Server<SockState> | null = null;
|
|
94
|
+
private sockets = new Set<ServerWebSocket<SockState>>();
|
|
95
|
+
private hbTimer: Timer | undefined = undefined;
|
|
96
|
+
private logFd: number | null = null;
|
|
97
|
+
private devices = new Map<string, string>(); // deviceId -> publicKeyPem
|
|
98
|
+
private revoked = new Set<string>(); // deviceIds; tombstones broadcast + persisted
|
|
99
|
+
/** Convergent authenticated revoke log (revokelog.ts); empty when no admin configured. */
|
|
100
|
+
readonly revokes = new RevokeLog();
|
|
101
|
+
private revokedDevices = new Set<string>(); // device-tombstone cache, valid while revokes.size is stable
|
|
102
|
+
private revokedDevicesAt = -1;
|
|
103
|
+
/** Per-token verdicts keyed on tokenId+epoch, valid while revokes.size is
|
|
104
|
+
* stable. authorize() runs per message and RevokeLog.isRevoked scans the
|
|
105
|
+
* log, so a stable log must answer from cache (O(1) hit); any mutation
|
|
106
|
+
* bumps size and invalidates every entry at once. */
|
|
107
|
+
private tokenVerdicts = new Map<string, { size: number; verdict: boolean }>();
|
|
108
|
+
serverTime = 1_700_000_000_000;
|
|
109
|
+
pushesReceived = 0;
|
|
110
|
+
pullsReceived = 0;
|
|
111
|
+
pongsReceived = 0;
|
|
112
|
+
rejectsReceived = 0;
|
|
113
|
+
revokePullsReceived = 0;
|
|
114
|
+
revokePushesReceived = 0;
|
|
115
|
+
revokeRejected = 0; // forged/dangling revoke events refused over the wire, never stored
|
|
116
|
+
crashAfter: number | null = null;
|
|
117
|
+
private rng: () => number;
|
|
118
|
+
constructor(private opts: WsRelayServerOpts = {}) {
|
|
119
|
+
this.rng = mulberry32(opts.seed ?? 1);
|
|
120
|
+
if (opts.trustedDevices) {
|
|
121
|
+
for (const [id, pem] of Object.entries(opts.trustedDevices)) this.devices.set(id, pem);
|
|
122
|
+
}
|
|
123
|
+
if (opts.revokeAdmins) {
|
|
124
|
+
for (const [id, pem] of Object.entries(opts.revokeAdmins)) this.revokes.addAdmin(id, pem);
|
|
125
|
+
}
|
|
126
|
+
if (opts.file && existsSync(opts.file)) {
|
|
127
|
+
// Startup guard: the whole JSONL is read into RAM below, so refuse an
|
|
128
|
+
// over-budget file instead of OOMing (and OOMing again every restart,
|
|
129
|
+
// since the bytes persist). Fail loud with the path + sizes.
|
|
130
|
+
const maxBytes = opts.maxFileBytes ?? MAX_RELAY_FILE_BYTES;
|
|
131
|
+
let size = 0;
|
|
132
|
+
try {
|
|
133
|
+
size = statSync(opts.file).size;
|
|
134
|
+
} catch {
|
|
135
|
+
size = 0;
|
|
136
|
+
}
|
|
137
|
+
if (size > maxBytes) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`relay refuses to load oversized log '${opts.file}': ${size} bytes exceeds the ${maxBytes}-byte budget; ` +
|
|
140
|
+
`compact/rotate the file or raise maxFileBytes explicitly`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
for (const line of readFileSync(opts.file, 'utf8').split('\n')) {
|
|
144
|
+
const t = line.trim();
|
|
145
|
+
if (!t) continue;
|
|
146
|
+
try {
|
|
147
|
+
const ev = JSON.parse(t) as LogEvent;
|
|
148
|
+
if (!this.byId.has(ev.id)) {
|
|
149
|
+
this.byId.set(ev.id, ev);
|
|
150
|
+
this.order.push(ev);
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
/* corrupt relay line: not an event, skip on reload */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
this.loadRevocations();
|
|
158
|
+
this.loadRevokeLog();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Sidecar next to the event log; same file key, never mixed into event lines. */
|
|
162
|
+
private revokeFile(): string | null {
|
|
163
|
+
return this.opts.file ? this.opts.file + '.revocations' : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private loadRevocations(): void {
|
|
167
|
+
const f = this.revokeFile();
|
|
168
|
+
if (!f || !existsSync(f)) return;
|
|
169
|
+
try {
|
|
170
|
+
const ids = JSON.parse(readFileSync(f, 'utf8')) as string[];
|
|
171
|
+
for (const id of ids) this.revoked.add(id);
|
|
172
|
+
} catch {
|
|
173
|
+
/* corrupt revoke sidecar: fail closed on listed ids only, keep serving */
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private persistRevocations(): void {
|
|
178
|
+
const f = this.revokeFile();
|
|
179
|
+
if (!f) return;
|
|
180
|
+
const dir = dirname(f);
|
|
181
|
+
if (dir !== '' && dir !== '.') mkdirSync(dir, { recursive: true });
|
|
182
|
+
writeFileSync(f, JSON.stringify([...this.revoked].sort()));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Trust a device key. Enforcement turns on once at least one device is known. */
|
|
186
|
+
registerDevice(deviceId: string, publicKeyPem: string): void {
|
|
187
|
+
this.devices.set(deviceId, publicKeyPem);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Trust a revoke admin (same registry the handshake peers share). */
|
|
191
|
+
addRevokeAdmin(deviceId: string, publicKeyPem: string): void {
|
|
192
|
+
this.revokes.addAdmin(deviceId, publicKeyPem);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** JSONL next to the event log; one signed RevokeEvent per line, never mixed into event lines. */
|
|
196
|
+
private revokeLogFile(): string | null {
|
|
197
|
+
return this.opts.file ? this.opts.file + '.revoke-events' : null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private loadRevokeLog(): void {
|
|
201
|
+
const f = this.revokeLogFile();
|
|
202
|
+
if (!f || !existsSync(f)) return;
|
|
203
|
+
const batch: RevokeEvent[] = [];
|
|
204
|
+
for (const line of readFileSync(f, 'utf8').split('\n')) {
|
|
205
|
+
const t = line.trim();
|
|
206
|
+
if (!t) continue;
|
|
207
|
+
try {
|
|
208
|
+
batch.push(JSON.parse(t) as RevokeEvent);
|
|
209
|
+
} catch {
|
|
210
|
+
/* corrupt revoke line: not an event, skip on reload */
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Idempotent set-union: forged or dangling lines are counted as rejected, never stored.
|
|
214
|
+
this.revokeRejected += this.revokes.merge(batch).rejected;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private persistRevokes(fresh: RevokeEvent[]): void {
|
|
218
|
+
const f = this.revokeLogFile();
|
|
219
|
+
if (!f || fresh.length === 0) return;
|
|
220
|
+
const dir = dirname(f);
|
|
221
|
+
if (dir !== '' && dir !== '.') mkdirSync(dir, { recursive: true });
|
|
222
|
+
const fd = openSync(f, 'a');
|
|
223
|
+
try {
|
|
224
|
+
writeSync(fd, fresh.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
|
225
|
+
fsyncSync(fd); // durable before any ack: restart loses nothing
|
|
226
|
+
} finally {
|
|
227
|
+
closeSync(fd);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Admin-signed revoke into the convergent log (persists + converges via handshake). */
|
|
232
|
+
issueRevoke(adminPrivatePem: string, admin: string, input: RevokeInput, now: number = Date.now()): RevokeEvent {
|
|
233
|
+
const e = this.revokes.create(adminPrivatePem, admin, input, now);
|
|
234
|
+
this.persistRevokes([e]);
|
|
235
|
+
return e;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Canonical convergent view, byte-equal across replicas after full merge. */
|
|
239
|
+
revokeSnapshot(): RevokeEvent[] {
|
|
240
|
+
return this.revokes.snapshot();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Local revoke-log length; doubles as the next diffSince cursor. */
|
|
244
|
+
revokeCursor(): number {
|
|
245
|
+
return this.revokes.size;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Whole-device tombstone derived from the log: some '*' event names this device. */
|
|
249
|
+
isDeviceRevokedByLog(deviceId: string): boolean {
|
|
250
|
+
for (const e of this.revokes.snapshot()) {
|
|
251
|
+
if (e.tokenId === '*' && e.deviceId === deviceId) return true;
|
|
252
|
+
}
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Per-token kill: some event for tokenId carries an equal-or-higher epoch. */
|
|
257
|
+
isTokenRevoked(tokenId: string, tokenEpoch = 0): boolean {
|
|
258
|
+
return this.cachedIsTokenRevoked(tokenId, tokenEpoch);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Size-keyed verdict cache: hits avoid the per-message log scan, and any
|
|
262
|
+
* log mutation (size bump) invalidates every entry on next read. */
|
|
263
|
+
private cachedIsTokenRevoked(tokenId: string, tokenEpoch = 0): boolean {
|
|
264
|
+
const key = `${tokenId}:${tokenEpoch}`;
|
|
265
|
+
const size = this.revokes.size;
|
|
266
|
+
const hit = this.tokenVerdicts.get(key);
|
|
267
|
+
if (hit && hit.size === size) return hit.verdict;
|
|
268
|
+
const verdict = this.revokes.isRevoked(tokenId, tokenEpoch);
|
|
269
|
+
this.tokenVerdicts.set(key, { size, verdict });
|
|
270
|
+
return verdict;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Revoke a device: future push/pull rejected, tombstone broadcast + persisted. */
|
|
274
|
+
revokeDevice(deviceId: string): void {
|
|
275
|
+
this.revoked.add(deviceId);
|
|
276
|
+
this.persistRevocations();
|
|
277
|
+
const msg = JSON.stringify({ op: 'revoked', deviceId } satisfies ToClient);
|
|
278
|
+
for (const ws of this.sockets) {
|
|
279
|
+
try {
|
|
280
|
+
ws.send(msg);
|
|
281
|
+
} catch {
|
|
282
|
+
/* gone */
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
isRevoked(deviceId: string): boolean {
|
|
288
|
+
return this.revoked.has(deviceId) || this.isDeviceRevokedByLog(deviceId);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
revokedIds(): string[] {
|
|
292
|
+
const ids = new Set<string>(this.revoked);
|
|
293
|
+
for (const e of this.revokes.snapshot()) {
|
|
294
|
+
if (e.tokenId === '*') ids.add(e.deviceId);
|
|
295
|
+
}
|
|
296
|
+
return [...ids].sort();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
get enforcing(): boolean {
|
|
300
|
+
return this.devices.size > 0;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
get port(): number {
|
|
304
|
+
if (!this.server) throw new Error('relay not started');
|
|
305
|
+
return this.server.port ?? 0;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
get size(): number {
|
|
309
|
+
return this.byId.size;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Every stored UUID on disk, for exact-once audits. */
|
|
313
|
+
storedIds(): string[] {
|
|
314
|
+
return this.order.map((e) => e.id);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async start(): Promise<number> {
|
|
318
|
+
// Double-start guard: a second serve would orphan the first listener and
|
|
319
|
+
// leak its log fd (kill() only tracks the latest). Start once; kill, then
|
|
320
|
+
// start again for a deliberate restart.
|
|
321
|
+
if (this.server) throw new Error('relay already started');
|
|
322
|
+
// Fail closed: an unsigned relay accepts any forged device_id, so
|
|
323
|
+
// production entrypoints must register a device or opt into --unsigned.
|
|
324
|
+
if (!this.enforcing && this.opts.allowUnsigned === false) {
|
|
325
|
+
throw new Error('relay refuses unsigned mode: register a trusted device or pass --unsigned to opt into the open relay');
|
|
326
|
+
}
|
|
327
|
+
const self = this;
|
|
328
|
+
if (this.opts.file) {
|
|
329
|
+
const dir = dirname(this.opts.file);
|
|
330
|
+
if (dir !== '' && dir !== '.') mkdirSync(dir, { recursive: true });
|
|
331
|
+
this.logFd = openSync(this.opts.file, 'a');
|
|
332
|
+
}
|
|
333
|
+
this.server = Bun.serve<SockState>({
|
|
334
|
+
port: this.opts.port ?? 0,
|
|
335
|
+
fetch(req, server) {
|
|
336
|
+
if (server.upgrade(req, { data: { lastPong: Date.now() } })) return;
|
|
337
|
+
return new Response('fieldlog relay', { status: 200 });
|
|
338
|
+
},
|
|
339
|
+
websocket: {
|
|
340
|
+
open(ws) {
|
|
341
|
+
self.sockets.add(ws);
|
|
342
|
+
},
|
|
343
|
+
close(ws) {
|
|
344
|
+
self.sockets.delete(ws);
|
|
345
|
+
},
|
|
346
|
+
message(ws, raw) {
|
|
347
|
+
self.onMessage(ws, String(raw));
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
const hbMs = this.opts.hbMs ?? 1000;
|
|
352
|
+
this.hbTimer = setInterval(() => {
|
|
353
|
+
const now = Date.now();
|
|
354
|
+
for (const ws of [...this.sockets]) {
|
|
355
|
+
if (now - ws.data.lastPong > hbMs * 3) {
|
|
356
|
+
try {
|
|
357
|
+
ws.close();
|
|
358
|
+
} catch {
|
|
359
|
+
/* gone */
|
|
360
|
+
}
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
ws.send(JSON.stringify({ op: 'ping' } satisfies ToClient));
|
|
365
|
+
} catch {
|
|
366
|
+
/* gone */
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}, hbMs);
|
|
370
|
+
return this.port;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Abrupt kill: no graceful close, in-flight requests die unacked. */
|
|
374
|
+
kill(): void {
|
|
375
|
+
clearInterval(this.hbTimer);
|
|
376
|
+
this.hbTimer = undefined;
|
|
377
|
+
for (const ws of [...this.sockets]) {
|
|
378
|
+
try {
|
|
379
|
+
ws.close();
|
|
380
|
+
} catch {
|
|
381
|
+
/* gone */
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
this.sockets.clear();
|
|
385
|
+
if (this.logFd !== null) {
|
|
386
|
+
try {
|
|
387
|
+
closeSync(this.logFd);
|
|
388
|
+
} catch {
|
|
389
|
+
/* gone */
|
|
390
|
+
}
|
|
391
|
+
this.logFd = null;
|
|
392
|
+
}
|
|
393
|
+
this.server?.stop(true);
|
|
394
|
+
this.server = null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private persist(evs: LogEvent[]): void {
|
|
398
|
+
if (!this.opts.file || evs.length === 0) return;
|
|
399
|
+
// Fail closed: a configured log with no open fd must never be acked as
|
|
400
|
+
// durable. Throw so the push path answers error instead of push_ack.
|
|
401
|
+
if (this.logFd === null) throw new Error('relay persist unavailable: event log not open, refusing to ack unwritten events');
|
|
402
|
+
writeSync(this.logFd, evs.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
|
403
|
+
fsyncSync(this.logFd); // durable before any ack: restart loses nothing
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private onMessage(
|
|
407
|
+
ws: ServerWebSocket<SockState>,
|
|
408
|
+
raw: string,
|
|
409
|
+
): void {
|
|
410
|
+
// Raw-message cap BEFORE JSON.parse: a giant anonymous frame is rejected
|
|
411
|
+
// cheaply without parse/verify/store work. Best-effort req echo for the
|
|
412
|
+
// client; unparseable frames are just dropped.
|
|
413
|
+
const maxRaw = this.opts.maxRawMessageBytes ?? MAX_RAW_MESSAGE_BYTES;
|
|
414
|
+
const rawBytes = utf8Bytes(raw);
|
|
415
|
+
if (rawBytes > maxRaw) {
|
|
416
|
+
let req = -1;
|
|
417
|
+
try {
|
|
418
|
+
const probe = JSON.parse(raw) as { req?: unknown };
|
|
419
|
+
if (Number.isInteger(probe?.req)) req = probe.req as number;
|
|
420
|
+
} catch {
|
|
421
|
+
/* unparseable: drop below */
|
|
422
|
+
}
|
|
423
|
+
if (req >= 0) {
|
|
424
|
+
this.send(ws, { op: 'error', req, code: 'bad_batch', message: `relay rejected message: raw frame exceeds ${maxRaw} bytes (${rawBytes})` });
|
|
425
|
+
}
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
let msg: ToServer;
|
|
429
|
+
try {
|
|
430
|
+
msg = JSON.parse(raw) as ToServer;
|
|
431
|
+
} catch {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (msg.op === 'pong') {
|
|
435
|
+
this.pongsReceived += 1;
|
|
436
|
+
ws.data.lastPong = Date.now();
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
// Revoke handshake bypasses the capability gate and the chaos drop: the
|
|
440
|
+
// events are self-authenticating (admin ed25519 over the content hash),
|
|
441
|
+
// idempotent by hash, and a revoked device must still learn its own
|
|
442
|
+
// revocation on reconnect. Forgeries are counted as rejected, never stored.
|
|
443
|
+
if (msg.op === 'revoke_pull') {
|
|
444
|
+
this.revokePullsReceived += 1;
|
|
445
|
+
let out: { events: RevokeEvent[]; cursor: number };
|
|
446
|
+
try {
|
|
447
|
+
out = this.revokes.diffSince(msg.cursor);
|
|
448
|
+
} catch {
|
|
449
|
+
this.send(ws, { op: 'error', req: msg.req, code: 'bad_cursor', message: `revoke rejected: bad cursor ${msg.cursor}` });
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
this.send(ws, { op: 'revoke_res', req: msg.req, events: out.events, cursor: out.cursor });
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (msg.op === 'revoke_push') {
|
|
456
|
+
this.revokePushesReceived += 1;
|
|
457
|
+
const batch = Array.isArray(msg.events) ? msg.events : [];
|
|
458
|
+
// Revoke budget gate BEFORE merge: each merged event costs an ed25519
|
|
459
|
+
// verify, so an unbounded anonymous batch is CPU-DoS. Reject cheaply
|
|
460
|
+
// with bad_batch, never verified/stored.
|
|
461
|
+
if (!this.checkRevokeBudget(ws, msg.req, batch)) return;
|
|
462
|
+
// No snapshot sorts here: merge only appends, so the pre-merge size is
|
|
463
|
+
// the exact cursor of the fresh suffix — diffSince slices it for free.
|
|
464
|
+
const cursorBefore = this.revokes.size;
|
|
465
|
+
const res = this.revokes.merge(batch);
|
|
466
|
+
this.revokeRejected += res.rejected;
|
|
467
|
+
this.persistRevokes(this.revokes.diffSince(cursorBefore).events);
|
|
468
|
+
this.send(ws, { op: 'revoke_ack', req: msg.req, added: res.added, skipped: res.skipped, rejected: res.rejected, cursor: this.revokes.size });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
// Capability gate runs before chaos: rejected payloads are never stored.
|
|
472
|
+
if (msg.op === 'push') {
|
|
473
|
+
if (!this.authorize(ws, msg.req, msg.token, 'relay:push')) return;
|
|
474
|
+
// Budget gate runs before chaos too: an over-budget batch must be
|
|
475
|
+
// rejected before store, never written-ahead into the chaos drop path.
|
|
476
|
+
if (!this.checkPushBudget(ws, msg.req, msg.events)) return;
|
|
477
|
+
} else if (msg.op === 'pull') {
|
|
478
|
+
if (!this.authorize(ws, msg.req, msg.token, 'relay:pull')) return;
|
|
479
|
+
}
|
|
480
|
+
// Chaos: message is dropped. Pushes are still stored first (write-ahead),
|
|
481
|
+
// but no ack/response goes out and the socket dies — the client must
|
|
482
|
+
// resume and the UUID dedupe must hold.
|
|
483
|
+
if ((this.opts.dropRate ?? 0) > 0 && this.rng() < (this.opts.dropRate ?? 0)) {
|
|
484
|
+
if (msg.op === 'push' && Array.isArray(msg.events)) {
|
|
485
|
+
try {
|
|
486
|
+
this.store(msg.events);
|
|
487
|
+
} catch {
|
|
488
|
+
/* persist failed: stay unacked, the socket dies below, client resumes */
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
ws.close();
|
|
493
|
+
} catch {
|
|
494
|
+
/* gone */
|
|
495
|
+
}
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (msg.op === 'push') {
|
|
499
|
+
this.pushesReceived += 1;
|
|
500
|
+
if (!Array.isArray(msg.events)) {
|
|
501
|
+
this.send(ws, { op: 'error', req: msg.req, code: 'bad_batch', message: 'relay rejected push: events must be an array' });
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
let fresh: LogEvent[];
|
|
505
|
+
try {
|
|
506
|
+
fresh = this.store(msg.events);
|
|
507
|
+
} catch (err) {
|
|
508
|
+
// Persist failed (e.g. log fd gone): nothing below is durable, so
|
|
509
|
+
// answer error instead of acking unwritten events. The client keeps
|
|
510
|
+
// the batch unacked and resumes it elsewhere.
|
|
511
|
+
this.send(ws, { op: 'error', req: msg.req, code: 'persist', message: `relay rejected push: ${err instanceof Error ? err.message : 'persist failed'}` });
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
this.serverTime += 1;
|
|
515
|
+
this.broadcast(fresh, ws);
|
|
516
|
+
if (this.crashAfter !== null) {
|
|
517
|
+
this.crashAfter -= 1;
|
|
518
|
+
if (this.crashAfter <= 0) {
|
|
519
|
+
this.crashAfter = null;
|
|
520
|
+
this.kill(); // crash BEFORE ack: client resumes unacked work
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
// Ack only ids confirmed stored (fresh or already-known duplicates):
|
|
525
|
+
// never blind-echo the inbound batch, so intra-batch repeats collapse
|
|
526
|
+
// to one ack and unwritten ids are never acked.
|
|
527
|
+
const acked = [...new Set(msg.events.map((e) => e.id).filter((id) => this.byId.has(id)))];
|
|
528
|
+
this.send(ws, { op: 'push_ack', req: msg.req, acked, server_time: this.serverTime });
|
|
529
|
+
} else if (msg.op === 'pull') {
|
|
530
|
+
this.pullsReceived += 1;
|
|
531
|
+
// Cursor parity with revoke_pull: a non-integer or negative cursor is
|
|
532
|
+
// a bad_cursor error, not a full dump (undefined) or a tail-from-end
|
|
533
|
+
// (negative) via slice coercion.
|
|
534
|
+
if (!Number.isInteger(msg.since) || (msg.since as number) < 0) {
|
|
535
|
+
this.send(ws, { op: 'error', req: msg.req, code: 'bad_cursor', message: `relay rejected pull: bad cursor ${String(msg.since)}` });
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
// Pagination cap: one pull_res never ships the whole tail. The cursor
|
|
539
|
+
// stays global (order.length), so the client paginates with since +=
|
|
540
|
+
// events.length until the returned prefix covers the cursor. A total
|
|
541
|
+
// byte cap bounds giant single events that a count cap cannot.
|
|
542
|
+
const cap = this.opts.maxPullEvents ?? MAX_PULL_EVENTS;
|
|
543
|
+
const maxTotal = this.opts.maxPullTotalBytes ?? MAX_PULL_TOTAL_BYTES;
|
|
544
|
+
const page = this.order.slice(msg.since, msg.since + cap);
|
|
545
|
+
let total = 0;
|
|
546
|
+
let kept = page.length;
|
|
547
|
+
for (let i = 0; i < page.length; i++) {
|
|
548
|
+
total += utf8Bytes(JSON.stringify(page[i]));
|
|
549
|
+
if (total > maxTotal) { kept = i; break; }
|
|
550
|
+
}
|
|
551
|
+
if (page.length === 0) {
|
|
552
|
+
this.send(ws, { op: 'pull_res', req: msg.req, events: [], cursor: this.order.length });
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (kept === 0) {
|
|
556
|
+
this.send(ws, { op: 'error', req: msg.req, code: 'too_large', message: `relay rejected pull: pull page exceeds ${maxTotal} bytes` });
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const events = page.slice(0, kept);
|
|
560
|
+
this.send(ws, { op: 'pull_res', req: msg.req, events, cursor: this.order.length });
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private authorize(
|
|
565
|
+
ws: ServerWebSocket<SockState>,
|
|
566
|
+
req: number,
|
|
567
|
+
token: CapToken | undefined,
|
|
568
|
+
scope: string,
|
|
569
|
+
): boolean {
|
|
570
|
+
if (!this.enforcing) return true;
|
|
571
|
+
const fail = (message: string): boolean => {
|
|
572
|
+
this.rejectsReceived += 1;
|
|
573
|
+
this.send(ws, { op: 'error', req, code: 'forbidden', message });
|
|
574
|
+
return false;
|
|
575
|
+
};
|
|
576
|
+
if (!token) return fail('missing capability token');
|
|
577
|
+
if (this.revoked.has(token.deviceId)) return fail(`device revoked: ${token.deviceId}`);
|
|
578
|
+
if (this.revokes.size > 0) {
|
|
579
|
+
// Device-tombstone set cached while the revoke log length is stable:
|
|
580
|
+
// one sort per mutation, not one per gated message. (Empty log needs
|
|
581
|
+
// no sort at all — nothing can match.)
|
|
582
|
+
if (this.revokedDevicesAt !== this.revokes.size) {
|
|
583
|
+
const ids = new Set<string>();
|
|
584
|
+
for (const e of this.revokes.snapshot()) {
|
|
585
|
+
if (e.tokenId === '*') ids.add(e.deviceId);
|
|
586
|
+
}
|
|
587
|
+
this.revokedDevices = ids;
|
|
588
|
+
this.revokedDevicesAt = this.revokes.size;
|
|
589
|
+
}
|
|
590
|
+
if (this.revokedDevices.has(token.deviceId)) {
|
|
591
|
+
return fail(`device revoked: ${token.deviceId}`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (token.id && this.cachedIsTokenRevoked(token.id)) return fail(`token revoked: ${token.id}`);
|
|
595
|
+
const pem = this.devices.get(token.deviceId);
|
|
596
|
+
if (!pem) return fail(`unknown device: ${token.deviceId}`);
|
|
597
|
+
if (!verifyCapToken(pem, token, scope)) return fail(`capability rejected for ${scope}`);
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Push budget gate: count + per-event bytes, rejected with bad_batch
|
|
602
|
+
* BEFORE store/persist/broadcast. Returns true when the batch may proceed.
|
|
603
|
+
* Non-array payloads fall through (the push path answers bad_batch itself). */
|
|
604
|
+
private checkPushBudget(ws: ServerWebSocket<SockState>, req: number, events: unknown): boolean {
|
|
605
|
+
if (!Array.isArray(events)) return true;
|
|
606
|
+
const maxBatch = this.opts.maxBatchEvents ?? MAX_BATCH_EVENTS;
|
|
607
|
+
if (events.length > maxBatch) {
|
|
608
|
+
this.send(ws, {
|
|
609
|
+
op: 'error',
|
|
610
|
+
req,
|
|
611
|
+
code: 'bad_batch',
|
|
612
|
+
message: `relay rejected push: bad_batch: too many events (${events.length} > ${maxBatch})`,
|
|
613
|
+
});
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
const maxBytes = this.opts.maxEventBytes ?? MAX_EVENT_BYTES;
|
|
617
|
+
for (const ev of events) {
|
|
618
|
+
const n = utf8Bytes(JSON.stringify(ev));
|
|
619
|
+
if (n > maxBytes) {
|
|
620
|
+
this.send(ws, {
|
|
621
|
+
op: 'error',
|
|
622
|
+
req,
|
|
623
|
+
code: 'bad_batch',
|
|
624
|
+
message: `relay rejected push: bad_batch: event exceeds ${maxBytes} bytes (${n})`,
|
|
625
|
+
});
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Revoke budget gate: count + per-event bytes, rejected with bad_batch
|
|
633
|
+
* BEFORE merge (each merged event costs an ed25519 verify). Anonymous
|
|
634
|
+
* callers get no per-event verify work past the cap. */
|
|
635
|
+
private checkRevokeBudget(ws: ServerWebSocket<SockState>, req: number, events: unknown): boolean {
|
|
636
|
+
if (!Array.isArray(events)) return true;
|
|
637
|
+
const maxBatch = this.opts.maxRevokeBatchEvents ?? this.opts.maxBatchEvents ?? MAX_REVOKE_BATCH_EVENTS;
|
|
638
|
+
if (events.length > maxBatch) {
|
|
639
|
+
this.send(ws, {
|
|
640
|
+
op: 'error',
|
|
641
|
+
req,
|
|
642
|
+
code: 'bad_batch',
|
|
643
|
+
message: `relay rejected revoke_push: bad_batch: too many events (${events.length} > ${maxBatch})`,
|
|
644
|
+
});
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
const maxBytes = this.opts.maxEventBytes ?? MAX_EVENT_BYTES;
|
|
648
|
+
for (const ev of events) {
|
|
649
|
+
const n = utf8Bytes(JSON.stringify(ev));
|
|
650
|
+
if (n > maxBytes) {
|
|
651
|
+
this.send(ws, {
|
|
652
|
+
op: 'error',
|
|
653
|
+
req,
|
|
654
|
+
code: 'bad_batch',
|
|
655
|
+
message: `relay rejected revoke_push: bad_batch: event exceeds ${maxBytes} bytes (${n})`,
|
|
656
|
+
});
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return true;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
/** Store new UUIDs (persist first); returns the fresh ones for broadcast. */
|
|
665
|
+
private store(batch: LogEvent[]): LogEvent[] {
|
|
666
|
+
const fresh: LogEvent[] = [];
|
|
667
|
+
for (const ev of batch) {
|
|
668
|
+
if (!this.byId.has(ev.id)) {
|
|
669
|
+
this.byId.set(ev.id, ev);
|
|
670
|
+
this.order.push(ev);
|
|
671
|
+
fresh.push(ev);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
try {
|
|
675
|
+
this.persist(fresh); // write-ahead: durable before any ack
|
|
676
|
+
} catch (err) {
|
|
677
|
+
// Persist failed: roll back the in-memory index so nothing looks
|
|
678
|
+
// stored. The push path answers error (never acks), and the client's
|
|
679
|
+
// retry lands here as fresh again.
|
|
680
|
+
for (const ev of fresh) this.byId.delete(ev.id);
|
|
681
|
+
this.order.splice(this.order.length - fresh.length, fresh.length);
|
|
682
|
+
throw err;
|
|
683
|
+
}
|
|
684
|
+
return fresh;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
private broadcast(evs: LogEvent[], except: ServerWebSocket<SockState>): void {
|
|
688
|
+
if (evs.length === 0) return;
|
|
689
|
+
const msg = JSON.stringify({ op: 'live', events: evs } satisfies ToClient);
|
|
690
|
+
for (const ws of this.sockets) {
|
|
691
|
+
if (ws === except) continue;
|
|
692
|
+
try {
|
|
693
|
+
ws.send(msg);
|
|
694
|
+
} catch {
|
|
695
|
+
/* gone */
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
private send(ws: ServerWebSocket<SockState>, msg: ToClient): void {
|
|
701
|
+
try {
|
|
702
|
+
ws.send(JSON.stringify(msg));
|
|
703
|
+
} catch {
|
|
704
|
+
/* gone — same best-effort policy as broadcast: never let a dead socket
|
|
705
|
+
take down the message loop with an uncaught throw */
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
export interface WsRelayClientOpts {
|
|
711
|
+
baseMs?: number;
|
|
712
|
+
maxMs?: number;
|
|
713
|
+
maxRetries?: number; // reconnect attempts per call
|
|
714
|
+
reqTimeoutMs?: number;
|
|
715
|
+
capToken?: CapToken; // capability token attached to every push/pull
|
|
716
|
+
/** Admin registry for the convergent revoke log (deviceId -> ed25519 publicKeyPem). */
|
|
717
|
+
revokeAdmins?: Record<string, string>;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
interface Inflight {
|
|
721
|
+
resolve: (m: ToClient) => void;
|
|
722
|
+
reject: (e: Error) => void;
|
|
723
|
+
timer: Timer;
|
|
724
|
+
}
|
|
725
|
+
/** Cap on buffered live-broadcast hints per client. Pull is the source of
|
|
726
|
+
* truth; hints must not grow without bound when the client never pulls. */
|
|
727
|
+
export const MAX_LIVE_HINTS = 1000;
|
|
728
|
+
|
|
729
|
+
/** Relay over a real socket: reconnects with backoff+jitter, resumes via cursors. */
|
|
730
|
+
export class WsRelayClient implements Relay {
|
|
731
|
+
private ws: WebSocket | null = null;
|
|
732
|
+
private req = 0;
|
|
733
|
+
private inflight = new Map<number, Inflight>();
|
|
734
|
+
private manualClose = false;
|
|
735
|
+
private liveBuf: LogEvent[] = [];
|
|
736
|
+
/** Persistent id index alongside liveBuf: O(1) amortized dedupe per live
|
|
737
|
+
* hint instead of rebuilding a Set from the array on every message. Kept
|
|
738
|
+
* in lockstep with liveBuf on insert, cap-eviction, and pull-drain. */
|
|
739
|
+
private liveIds = new Set<string>();
|
|
740
|
+
private dials = 0;
|
|
741
|
+
private capToken: CapToken | undefined;
|
|
742
|
+
/** Revoke tombstones broadcast by the relay while this client was connected. */
|
|
743
|
+
revokedNotices: string[] = [];
|
|
744
|
+
pingsReceived = 0;
|
|
745
|
+
reconnects = 0;
|
|
746
|
+
/** Convergent authenticated revoke log; syncs with the relay on every pull/push. */
|
|
747
|
+
readonly revokes = new RevokeLog();
|
|
748
|
+
private revokeServerCursor = 0; // relay log prefix already merged locally
|
|
749
|
+
private revokeUpTo = 0; // local log prefix already offered to the relay
|
|
750
|
+
revokeSyncs = 0;
|
|
751
|
+
revokeRejected = 0; // forged/dangling events refused by either side, never stored
|
|
752
|
+
|
|
753
|
+
constructor(
|
|
754
|
+
private url: string,
|
|
755
|
+
private opts: WsRelayClientOpts = {},
|
|
756
|
+
) {
|
|
757
|
+
this.capToken = opts.capToken;
|
|
758
|
+
if (opts.revokeAdmins) {
|
|
759
|
+
for (const [id, pem] of Object.entries(opts.revokeAdmins)) this.revokes.addAdmin(id, pem);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Swap the capability token (rotation / expiry refresh without redialling). */
|
|
764
|
+
setCapToken(token: CapToken | undefined): void {
|
|
765
|
+
this.capToken = token;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** Live-broadcast hints received (pull stays the source of truth). */
|
|
769
|
+
get liveCount(): number {
|
|
770
|
+
return this.liveBuf.length;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
private async ensureConn(): Promise<WebSocket> {
|
|
774
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) return this.ws;
|
|
775
|
+
const maxRetries = this.opts.maxRetries ?? 10;
|
|
776
|
+
const baseMs = this.opts.baseMs ?? 50;
|
|
777
|
+
const maxMs = this.opts.maxMs ?? 5000;
|
|
778
|
+
let attempt = 0;
|
|
779
|
+
for (;;) {
|
|
780
|
+
try {
|
|
781
|
+
this.ws = await this.dial();
|
|
782
|
+
this.dials += 1;
|
|
783
|
+
if (this.dials > 1) this.reconnects += 1;
|
|
784
|
+
return this.ws;
|
|
785
|
+
} catch (err) {
|
|
786
|
+
if (attempt >= maxRetries) throw err;
|
|
787
|
+
await new Promise((r) => setTimeout(r, backoffMs(attempt, baseMs, maxMs)));
|
|
788
|
+
attempt += 1;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
private dial(): Promise<WebSocket> {
|
|
794
|
+
return new Promise((resolve, reject) => {
|
|
795
|
+
const ws = new WebSocket(this.url);
|
|
796
|
+
const fail = (e: Event | Error) => {
|
|
797
|
+
ws.onopen = ws.onerror = null;
|
|
798
|
+
reject(e instanceof Error ? e : new Error('ws dial failed'));
|
|
799
|
+
};
|
|
800
|
+
ws.onerror = fail;
|
|
801
|
+
ws.onopen = () => {
|
|
802
|
+
ws.onmessage = (ev) => this.onMessage(String(ev.data));
|
|
803
|
+
ws.onclose = () => this.onDrop(new Error('ws dropped'));
|
|
804
|
+
ws.onerror = () => this.onDrop(new Error('ws error'));
|
|
805
|
+
resolve(ws);
|
|
806
|
+
};
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
private onDrop(err: Error): void {
|
|
811
|
+
this.ws = null;
|
|
812
|
+
for (const [, f] of this.inflight) {
|
|
813
|
+
clearTimeout(f.timer);
|
|
814
|
+
f.reject(err);
|
|
815
|
+
}
|
|
816
|
+
this.inflight.clear();
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
private onMessage(raw: string): void {
|
|
820
|
+
let msg: ToClient;
|
|
821
|
+
try {
|
|
822
|
+
msg = JSON.parse(raw) as ToClient;
|
|
823
|
+
} catch {
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (msg.op === 'ping') {
|
|
827
|
+
this.pingsReceived += 1;
|
|
828
|
+
try {
|
|
829
|
+
this.ws?.send(JSON.stringify({ op: 'pong' } satisfies ToServer));
|
|
830
|
+
} catch {
|
|
831
|
+
/* dropping */
|
|
832
|
+
}
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
if (msg.op === 'live') {
|
|
836
|
+
for (const ev of msg.events) {
|
|
837
|
+
if (!this.liveIds.has(ev.id)) {
|
|
838
|
+
this.liveIds.add(ev.id);
|
|
839
|
+
this.liveBuf.push(ev);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
// Bound the hint buffer: oldest hints drop first, pull stays the source
|
|
843
|
+
// of truth so nothing is lost — the next pull re-covers the gap.
|
|
844
|
+
if (this.liveBuf.length > MAX_LIVE_HINTS) {
|
|
845
|
+
const drop = this.liveBuf.length - MAX_LIVE_HINTS;
|
|
846
|
+
for (let i = 0; i < drop; i++) this.liveIds.delete(this.liveBuf[i].id);
|
|
847
|
+
this.liveBuf.splice(0, drop);
|
|
848
|
+
}
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
if (msg.op === 'revoked') {
|
|
852
|
+
if (!this.revokedNotices.includes(msg.deviceId)) this.revokedNotices.push(msg.deviceId);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
const f = this.inflight.get((msg as { req: number }).req);
|
|
856
|
+
if (!f) return;
|
|
857
|
+
clearTimeout(f.timer);
|
|
858
|
+
this.inflight.delete((msg as { req: number }).req);
|
|
859
|
+
f.resolve(msg);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
private request(op: 'push' | 'pull' | 'revoke_pull' | 'revoke_push', body: Record<string, unknown>): Promise<ToClient> {
|
|
863
|
+
const reqTimeoutMs = this.opts.reqTimeoutMs ?? 10_000;
|
|
864
|
+
return (async () => {
|
|
865
|
+
const ws = await this.ensureConn();
|
|
866
|
+
const req = ++this.req;
|
|
867
|
+
const { promise, resolve, reject } = Promise.withResolvers<ToClient>();
|
|
868
|
+
const timer = setTimeout(() => {
|
|
869
|
+
this.inflight.delete(req);
|
|
870
|
+
reject(new Error(`relay req ${req} timed out`));
|
|
871
|
+
}, reqTimeoutMs);
|
|
872
|
+
this.inflight.set(req, { resolve, reject, timer });
|
|
873
|
+
// Revoke ops carry no capability token: the events authenticate
|
|
874
|
+
// themselves via the admin signature, and a revoked device must still
|
|
875
|
+
// complete the handshake to learn its own revocation.
|
|
876
|
+
const auth = op === 'push' || op === 'pull' ? { ...(this.capToken ? { token: this.capToken } : {}) } : {};
|
|
877
|
+
try {
|
|
878
|
+
ws.send(JSON.stringify({ op, req, ...auth, ...body }));
|
|
879
|
+
} catch (e) {
|
|
880
|
+
clearTimeout(timer);
|
|
881
|
+
this.inflight.delete(req);
|
|
882
|
+
reject(e instanceof Error ? e : new Error('ws send failed'));
|
|
883
|
+
}
|
|
884
|
+
return await promise;
|
|
885
|
+
})();
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** Trust a revoke admin (same registry the relay and peers share). */
|
|
889
|
+
addRevokeAdmin(deviceId: string, publicKeyPem: string): void {
|
|
890
|
+
this.revokes.addAdmin(deviceId, publicKeyPem);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** Canonical convergent view, byte-equal with the relay after a full handshake. */
|
|
894
|
+
revokeSnapshot(): RevokeEvent[] {
|
|
895
|
+
return this.revokes.snapshot();
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
isTokenRevoked(tokenId: string, tokenEpoch = 0): boolean {
|
|
899
|
+
return this.revokes.isRevoked(tokenId, tokenEpoch);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
isDeviceRevoked(deviceId: string): boolean {
|
|
903
|
+
if (this.revokedNotices.includes(deviceId)) return true;
|
|
904
|
+
for (const e of this.revokes.snapshot()) {
|
|
905
|
+
if (e.tokenId === '*' && e.deviceId === deviceId) return true;
|
|
906
|
+
}
|
|
907
|
+
return false;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** Raw tail fetch; throws on protocol error (bad cursor included). */
|
|
911
|
+
async pullRevokes(cursor: number): Promise<{ events: RevokeEvent[]; cursor: number }> {
|
|
912
|
+
const res = await this.request('revoke_pull', { cursor });
|
|
913
|
+
if (res.op === 'error') throw new Error(`relay rejected revoke_pull: ${res.message}`);
|
|
914
|
+
if (res.op !== 'revoke_res') throw new Error('relay protocol: expected revoke_res');
|
|
915
|
+
return { events: res.events, cursor: res.cursor };
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/** Raw tail offer; forgeries count as rejected, never stored. */
|
|
919
|
+
async pushRevokes(events: RevokeEvent[]): Promise<{ added: number; skipped: number; rejected: number; cursor: number }> {
|
|
920
|
+
const res = await this.request('revoke_push', { events });
|
|
921
|
+
if (res.op === 'error') throw new Error(`relay rejected revoke_push: ${res.message}`);
|
|
922
|
+
if (res.op !== 'revoke_ack') throw new Error('relay protocol: expected revoke_ack');
|
|
923
|
+
return { added: res.added, skipped: res.skipped, rejected: res.rejected, cursor: res.cursor };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Bidirectional revoke handshake: pull the relay tail since the last known
|
|
928
|
+
* server cursor and merge idempotently, offer the unseen local tail, then
|
|
929
|
+
* pull once more so concurrent relay writes converge in a single call.
|
|
930
|
+
* A stale cursor (relay restarted from an older file) falls back to a full
|
|
931
|
+
* snapshot merge; set-union is idempotent so replay is always safe.
|
|
932
|
+
*/
|
|
933
|
+
async syncRevokes(): Promise<{ added: number; skipped: number; rejected: number; serverCursor: number }> {
|
|
934
|
+
const total = { added: 0, skipped: 0, rejected: 0 };
|
|
935
|
+
// Snapshot the locally-authored tail BEFORE absorbing: anything merged
|
|
936
|
+
// from the server below is already stored on the relay, and offering it
|
|
937
|
+
// back would echo a perpetual tail (every handshake re-pushes the events
|
|
938
|
+
// the previous handshake absorbed).
|
|
939
|
+
const tail = this.revokes.diffSince(this.revokeUpTo).events;
|
|
940
|
+
const absorb = async (): Promise<void> => {
|
|
941
|
+
let res: { events: RevokeEvent[]; cursor: number };
|
|
942
|
+
try {
|
|
943
|
+
res = await this.pullRevokes(this.revokeServerCursor);
|
|
944
|
+
} catch (err) {
|
|
945
|
+
if (!(err instanceof Error) || !/bad cursor|bad_cursor/.test(err.message)) throw err;
|
|
946
|
+
this.revokeServerCursor = 0;
|
|
947
|
+
res = await this.pullRevokes(0);
|
|
948
|
+
}
|
|
949
|
+
const m = this.revokes.merge(res.events);
|
|
950
|
+
total.added += m.added;
|
|
951
|
+
total.skipped += m.skipped;
|
|
952
|
+
total.rejected += m.rejected;
|
|
953
|
+
this.revokeServerCursor = res.cursor;
|
|
954
|
+
};
|
|
955
|
+
await absorb();
|
|
956
|
+
const ack = await this.pushRevokes(tail);
|
|
957
|
+
total.added += ack.added;
|
|
958
|
+
total.skipped += ack.skipped;
|
|
959
|
+
total.rejected += ack.rejected;
|
|
960
|
+
this.revokeUpTo = this.revokes.size;
|
|
961
|
+
await absorb();
|
|
962
|
+
// The second absorb only merges server-originated events the relay
|
|
963
|
+
// already stores — mark them offered so the next handshake is quiet.
|
|
964
|
+
this.revokeUpTo = this.revokes.size;
|
|
965
|
+
this.revokeSyncs += 1;
|
|
966
|
+
this.revokeRejected += total.rejected;
|
|
967
|
+
return { ...total, serverCursor: this.revokeServerCursor };
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** Best-effort handshake around data ops: revoke state is a hint, the data pull stays the source of truth. */
|
|
971
|
+
private async maybeSyncRevokes(): Promise<void> {
|
|
972
|
+
try {
|
|
973
|
+
await this.syncRevokes();
|
|
974
|
+
} catch {
|
|
975
|
+
/* next connect/pull retries idempotently */
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
async push(batch: LogEvent[]): Promise<PushAck> {
|
|
980
|
+
await this.maybeSyncRevokes();
|
|
981
|
+
const res = await this.request('push', { events: batch });
|
|
982
|
+
if (res.op === 'error') throw new Error(`relay rejected push: ${res.message}`);
|
|
983
|
+
if (res.op !== 'push_ack') throw new Error('relay protocol: expected push_ack');
|
|
984
|
+
return { acked: res.acked, server_time: res.server_time };
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
async pull(since: number): Promise<{ events: LogEvent[]; cursor: number }> {
|
|
988
|
+
await this.maybeSyncRevokes();
|
|
989
|
+
// Paginate: the server caps one pull_res at maxPullEvents while the
|
|
990
|
+
// cursor stays global, so walk since forward until the fetched prefix
|
|
991
|
+
// covers the cursor. Order only appends, so old pages stay stable.
|
|
992
|
+
const events: LogEvent[] = [];
|
|
993
|
+
let cur = since;
|
|
994
|
+
let cursor = since;
|
|
995
|
+
for (;;) {
|
|
996
|
+
const res = await this.request('pull', { since: cur });
|
|
997
|
+
if (res.op === 'error') throw new Error(`relay rejected pull: ${res.message}`);
|
|
998
|
+
if (res.op !== 'pull_res') throw new Error('relay protocol: expected pull_res');
|
|
999
|
+
events.push(...res.events);
|
|
1000
|
+
cursor = res.cursor;
|
|
1001
|
+
if (res.events.length === 0 || cur + res.events.length >= cursor) break;
|
|
1002
|
+
cur += res.events.length;
|
|
1003
|
+
}
|
|
1004
|
+
// Merge live hints the server history doesn't cover yet; UUID dedupe
|
|
1005
|
+
// keeps it exact (kernel also skips known UUIDs on apply).
|
|
1006
|
+
const seen = new Set(events.map((e) => e.id));
|
|
1007
|
+
const extra: LogEvent[] = [];
|
|
1008
|
+
for (const e of this.liveBuf) {
|
|
1009
|
+
if (seen.has(e.id)) this.liveIds.delete(e.id);
|
|
1010
|
+
else extra.push(e);
|
|
1011
|
+
}
|
|
1012
|
+
this.liveBuf = extra;
|
|
1013
|
+
return { events: [...events, ...extra], cursor };
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
close(): void {
|
|
1017
|
+
this.manualClose = true;
|
|
1018
|
+
this.onDrop(new Error('client closed'));
|
|
1019
|
+
try {
|
|
1020
|
+
this.ws?.close();
|
|
1021
|
+
} catch {
|
|
1022
|
+
/* gone */
|
|
1023
|
+
}
|
|
1024
|
+
this.ws = null;
|
|
1025
|
+
void this.manualClose;
|
|
1026
|
+
}
|
|
1027
|
+
}
|