@nicknisi/pi-relay 0.1.0 → 0.3.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/mailbox.ts CHANGED
@@ -9,11 +9,21 @@
9
9
  * - A corrupt letter is discarded on read, so it cannot poison every drain.
10
10
  * - Consumption is the receipt: the sender learns "delivered" only when the
11
11
  * letter actually disappeared from the target's inbox.
12
+ * - Every deposit and every delivery appends one append-only audit line —
13
+ * drain-as-receipt must not destroy evidence. The log never holds a full
14
+ * body, only a short preview.
12
15
  */
13
16
 
14
- import * as fs from 'node:fs';
17
+ import { randomBytes } from 'node:crypto';
15
18
  import * as path from 'node:path';
16
- import { asksDir, inboxDir } from './registry.js';
19
+ import {
20
+ assertPathSegment,
21
+ openRelayRoot,
22
+ rethrowFilesystemError,
23
+ RelayDirectoryHandle,
24
+ RelayFilesystemError,
25
+ type RelayWatcher,
26
+ } from './filesystem.js';
17
27
 
18
28
  export type LetterKind = 'message' | 'ask' | 'reply' | 'cancel';
19
29
 
@@ -28,18 +38,66 @@ export interface Letter {
28
38
 
29
39
  export const MAX_BODY_CHARS = 32 * 1024;
30
40
 
41
+ const MESSAGE_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
42
+ const INBOX_FILE_TOKEN_PATTERN = /^(0|[1-9][0-9]*)-([a-zA-Z0-9][a-zA-Z0-9_-]{0,127})\.json$/;
43
+ const INBOX_CLAIM_TOKEN_PATTERN = /^[a-f0-9]{32}$/;
44
+
31
45
  function letterFileName(letter: Letter): string {
32
- return `${letter.ts}-${letter.id.slice(0, 6)}.json`;
46
+ if (!MESSAGE_ID_PATTERN.test(letter.id)) throw new TypeError(`Invalid relay message id: ${letter.id}`);
47
+ if (!Number.isSafeInteger(letter.ts) || letter.ts < 0) {
48
+ throw new TypeError(`Invalid relay letter timestamp: ${letter.ts}`);
49
+ }
50
+ const name = `${letter.ts}-${letter.id}.json`;
51
+ assertPathSegment(name, 'relay letter file name');
52
+ return name;
53
+ }
54
+
55
+ function inboxFileTokenParts(fileToken: string): { ts: number; id: string } | null {
56
+ const match = INBOX_FILE_TOKEN_PATTERN.exec(fileToken);
57
+ if (match === null) return null;
58
+ const ts = Number(match[1]);
59
+ return Number.isSafeInteger(ts) ? { ts, id: match[2]! } : null;
60
+ }
61
+
62
+ function assertInboxClaimToken(claimToken: string): void {
63
+ if (!INBOX_CLAIM_TOKEN_PATTERN.test(claimToken)) {
64
+ throw new TypeError(`Invalid relay inbox claim token: ${claimToken}`);
65
+ }
66
+ }
67
+
68
+ function assertInboxFileToken(fileToken: string): void {
69
+ if (inboxFileTokenParts(fileToken) === null) {
70
+ throw new TypeError(`Invalid relay inbox file token: ${fileToken}`);
71
+ }
33
72
  }
34
73
 
35
- /** Atomic deposit: write to a tmp sibling, then rename into place. */
74
+ /** Atomic deposit: write to a random exclusive tmp sibling, then rename into place. */
36
75
  export function deposit(root: string, toAddr: string, letter: Letter): void {
37
- const dir = inboxDir(root, toAddr);
38
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
39
- const name = letterFileName(letter);
40
- const tmp = path.join(dir, `${name}.${process.pid}.tmp`);
41
- fs.writeFileSync(tmp, JSON.stringify(letter), { mode: 0o600 });
42
- fs.renameSync(tmp, path.join(dir, name));
76
+ assertPathSegment(toAddr, 'relay address');
77
+ const relay = openRelayRoot(root, true)!;
78
+ try {
79
+ // Refuse an unsafe audit target before committing the atomic letter; this
80
+ // avoids reporting failure after a delivery has already landed.
81
+ relay.verifyFile('audit.log');
82
+ const inbox = relay.openDirectory(`${toAddr}.inbox`, true)!;
83
+ try {
84
+ inbox.writeFileAtomic(letterFileName(letter), JSON.stringify(letter));
85
+ } finally {
86
+ inbox.close();
87
+ }
88
+ // Send-side choke point: every send/ask/reply/cancel goes through here.
89
+ appendAuditToDirectory(relay, {
90
+ ts: Date.now(),
91
+ event: 'deposit',
92
+ kind: letter.kind,
93
+ from: letter.from.addr,
94
+ to: toAddr,
95
+ messageId: letter.id,
96
+ preview: previewBody(letter.body),
97
+ });
98
+ } finally {
99
+ relay.close();
100
+ }
43
101
  }
44
102
 
45
103
  /**
@@ -49,47 +107,78 @@ export function deposit(root: string, toAddr: string, letter: Letter): void {
49
107
  * caller must re-deposit any letter it fails to deliver (checkInbox does).
50
108
  */
51
109
  export function drain(root: string, addr: string): Letter[] {
52
- const dir = inboxDir(root, addr);
53
- let names: string[];
110
+ assertPathSegment(addr, 'relay address');
111
+ const relay = openRelayRoot(root);
112
+ if (relay === null) return [];
54
113
  try {
55
- names = fs
56
- .readdirSync(dir)
57
- .filter((f) => f.endsWith('.json'))
58
- .sort();
59
- } catch {
60
- return [];
61
- }
62
- const out: Letter[] = [];
63
- for (const name of names) {
64
- const file = path.join(dir, name);
65
- let raw: string;
66
- try {
67
- raw = fs.readFileSync(file, 'utf8');
68
- } catch {
69
- continue;
70
- }
71
- try {
72
- fs.unlinkSync(file);
73
- } catch {
74
- continue; // another drainer took it
75
- }
114
+ const inbox = relay.openDirectory(`${addr}.inbox`);
115
+ if (inbox === null) return [];
76
116
  try {
77
- const parsed = JSON.parse(raw);
78
- if (parsed && typeof parsed.id === 'string' && typeof parsed.body === 'string') {
79
- out.push(parsed as Letter);
117
+ const names = inbox
118
+ .readDirectory()
119
+ .filter((entry) => {
120
+ if (!entry.name.endsWith('.json')) return false;
121
+ if (entry.isSymbolicLink()) throw new RelayFilesystemError(`Refusing symlinked relay letter: ${entry.name}`);
122
+ return entry.isFile();
123
+ })
124
+ .map((entry) => entry.name)
125
+ .sort();
126
+ const out: Letter[] = [];
127
+ for (const name of names) {
128
+ let raw: string | null;
129
+ try {
130
+ raw = inbox.readFile(name);
131
+ } catch (error) {
132
+ rethrowFilesystemError(error);
133
+ continue;
134
+ }
135
+ if (raw === null) continue;
136
+ try {
137
+ if (!inbox.unlinkFile(name)) continue; // another drainer took it
138
+ } catch (error) {
139
+ rethrowFilesystemError(error);
140
+ continue;
141
+ }
142
+ try {
143
+ const parsed = JSON.parse(raw);
144
+ if (parsed && typeof parsed.id === 'string' && typeof parsed.body === 'string') out.push(parsed as Letter);
145
+ } catch {
146
+ // corrupt letter — discarded, cannot fail future drains
147
+ }
80
148
  }
81
- } catch {
82
- // corrupt letter — discarded, cannot fail future drains
149
+ return out;
150
+ } finally {
151
+ inbox.close();
83
152
  }
153
+ } catch (error) {
154
+ rethrowFilesystemError(error);
155
+ return [];
156
+ } finally {
157
+ relay.close();
84
158
  }
85
- return out;
86
159
  }
87
160
 
88
161
  export function unreadCount(root: string, addr: string): number {
162
+ assertPathSegment(addr, 'relay address');
163
+ const relay = openRelayRoot(root);
164
+ if (relay === null) return 0;
89
165
  try {
90
- return fs.readdirSync(inboxDir(root, addr)).filter((f) => f.endsWith('.json')).length;
91
- } catch {
166
+ const inbox = relay.openDirectory(`${addr}.inbox`);
167
+ if (inbox === null) return 0;
168
+ try {
169
+ return inbox.readDirectory().filter((entry) => {
170
+ if (!entry.name.endsWith('.json')) return false;
171
+ if (entry.isSymbolicLink()) throw new RelayFilesystemError(`Refusing symlinked relay letter: ${entry.name}`);
172
+ return entry.isFile();
173
+ }).length;
174
+ } finally {
175
+ inbox.close();
176
+ }
177
+ } catch (error) {
178
+ rethrowFilesystemError(error);
92
179
  return 0;
180
+ } finally {
181
+ relay.close();
93
182
  }
94
183
  }
95
184
 
@@ -100,29 +189,279 @@ export function unreadCount(root: string, addr: string): number {
100
189
  * a process alive.
101
190
  */
102
191
  export function watchInbox(root: string, addr: string, onMail: () => void): () => void {
103
- const dir = inboxDir(root, addr);
104
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
105
- let watcher: fs.FSWatcher | undefined;
192
+ assertPathSegment(addr, 'relay address');
193
+ let relay: RelayDirectoryHandle | null = null;
194
+ let inbox: RelayDirectoryHandle | null = null;
195
+ let watcher: RelayWatcher | undefined;
106
196
  try {
107
- watcher = fs.watch(dir, () => onMail());
197
+ relay = openRelayRoot(root, true)!;
198
+ inbox = relay.openDirectory(`${addr}.inbox`, true)!;
199
+ watcher = inbox.watch(onMail);
108
200
  watcher.unref();
109
- } catch {
110
- // poll-only fallback
201
+ } catch (error) {
202
+ inbox?.close();
203
+ relay?.close();
204
+ inbox = null;
205
+ relay = null;
206
+ rethrowFilesystemError(error);
207
+ // poll-only fallback for ordinary watch failures
111
208
  }
112
209
  const poll = setInterval(onMail, 3000);
113
210
  poll.unref();
114
211
  return () => {
115
212
  watcher?.close();
116
213
  clearInterval(poll);
214
+ inbox?.close();
215
+ relay?.close();
117
216
  };
118
217
  }
119
218
 
219
+ // ── Durable inbox claims ────────────────────────────────────────────────
220
+ // A claim atomically renames the current inbox beneath <addr>.claims and
221
+ // immediately creates a fresh inbox for new deposits. Tokens, never paths,
222
+ // are the public persistence boundary for crash recovery.
223
+
224
+ export interface InboxClaim {
225
+ claimToken: string;
226
+ fileTokens: string[];
227
+ }
228
+
229
+ function claimsDirectoryName(addr: string): string {
230
+ return `${addr}.claims`;
231
+ }
232
+
233
+ function inboxFileTokens(directory: RelayDirectoryHandle): string[] {
234
+ return directory
235
+ .readDirectory()
236
+ .filter((entry) => {
237
+ if (inboxFileTokenParts(entry.name) === null) return false;
238
+ if (entry.isSymbolicLink()) throw new RelayFilesystemError(`Refusing symlinked claimed letter: ${entry.name}`);
239
+ return entry.isFile();
240
+ })
241
+ .map((entry) => entry.name)
242
+ .sort();
243
+ }
244
+
245
+ /** Atomically detach the current non-empty inbox and return its durable tokens. */
246
+ export function claimInbox(root: string, addr: string): InboxClaim | null {
247
+ assertPathSegment(addr, 'relay address');
248
+ const relay = openRelayRoot(root, true)!;
249
+ try {
250
+ const inboxName = `${addr}.inbox`;
251
+ const inbox = relay.openDirectory(inboxName);
252
+ if (inbox === null) return null;
253
+ try {
254
+ if (inboxFileTokens(inbox).length === 0) return null;
255
+ } finally {
256
+ inbox.close();
257
+ }
258
+
259
+ const claims = relay.openDirectory(claimsDirectoryName(addr), true)!;
260
+ try {
261
+ let claimToken = '';
262
+ for (let attempt = 0; attempt < 10; attempt++) {
263
+ const candidate = randomBytes(16).toString('hex');
264
+ const existing = claims.openDirectory(candidate);
265
+ if (existing === null) {
266
+ claimToken = candidate;
267
+ break;
268
+ }
269
+ existing.close();
270
+ }
271
+ if (claimToken === '') throw new Error('Could not allocate an exclusive relay inbox claim token');
272
+
273
+ const claimed = relay.moveDirectoryTo(inboxName, claims, claimToken);
274
+ if (claimed === null) return null;
275
+ try {
276
+ claimed.sync();
277
+ claims.sync();
278
+ const replacement = relay.openDirectory(inboxName, true)!;
279
+ replacement.close();
280
+ relay.sync();
281
+ return { claimToken, fileTokens: inboxFileTokens(claimed) };
282
+ } finally {
283
+ claimed.close();
284
+ }
285
+ } finally {
286
+ claims.close();
287
+ }
288
+ } finally {
289
+ relay.close();
290
+ }
291
+ }
292
+
293
+ /** Enumerate durable claims left by this or a crashed consumer. */
294
+ export function recoverInboxClaims(root: string, addr: string): InboxClaim[] {
295
+ assertPathSegment(addr, 'relay address');
296
+ const relay = openRelayRoot(root);
297
+ if (relay === null) return [];
298
+ try {
299
+ const claims = relay.openDirectory(claimsDirectoryName(addr));
300
+ if (claims === null) return [];
301
+ try {
302
+ const out: InboxClaim[] = [];
303
+ for (const entry of claims.readDirectory()) {
304
+ if (!INBOX_CLAIM_TOKEN_PATTERN.test(entry.name)) continue;
305
+ if (entry.isSymbolicLink()) throw new RelayFilesystemError(`Refusing symlinked inbox claim: ${entry.name}`);
306
+ if (!entry.isDirectory()) continue;
307
+ const claim = claims.openDirectory(entry.name);
308
+ if (claim === null) continue;
309
+ try {
310
+ out.push({ claimToken: entry.name, fileTokens: inboxFileTokens(claim) });
311
+ } finally {
312
+ claim.close();
313
+ }
314
+ }
315
+ return out.sort((left, right) => left.claimToken.localeCompare(right.claimToken));
316
+ } finally {
317
+ claims.close();
318
+ }
319
+ } finally {
320
+ relay.close();
321
+ }
322
+ }
323
+
324
+ /** Read and validate one claimed letter without exposing or following a path. */
325
+ export function readClaimedLetter(root: string, addr: string, claimToken: string, fileToken: string): Letter | null {
326
+ assertPathSegment(addr, 'relay address');
327
+ assertInboxClaimToken(claimToken);
328
+ assertInboxFileToken(fileToken);
329
+ const expected = inboxFileTokenParts(fileToken)!;
330
+ const relay = openRelayRoot(root);
331
+ if (relay === null) return null;
332
+ try {
333
+ const claims = relay.openDirectory(claimsDirectoryName(addr));
334
+ if (claims === null) return null;
335
+ try {
336
+ const claim = claims.openDirectory(claimToken);
337
+ if (claim === null) return null;
338
+ try {
339
+ const raw = claim.readFile(fileToken);
340
+ if (raw === null) return null;
341
+ const parsed = JSON.parse(raw) as Letter;
342
+ if (
343
+ parsed === null ||
344
+ typeof parsed !== 'object' ||
345
+ parsed.id !== expected.id ||
346
+ parsed.ts !== expected.ts ||
347
+ typeof parsed.body !== 'string'
348
+ ) {
349
+ return null;
350
+ }
351
+ return parsed;
352
+ } catch (error) {
353
+ rethrowFilesystemError(error);
354
+ return null;
355
+ } finally {
356
+ claim.close();
357
+ }
358
+ } finally {
359
+ claims.close();
360
+ }
361
+ } finally {
362
+ relay.close();
363
+ }
364
+ }
365
+
366
+ function finishClaimedLetter(
367
+ root: string,
368
+ addr: string,
369
+ claimToken: string,
370
+ fileToken: string,
371
+ requeue: boolean,
372
+ ): boolean {
373
+ assertPathSegment(addr, 'relay address');
374
+ assertInboxClaimToken(claimToken);
375
+ assertInboxFileToken(fileToken);
376
+ const relay = openRelayRoot(root, requeue);
377
+ if (relay === null) return false;
378
+ try {
379
+ const claims = relay.openDirectory(claimsDirectoryName(addr));
380
+ if (claims === null) return false;
381
+ try {
382
+ const claim = claims.openDirectory(claimToken);
383
+ if (claim === null) return false;
384
+ let finished: boolean;
385
+ try {
386
+ if (requeue) {
387
+ const inbox = relay.openDirectory(`${addr}.inbox`, true)!;
388
+ try {
389
+ finished = claim.moveFileTo(fileToken, inbox);
390
+ relay.sync();
391
+ } finally {
392
+ inbox.close();
393
+ }
394
+ } else {
395
+ finished = claim.unlinkFile(fileToken);
396
+ if (finished) claim.sync();
397
+ }
398
+ } finally {
399
+ claim.close();
400
+ }
401
+ if (finished) claims.removeEmptyDirectory(claimToken);
402
+ return finished;
403
+ } finally {
404
+ claims.close();
405
+ }
406
+ } finally {
407
+ relay.close();
408
+ }
409
+ }
410
+
411
+ /** Permanently acknowledge/delete one claimed letter. Idempotent. */
412
+ export function ackClaimedLetter(root: string, addr: string, claimToken: string, fileToken: string): boolean {
413
+ return finishClaimedLetter(root, addr, claimToken, fileToken, false);
414
+ }
415
+
416
+ /** Atomically return one claimed letter to the current inbox. */
417
+ export function requeueClaimedLetter(root: string, addr: string, claimToken: string, fileToken: string): boolean {
418
+ return finishClaimedLetter(root, addr, claimToken, fileToken, true);
419
+ }
420
+
120
421
  const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
121
422
 
423
+ function fileExistsInClaims(relay: RelayDirectoryHandle, addr: string, fileName: string): boolean {
424
+ const claims = relay.openDirectory(claimsDirectoryName(addr));
425
+ if (claims === null) return false;
426
+ try {
427
+ for (const entry of claims.readDirectory()) {
428
+ if (!INBOX_CLAIM_TOKEN_PATTERN.test(entry.name)) continue;
429
+ if (entry.isSymbolicLink()) throw new RelayFilesystemError(`Refusing symlinked inbox claim: ${entry.name}`);
430
+ if (!entry.isDirectory()) continue;
431
+ const claim = claims.openDirectory(entry.name);
432
+ if (claim === null) continue;
433
+ try {
434
+ if (claim.fileExists(fileName)) return true;
435
+ } finally {
436
+ claim.close();
437
+ }
438
+ }
439
+ return false;
440
+ } finally {
441
+ claims.close();
442
+ }
443
+ }
444
+
445
+ function receiptFileExists(relay: RelayDirectoryHandle, addr: string, fileName: string): boolean {
446
+ // Check claims on both sides of the live inbox. This avoids observing a
447
+ // false absence while a descriptor-relative rename moves in either
448
+ // direction between a claim and the current inbox.
449
+ if (fileExistsInClaims(relay, addr, fileName)) return true;
450
+ const inbox = relay.openDirectory(`${addr}.inbox`);
451
+ if (inbox !== null) {
452
+ try {
453
+ if (inbox.fileExists(fileName)) return true;
454
+ } finally {
455
+ inbox.close();
456
+ }
457
+ }
458
+ return fileExistsInClaims(relay, addr, fileName);
459
+ }
460
+
122
461
  /**
123
462
  * Consumption receipt: after depositing to a LIVE target, wait briefly for
124
- * the letter to vanish. 'delivered' means the receiver's drainer took it —
125
- * stronger than any transport ack.
463
+ * the exact letter to vanish from both the live inbox and durable claims.
464
+ * 'delivered' means the receiver acknowledged it, not merely claimed it.
126
465
  */
127
466
  export async function awaitReceipt(
128
467
  root: string,
@@ -130,13 +469,20 @@ export async function awaitReceipt(
130
469
  letter: Letter,
131
470
  timeoutMs = 1500,
132
471
  ): Promise<'delivered' | 'queued'> {
133
- const file = path.join(inboxDir(root, toAddr), letterFileName(letter));
134
- const deadline = Date.now() + timeoutMs;
135
- while (Date.now() < deadline) {
136
- if (!fs.existsSync(file)) return 'delivered';
137
- await sleep(100);
472
+ assertPathSegment(toAddr, 'relay address');
473
+ const relay = openRelayRoot(root);
474
+ if (relay === null) return 'delivered';
475
+ try {
476
+ const fileName = letterFileName(letter);
477
+ const deadline = Date.now() + timeoutMs;
478
+ while (Date.now() < deadline) {
479
+ if (!receiptFileExists(relay, toAddr, fileName)) return 'delivered';
480
+ await sleep(100);
481
+ }
482
+ return receiptFileExists(relay, toAddr, fileName) ? 'queued' : 'delivered';
483
+ } finally {
484
+ relay.close();
138
485
  }
139
- return fs.existsSync(file) ? 'queued' : 'delivered';
140
486
  }
141
487
 
142
488
  // ── Ask tracking ─────────────────────────────────────────────────────────
@@ -151,77 +497,253 @@ export interface OutAsk {
151
497
  ts: number;
152
498
  }
153
499
 
154
- function asksPath(root: string, addr: string, askId: string): string {
155
- return path.join(asksDir(root, addr), `${askId}.json`);
500
+ function askFileName(askId: string): string {
501
+ assertPathSegment(askId, 'relay ask id');
502
+ return `${askId}.json`;
156
503
  }
157
504
 
158
- function outAskPath(root: string, addr: string, askId: string): string {
159
- return path.join(asksDir(root, addr), `out-${askId}.json`);
505
+ function outAskFileName(askId: string): string {
506
+ assertPathSegment(askId, 'relay ask id');
507
+ return `out-${askId}.json`;
160
508
  }
161
509
 
162
- function writeJson0600(file: string, value: unknown): void {
163
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
164
- const tmp = `${file}.${process.pid}.tmp`;
165
- fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
166
- fs.renameSync(tmp, file);
510
+ function writeAskJson(root: string, addr: string, fileName: string, value: unknown): void {
511
+ assertPathSegment(addr, 'relay address');
512
+ const relay = openRelayRoot(root, true)!;
513
+ try {
514
+ const asks = relay.openDirectory(`${addr}.asks`, true)!;
515
+ try {
516
+ asks.writeFileAtomic(fileName, JSON.stringify(value));
517
+ } finally {
518
+ asks.close();
519
+ }
520
+ } finally {
521
+ relay.close();
522
+ }
167
523
  }
168
524
 
169
525
  export function trackIncomingAsk(root: string, addr: string, letter: Letter): void {
170
- writeJson0600(asksPath(root, addr, letter.id), letter);
526
+ writeAskJson(root, addr, askFileName(letter.id), letter);
171
527
  }
172
528
 
173
529
  export function trackOutgoingAsk(root: string, addr: string, out: OutAsk): void {
174
- writeJson0600(outAskPath(root, addr, out.askId), out);
530
+ writeAskJson(root, addr, outAskFileName(out.askId), out);
175
531
  }
176
532
 
177
- export function readIncomingAsk(root: string, addr: string, askId: string): Letter | null {
533
+ function readAskFile(root: string, addr: string, fileName: string): unknown | null {
534
+ assertPathSegment(addr, 'relay address');
535
+ const relay = openRelayRoot(root);
536
+ if (relay === null) return null;
178
537
  try {
179
- const parsed = JSON.parse(fs.readFileSync(asksPath(root, addr, askId), 'utf8'));
180
- return parsed && typeof parsed.id === 'string' ? (parsed as Letter) : null;
181
- } catch {
538
+ const asks = relay.openDirectory(`${addr}.asks`);
539
+ if (asks === null) return null;
540
+ try {
541
+ const raw = asks.readFile(fileName);
542
+ return raw === null ? null : JSON.parse(raw);
543
+ } finally {
544
+ asks.close();
545
+ }
546
+ } catch (error) {
547
+ rethrowFilesystemError(error);
182
548
  return null;
549
+ } finally {
550
+ relay.close();
183
551
  }
184
552
  }
185
553
 
554
+ export function readIncomingAsk(root: string, addr: string, askId: string): Letter | null {
555
+ const parsed = readAskFile(root, addr, askFileName(askId));
556
+ return parsed && typeof (parsed as Letter).id === 'string' ? (parsed as Letter) : null;
557
+ }
558
+
186
559
  export function readOutgoingAsk(root: string, addr: string, askId: string): OutAsk | null {
560
+ const parsed = readAskFile(root, addr, outAskFileName(askId));
561
+ return parsed && typeof (parsed as OutAsk).toAddr === 'string' ? (parsed as OutAsk) : null;
562
+ }
563
+
564
+ /** Remove both sides of an ask id (incoming and/or outgoing). Idempotent. */
565
+ export function clearAsk(root: string, addr: string, askId: string): void {
566
+ assertPathSegment(addr, 'relay address');
567
+ const relay = openRelayRoot(root);
568
+ if (relay === null) return;
569
+ try {
570
+ const asks = relay.openDirectory(`${addr}.asks`);
571
+ if (asks === null) return;
572
+ try {
573
+ for (const fileName of [askFileName(askId), outAskFileName(askId)]) {
574
+ try {
575
+ asks.unlinkFile(fileName);
576
+ } catch (error) {
577
+ rethrowFilesystemError(error);
578
+ // deletion remains best-effort for ordinary I/O failures
579
+ }
580
+ }
581
+ } finally {
582
+ asks.close();
583
+ }
584
+ } finally {
585
+ relay.close();
586
+ }
587
+ }
588
+
589
+ // ── Audit log ────────────────────────────────────────────────────────────
590
+ // Append-only line-delimited JSON at <root>/audit.log. Written from the
591
+ // deposit and deliver choke points so the record survives the letter being
592
+ // unlinked-as-read. Never holds a full body — only a short preview.
593
+
594
+ export interface AuditRecord {
595
+ ts: number;
596
+ event: 'deposit' | 'deliver' | 'deliver-failed';
597
+ kind: Letter['kind'];
598
+ from: string;
599
+ to: string;
600
+ messageId: string;
601
+ preview: string;
602
+ }
603
+
604
+ const AUDIT_PREVIEW_CHARS = 80;
605
+
606
+ /** Whitespace-collapsed body preview — never the full payload. */
607
+ export function previewBody(body: string): string {
608
+ // Strip ANSI/CSI escapes — the preview is peer-controlled text that can
609
+ // reach a raw terminal via the non-UI /relay log print path.
610
+ // eslint-disable-next-line no-control-regex
611
+ return body
612
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]|\x1b./g, '')
613
+ .replace(/\s+/g, ' ')
614
+ .trim()
615
+ .slice(0, AUDIT_PREVIEW_CHARS);
616
+ }
617
+
618
+ export function auditLogPath(root: string): string {
619
+ return path.join(root, 'audit.log');
620
+ }
621
+
622
+ /** Audit log cap: at ~1 MB the current log becomes audit.log.1 (single generation) and a fresh log starts. */
623
+ const AUDIT_MAX_BYTES = 1024 * 1024;
624
+
625
+ function appendAuditToDirectory(directory: RelayDirectoryHandle, record: AuditRecord): void {
187
626
  try {
188
- const parsed = JSON.parse(fs.readFileSync(outAskPath(root, addr, askId), 'utf8'));
189
- return parsed && typeof parsed.toAddr === 'string' ? (parsed as OutAsk) : null;
627
+ const size = directory.appendFile('audit.log', `${JSON.stringify(record)}\n`);
628
+ // Bounded growth: rotate once past the cap, keeping one previous generation.
629
+ if (size > AUDIT_MAX_BYTES) directory.renameFile('audit.log', 'audit.log.1');
190
630
  } catch {
191
- return null;
631
+ // audit failure (including an unsafe path) never breaks the mail path
192
632
  }
193
633
  }
194
634
 
195
- /** Remove both sides of an ask id (incoming and/or outgoing). Idempotent. */
196
- export function clearAsk(root: string, addr: string, askId: string): void {
197
- for (const file of [asksPath(root, addr, askId), outAskPath(root, addr, askId)]) {
635
+ /** Append one audit record. Best-effort: never throws (audit must not break delivery). */
636
+ export function appendAudit(root: string, record: AuditRecord): void {
637
+ let relay: RelayDirectoryHandle | null = null;
638
+ try {
639
+ relay = openRelayRoot(root, true)!;
640
+ appendAuditToDirectory(relay, record);
641
+ } catch {
642
+ // audit failure (including an unsafe path) never breaks the mail path
643
+ } finally {
644
+ relay?.close();
645
+ }
646
+ }
647
+
648
+ /** Read the last `limit` audit entries (oldest-first within that tail). */
649
+ export function readAudit(root: string, limit = 50): AuditRecord[] {
650
+ const relay = openRelayRoot(root);
651
+ if (relay === null) return [];
652
+ let raw: string | null;
653
+ try {
654
+ raw = relay.readFile('audit.log');
655
+ } catch (error) {
656
+ rethrowFilesystemError(error);
657
+ return [];
658
+ } finally {
659
+ relay.close();
660
+ }
661
+ if (raw === null) return [];
662
+ const out: AuditRecord[] = [];
663
+ for (const line of raw.split('\n')) {
664
+ if (line.length === 0) continue;
198
665
  try {
199
- fs.unlinkSync(file);
666
+ const parsed = JSON.parse(line);
667
+ if (parsed && typeof parsed.ts === 'number' && typeof parsed.event === 'string') out.push(parsed as AuditRecord);
200
668
  } catch {
201
- // already gone
669
+ // skip corrupt line — append-only, never fatal
202
670
  }
203
671
  }
672
+ return out.slice(-limit);
673
+ }
674
+
675
+ /** Resolve a pending ask by explicit replyTo id or unique prefix. No inference. */
676
+ export function resolveAskByRef(root: string, addr: string, replyTo: string): Letter | null {
677
+ if (!replyTo) return null; // empty prefix matches every id — an explicit ref is required
678
+ return pendingAsks(root, addr).find((a) => a.id === replyTo || a.id.startsWith(replyTo)) ?? null;
204
679
  }
205
680
 
206
681
  /** Asks we have received and not yet answered, oldest first. */
207
682
  export function pendingAsks(root: string, addr: string): Letter[] {
208
- let names: string[];
683
+ assertPathSegment(addr, 'relay address');
684
+ const relay = openRelayRoot(root);
685
+ if (relay === null) return [];
209
686
  try {
210
- names = fs
211
- .readdirSync(asksDir(root, addr))
212
- .filter((f) => f.endsWith('.json') && !f.startsWith('out-'))
213
- .sort();
214
- } catch {
687
+ const asks = relay.openDirectory(`${addr}.asks`);
688
+ if (asks === null) return [];
689
+ try {
690
+ const names = asks
691
+ .readDirectory()
692
+ .filter((entry) => {
693
+ if (!entry.name.endsWith('.json') || entry.name.startsWith('out-')) return false;
694
+ if (entry.isSymbolicLink()) throw new RelayFilesystemError(`Refusing symlinked relay ask: ${entry.name}`);
695
+ return entry.isFile();
696
+ })
697
+ .map((entry) => entry.name)
698
+ .sort();
699
+ const out: Letter[] = [];
700
+ for (const name of names) {
701
+ try {
702
+ const raw = asks.readFile(name);
703
+ if (raw === null) continue;
704
+ const parsed = JSON.parse(raw);
705
+ if (parsed && typeof parsed.id === 'string') out.push(parsed as Letter);
706
+ } catch (error) {
707
+ rethrowFilesystemError(error);
708
+ // skip corrupt entry
709
+ }
710
+ }
711
+ return out;
712
+ } finally {
713
+ asks.close();
714
+ }
715
+ } catch (error) {
716
+ rethrowFilesystemError(error);
215
717
  return [];
718
+ } finally {
719
+ relay.close();
216
720
  }
217
- const out: Letter[] = [];
218
- for (const name of names) {
721
+ }
722
+
723
+ /** Outgoing ask ids, used by the Pi entry point for prefix resolution. */
724
+ export function outgoingAskIds(root: string, addr: string): string[] {
725
+ assertPathSegment(addr, 'relay address');
726
+ const relay = openRelayRoot(root);
727
+ if (relay === null) return [];
728
+ try {
729
+ const asks = relay.openDirectory(`${addr}.asks`);
730
+ if (asks === null) return [];
219
731
  try {
220
- const parsed = JSON.parse(fs.readFileSync(path.join(asksDir(root, addr), name), 'utf8'));
221
- if (parsed && typeof parsed.id === 'string') out.push(parsed as Letter);
222
- } catch {
223
- // skip corrupt entry
732
+ return asks
733
+ .readDirectory()
734
+ .filter((entry) => {
735
+ if (!entry.name.startsWith('out-') || !entry.name.endsWith('.json')) return false;
736
+ if (entry.isSymbolicLink()) throw new RelayFilesystemError(`Refusing symlinked relay ask: ${entry.name}`);
737
+ return entry.isFile();
738
+ })
739
+ .map((entry) => entry.name.slice('out-'.length, -'.json'.length));
740
+ } finally {
741
+ asks.close();
224
742
  }
743
+ } catch (error) {
744
+ rethrowFilesystemError(error);
745
+ return [];
746
+ } finally {
747
+ relay.close();
225
748
  }
226
- return out;
227
749
  }