@nicknisi/pi-relay 0.2.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
@@ -14,9 +14,16 @@
14
14
  * body, only a short preview.
15
15
  */
16
16
 
17
- import * as fs from 'node:fs';
17
+ import { randomBytes } from 'node:crypto';
18
18
  import * as path from 'node:path';
19
- 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';
20
27
 
21
28
  export type LetterKind = 'message' | 'ask' | 'reply' | 'cancel';
22
29
 
@@ -31,28 +38,66 @@ export interface Letter {
31
38
 
32
39
  export const MAX_BODY_CHARS = 32 * 1024;
33
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
+
34
45
  function letterFileName(letter: Letter): string {
35
- 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
+ }
36
72
  }
37
73
 
38
- /** 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. */
39
75
  export function deposit(root: string, toAddr: string, letter: Letter): void {
40
- const dir = inboxDir(root, toAddr);
41
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
42
- const name = letterFileName(letter);
43
- const tmp = path.join(dir, `${name}.${process.pid}.tmp`);
44
- fs.writeFileSync(tmp, JSON.stringify(letter), { mode: 0o600 });
45
- fs.renameSync(tmp, path.join(dir, name));
46
- // Send-side choke point: every send/ask/reply/cancel goes through here.
47
- appendAudit(root, {
48
- ts: Date.now(),
49
- event: 'deposit',
50
- kind: letter.kind,
51
- from: letter.from.addr,
52
- to: toAddr,
53
- messageId: letter.id,
54
- preview: previewBody(letter.body),
55
- });
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
+ }
56
101
  }
57
102
 
58
103
  /**
@@ -62,47 +107,78 @@ export function deposit(root: string, toAddr: string, letter: Letter): void {
62
107
  * caller must re-deposit any letter it fails to deliver (checkInbox does).
63
108
  */
64
109
  export function drain(root: string, addr: string): Letter[] {
65
- const dir = inboxDir(root, addr);
66
- let names: string[];
110
+ assertPathSegment(addr, 'relay address');
111
+ const relay = openRelayRoot(root);
112
+ if (relay === null) return [];
67
113
  try {
68
- names = fs
69
- .readdirSync(dir)
70
- .filter((f) => f.endsWith('.json'))
71
- .sort();
72
- } catch {
73
- return [];
74
- }
75
- const out: Letter[] = [];
76
- for (const name of names) {
77
- const file = path.join(dir, name);
78
- let raw: string;
79
- try {
80
- raw = fs.readFileSync(file, 'utf8');
81
- } catch {
82
- continue;
83
- }
114
+ const inbox = relay.openDirectory(`${addr}.inbox`);
115
+ if (inbox === null) return [];
84
116
  try {
85
- fs.unlinkSync(file);
86
- } catch {
87
- continue; // another drainer took it
88
- }
89
- try {
90
- const parsed = JSON.parse(raw);
91
- if (parsed && typeof parsed.id === 'string' && typeof parsed.body === 'string') {
92
- 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
+ }
93
148
  }
94
- } catch {
95
- // corrupt letter — discarded, cannot fail future drains
149
+ return out;
150
+ } finally {
151
+ inbox.close();
96
152
  }
153
+ } catch (error) {
154
+ rethrowFilesystemError(error);
155
+ return [];
156
+ } finally {
157
+ relay.close();
97
158
  }
98
- return out;
99
159
  }
100
160
 
101
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;
102
165
  try {
103
- return fs.readdirSync(inboxDir(root, addr)).filter((f) => f.endsWith('.json')).length;
104
- } 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);
105
179
  return 0;
180
+ } finally {
181
+ relay.close();
106
182
  }
107
183
  }
108
184
 
