@nicknisi/pi-relay 0.2.0 → 0.3.1

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