@@ -113,29 +189,279 @@ export function unreadCount(root: string, addr: string): number {
113
189
  * a process alive.
114
190
  */
115
191
  export function watchInbox(root: string, addr: string, onMail: () => void): () => void {
116
- const dir = inboxDir(root, addr);
117
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
118
- 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;
119
196
  try {
120
- watcher = fs.watch(dir, () => onMail());
197
+ relay = openRelayRoot(root, true)!;
198
+ inbox = relay.openDirectory(`${addr}.inbox`, true)!;
199
+ watcher = inbox.watch(onMail);
121
200
  watcher.unref();
122
- } catch {
123
- // 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
124
208
  }
125
209
  const poll = setInterval(onMail, 3000);
126
210
  poll.unref();
127
211
  return () => {
128
212
  watcher?.close();
129
213
  clearInterval(poll);
214
+ inbox?.close();
215
+ relay?.close();
130
216
  };
131
217
  }
132
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
+
133
421
  const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
134
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
+
135
461
  /**
136
462
  * Consumption receipt: after depositing to a LIVE target, wait briefly for
137
- * the letter to vanish. 'delivered' means the receiver's drainer took it —
138
- * 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.
139
465
  */
140
466
  export async function awaitReceipt(
141
467
  root: string,
@@ -143,13 +469,20 @@ export async function awaitReceipt(
143
469
  letter: Letter,
144
470
  timeoutMs = 1500,
145
471
  ): Promise<'delivered' | 'queued'> {
146
- const file = path.join(inboxDir(root, toAddr), letterFileName(letter));
147
- const deadline = Date.now() + timeoutMs;
148
- while (Date.now() < deadline) {
149
- if (!fs.existsSync(file)) return 'delivered';
150
- 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();
151
485
  }
152
- return fs.existsSync(file) ? 'queued' : 'delivered';
153
486
  }
154
487
 
155
488
  // ── Ask tracking ─────────────────────────────────────────────────────────
@@ -164,55 +497,92 @@ export interface OutAsk {
164
497
  ts: number;
165
498
  }
166
499
 
167
- function asksPath(root: string, addr: string, askId: string): string {
168
- return path.join(asksDir(root, addr), `${askId}.json`);
500
+ function askFileName(askId: string): string {
501
+ assertPathSegment(askId, 'relay ask id');
502
+ return `${askId}.json`;
169
503
  }
170
504
 
171
- function outAskPath(root: string, addr: string, askId: string): string {
172
- 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`;
173
508
  }
174
509
 
175
- function writeJson0600(file: string, value: unknown): void {
176
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
177
- const tmp = `${file}.${process.pid}.tmp`;
178
- fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
179
- 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
+ }
180
523
  }
181
524
 
182
525
  export function trackIncomingAsk(root: string, addr: string, letter: Letter): void {
183
- writeJson0600(asksPath(root, addr, letter.id), letter);
526
+ writeAskJson(root, addr, askFileName(letter.id), letter);
184
527
  }
185
528
 
186
529
  export function trackOutgoingAsk(root: string, addr: string, out: OutAsk): void {
187
- writeJson0600(outAskPath(root, addr, out.askId), out);
530
+ writeAskJson(root, addr, outAskFileName(out.askId), out);
188
531
  }
189
532
 
190
- 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;
191
537
  try {
192
- const parsed = JSON.parse(fs.readFileSync(asksPath(root, addr, askId), 'utf8'));
193
- return parsed && typeof parsed.id === 'string' ? (parsed as Letter) : null;
194
- } 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);
195
548
  return null;
549
+ } finally {
550
+ relay.close();
196
551
  }
197
552
  }
198
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
+
199
559
  export function readOutgoingAsk(root: string, addr: string, askId: string): OutAsk | null {
200
- try {
201
- const parsed = JSON.parse(fs.readFileSync(outAskPath(root, addr, askId), 'utf8'));
202
- return parsed && typeof parsed.toAddr === 'string' ? (parsed as OutAsk) : null;
203
- } catch {
204
- return null;
205
- }
560
+ const parsed = readAskFile(root, addr, outAskFileName(askId));
561
+ return parsed && typeof (parsed as OutAsk).toAddr === 'string' ? (parsed as OutAsk) : null;
206
562
  }
207
563
 
208
564
  /** Remove both sides of an ask id (incoming and/or outgoing). Idempotent. */
209
565
  export function clearAsk(root: string, addr: string, askId: string): void {
210
- for (const file of [asksPath(root, addr, askId), outAskPath(root, addr, askId)]) {
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;
211
572
  try {
212
- fs.unlinkSync(file);
213
- } catch {
214
- // already gone
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();
215
583
  }
584
+ } finally {
585
+ relay.close();
216
586
  }
217
587
  }
218
588
 
@@ -252,37 +622,49 @@ export function auditLogPath(root: string): string {
252
622
  /** Audit log cap: at ~1 MB the current log becomes audit.log.1 (single generation) and a fresh log starts. */
253
623
  const AUDIT_MAX_BYTES = 1024 * 1024;
254
624
 
625
+ function appendAuditToDirectory(directory: RelayDirectoryHandle, record: AuditRecord): void {
626
+ try {
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');
630
+ } catch {
631
+ // audit failure (including an unsafe path) never breaks the mail path
632
+ }
633
+ }
634
+
255
635
  /** Append one audit record. Best-effort: never throws (audit must not break delivery). */
256
636
  export function appendAudit(root: string, record: AuditRecord): void {
637
+ let relay: RelayDirectoryHandle | null = null;
257
638
  try {
258
- const file = auditLogPath(root);
259
- if (!fs.existsSync(file)) fs.writeFileSync(file, '', { mode: 0o600 });
260
- fs.appendFileSync(file, `${JSON.stringify(record)}\n`);
261
- // Bounded growth: rotate once past the cap, keeping one previous generation.
262
- if (fs.statSync(file).size > AUDIT_MAX_BYTES) {
263
- fs.renameSync(file, `${file}.1`);
264
- }
639
+ relay = openRelayRoot(root, true)!;
640
+ appendAuditToDirectory(relay, record);
265
641
  } catch {
266
- // audit failure never breaks the mail path
642
+ // audit failure (including an unsafe path) never breaks the mail path
643
+ } finally {
644
+ relay?.close();
267
645
  }
268
646
  }
269
647
 
270
648
  /** Read the last `limit` audit entries (oldest-first within that tail). */
271
649
  export function readAudit(root: string, limit = 50): AuditRecord[] {
272
- let raw: string;
650
+ const relay = openRelayRoot(root);
651
+ if (relay === null) return [];
652
+ let raw: string | null;
273
653
  try {
274
- raw = fs.readFileSync(auditLogPath(root), 'utf8');
275
- } catch {
654
+ raw = relay.readFile('audit.log');
655
+ } catch (error) {
656
+ rethrowFilesystemError(error);
276
657
  return [];
658
+ } finally {
659
+ relay.close();
277
660
  }
661
+ if (raw === null) return [];
278
662
  const out: AuditRecord[] = [];
279
663
  for (const line of raw.split('\n')) {
280
664
  if (line.length === 0) continue;
281
665
  try {
282
666
  const parsed = JSON.parse(line);
283
- if (parsed && typeof parsed.ts === 'number' && typeof parsed.event === 'string') {
284
- out.push(parsed as AuditRecord);
285
- }
667
+ if (parsed && typeof parsed.ts === 'number' && typeof parsed.event === 'string') out.push(parsed as AuditRecord);
286
668
  } catch {
287
669
  // skip corrupt line — append-only, never fatal
288
670
  }
@@ -298,23 +680,70 @@ export function resolveAskByRef(root: string, addr: string, replyTo: string): Le
298
680
 
299
681
  /** Asks we have received and not yet answered, oldest first. */
300
682
  export function pendingAsks(root: string, addr: string): Letter[] {
301
- let names: string[];
683
+ assertPathSegment(addr, 'relay address');
684
+ const relay = openRelayRoot(root);
685
+ if (relay === null) return [];
302
686
  try {
303
- names = fs
304
- .readdirSync(asksDir(root, addr))
305
- .filter((f) => f.endsWith('.json') && !f.startsWith('out-'))
306
- .sort();
307
- } 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);
308
717
  return [];
718
+ } finally {
719
+ relay.close();
309
720
  }
310
- const out: Letter[] = [];
311
- 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 [];
312
731
  try {
313
- const parsed = JSON.parse(fs.readFileSync(path.join(asksDir(root, addr), name), 'utf8'));
314
- if (parsed && typeof parsed.id === 'string') out.push(parsed as Letter);
315
- } catch {
316
- // 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();
317
742
  }
743
+ } catch (error) {
744
+ rethrowFilesystemError(error);
745
+ return [];
746
+ } finally {
747
+ relay.close();
318
748
  }
319
- return out;
320
749
  }