@nicknisi/pi-relay 0.3.5 → 0.4.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/README.md +6 -2
- package/core.ts +3 -1
- package/discovery.ts +152 -0
- package/dist/core.d.ts +3 -3
- package/dist/core.test.js +23 -2
- package/dist/discovery.d.ts +48 -0
- package/dist/discovery.js +97 -0
- package/dist/discovery.test.d.ts +1 -0
- package/dist/discovery.test.js +95 -0
- package/dist/extension.test.d.ts +1 -0
- package/dist/extension.test.js +190 -0
- package/dist/filesystem.js +1 -1
- package/dist/format.d.ts +0 -2
- package/dist/format.js +0 -6
- package/dist/index.js +106 -45
- package/dist/mailbox.d.ts +9 -6
- package/dist/mailbox.js +125 -11
- package/dist/receipts.test.d.ts +1 -0
- package/dist/receipts.test.js +183 -0
- package/dist/registry.js +7 -0
- package/dist/relay.test.js +3 -7
- package/dist/routing.d.ts +18 -0
- package/dist/routing.js +58 -0
- package/filesystem.ts +1 -1
- package/format.ts +0 -8
- package/index.ts +129 -56
- package/mailbox.ts +135 -10
- package/package.json +4 -2
- package/registry.ts +6 -0
- package/routing.ts +70 -0
package/dist/mailbox.js
CHANGED
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
* failed delivery leaves the letter recoverable (recoverInboxClaims) or
|
|
10
10
|
* requeueable.
|
|
11
11
|
* - A corrupt letter is discarded on read, so it cannot poison every drain.
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
12
|
+
* - Durable acknowledgement is the receipt: disappearance without exact
|
|
13
|
+
* proof is uncertain, never evidence of delivery.
|
|
14
14
|
* - Every deposit and every delivery appends one append-only audit line —
|
|
15
15
|
* drain-as-receipt must not destroy evidence. The log never holds a full
|
|
16
16
|
* body, only a short preview.
|
|
17
17
|
*/
|
|
18
|
-
import { randomBytes } from 'node:crypto';
|
|
18
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
19
19
|
import * as path from 'node:path';
|
|
20
20
|
import { assertPathSegment, openRelayRoot, rethrowFilesystemError, RelayDirectoryHandle, RelayFilesystemError, } from './filesystem.js';
|
|
21
21
|
export const MAX_BODY_CHARS = 32 * 1024;
|
|
@@ -372,6 +372,36 @@ export function readClaimedLetter(root, addr, claimToken, fileToken) {
|
|
|
372
372
|
relay.close();
|
|
373
373
|
}
|
|
374
374
|
}
|
|
375
|
+
function letterDigest(letter) {
|
|
376
|
+
return createHash('sha256')
|
|
377
|
+
.update(JSON.stringify([
|
|
378
|
+
letter.id,
|
|
379
|
+
letter.ts,
|
|
380
|
+
letter.kind,
|
|
381
|
+
letter.body,
|
|
382
|
+
letter.replyTo ?? null,
|
|
383
|
+
letter.from.addr,
|
|
384
|
+
letter.from.name,
|
|
385
|
+
letter.from.cwd,
|
|
386
|
+
]))
|
|
387
|
+
.digest('hex');
|
|
388
|
+
}
|
|
389
|
+
function readAcknowledgement(raw, fileName) {
|
|
390
|
+
try {
|
|
391
|
+
const proof = raw === null ? null : JSON.parse(raw);
|
|
392
|
+
return proof &&
|
|
393
|
+
proof.fileName === fileName &&
|
|
394
|
+
typeof proof.digest === 'string' &&
|
|
395
|
+
/^[a-f0-9]{64}$/.test(proof.digest) &&
|
|
396
|
+
Number.isSafeInteger(proof.acknowledgedAt) &&
|
|
397
|
+
proof.acknowledgedAt >= 0
|
|
398
|
+
? proof
|
|
399
|
+
: null;
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
375
405
|
function finishClaimedLetter(root, addr, claimToken, fileToken, requeue) {
|
|
376
406
|
assertPathSegment(addr, 'relay address');
|
|
377
407
|
assertInboxClaimToken(claimToken);
|
|
@@ -400,6 +430,42 @@ function finishClaimedLetter(root, addr, claimToken, fileToken, requeue) {
|
|
|
400
430
|
}
|
|
401
431
|
}
|
|
402
432
|
else {
|
|
433
|
+
// Persist proof before deleting the exact letter. Corrupt payloads are discarded without proof.
|
|
434
|
+
const raw = claim.readFile(fileToken);
|
|
435
|
+
if (raw !== null) {
|
|
436
|
+
let parsed;
|
|
437
|
+
try {
|
|
438
|
+
parsed = JSON.parse(raw);
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
/* corrupt letter */
|
|
442
|
+
}
|
|
443
|
+
if (parsed &&
|
|
444
|
+
typeof parsed.id === 'string' &&
|
|
445
|
+
MESSAGE_ID_PATTERN.test(parsed.id) &&
|
|
446
|
+
typeof parsed.body === 'string' &&
|
|
447
|
+
['message', 'ask', 'reply', 'cancel'].includes(parsed.kind) &&
|
|
448
|
+
parsed.from &&
|
|
449
|
+
typeof parsed.from.addr === 'string' &&
|
|
450
|
+
typeof parsed.from.name === 'string' &&
|
|
451
|
+
typeof parsed.from.cwd === 'string' &&
|
|
452
|
+
Number.isSafeInteger(parsed.ts) &&
|
|
453
|
+
parsed.ts >= 0 &&
|
|
454
|
+
letterFileName(parsed) === fileToken) {
|
|
455
|
+
const acks = relay.openDirectory(`${addr}.acks`, true);
|
|
456
|
+
try {
|
|
457
|
+
const proof = {
|
|
458
|
+
fileName: fileToken,
|
|
459
|
+
digest: letterDigest(parsed),
|
|
460
|
+
acknowledgedAt: Date.now(),
|
|
461
|
+
};
|
|
462
|
+
acks.writeFileAtomic(fileToken, JSON.stringify(proof));
|
|
463
|
+
}
|
|
464
|
+
finally {
|
|
465
|
+
acks.close();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
403
469
|
finished = claim.unlinkFile(fileToken);
|
|
404
470
|
if (finished)
|
|
405
471
|
claim.sync();
|
|
@@ -428,6 +494,45 @@ export function ackClaimedLetter(root, addr, claimToken, fileToken) {
|
|
|
428
494
|
export function requeueClaimedLetter(root, addr, claimToken, fileToken) {
|
|
429
495
|
return finishClaimedLetter(root, addr, claimToken, fileToken, true);
|
|
430
496
|
}
|
|
497
|
+
/** Retain exact acknowledgement proof for the mail retention window, including live sessions. */
|
|
498
|
+
export function sweepAcknowledgements(root, addr, now, retentionMs) {
|
|
499
|
+
assertPathSegment(addr, 'relay address');
|
|
500
|
+
const relay = openRelayRoot(root);
|
|
501
|
+
if (relay === null)
|
|
502
|
+
return 0;
|
|
503
|
+
try {
|
|
504
|
+
const acks = relay.openDirectory(`${addr}.acks`);
|
|
505
|
+
if (acks === null)
|
|
506
|
+
return 0;
|
|
507
|
+
let retained = 0;
|
|
508
|
+
try {
|
|
509
|
+
for (const entry of acks.readDirectory()) {
|
|
510
|
+
if (entry.isSymbolicLink())
|
|
511
|
+
throw new RelayFilesystemError(`Refusing symlinked relay acknowledgement: ${entry.name}`);
|
|
512
|
+
if (!entry.isFile()) {
|
|
513
|
+
retained++;
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
const parts = inboxFileTokenParts(entry.name);
|
|
517
|
+
const proof = readAcknowledgement(acks.readFile(entry.name), entry.name);
|
|
518
|
+
if (parts === null || proof === null || now - proof.acknowledgedAt < retentionMs) {
|
|
519
|
+
retained++;
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
acks.unlinkFile(entry.name);
|
|
523
|
+
}
|
|
524
|
+
acks.sync();
|
|
525
|
+
}
|
|
526
|
+
finally {
|
|
527
|
+
acks.close();
|
|
528
|
+
}
|
|
529
|
+
relay.removeEmptyDirectory(`${addr}.acks`);
|
|
530
|
+
return retained;
|
|
531
|
+
}
|
|
532
|
+
finally {
|
|
533
|
+
relay.close();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
431
536
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
432
537
|
function fileExistsInClaims(relay, addr, fileName) {
|
|
433
538
|
const claims = relay.openDirectory(claimsDirectoryName(addr));
|
|
@@ -476,25 +581,34 @@ function receiptFileExists(relay, addr, fileName) {
|
|
|
476
581
|
}
|
|
477
582
|
return fileExistsInClaims(relay, addr, fileName);
|
|
478
583
|
}
|
|
479
|
-
/**
|
|
480
|
-
* Consumption receipt: after depositing to a LIVE target, wait briefly for
|
|
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.
|
|
483
|
-
*/
|
|
484
584
|
export async function awaitReceipt(root, toAddr, letter, timeoutMs = 1500) {
|
|
485
585
|
assertPathSegment(toAddr, 'relay address');
|
|
486
586
|
const relay = openRelayRoot(root);
|
|
487
587
|
if (relay === null)
|
|
488
|
-
return '
|
|
588
|
+
return 'uncertain';
|
|
489
589
|
try {
|
|
490
590
|
const fileName = letterFileName(letter);
|
|
591
|
+
const observe = () => {
|
|
592
|
+
const acks = relay.openDirectory(`${toAddr}.acks`);
|
|
593
|
+
if (acks !== null) {
|
|
594
|
+
try {
|
|
595
|
+
const proof = readAcknowledgement(acks.readFile(fileName), fileName);
|
|
596
|
+
if (proof?.digest === letterDigest(letter))
|
|
597
|
+
return 'delivered';
|
|
598
|
+
}
|
|
599
|
+
finally {
|
|
600
|
+
acks.close();
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return receiptFileExists(relay, toAddr, fileName) ? 'queued' : 'uncertain';
|
|
604
|
+
};
|
|
491
605
|
const deadline = Date.now() + timeoutMs;
|
|
492
606
|
while (Date.now() < deadline) {
|
|
493
|
-
if (
|
|
607
|
+
if (observe() === 'delivered')
|
|
494
608
|
return 'delivered';
|
|
495
609
|
await sleep(100);
|
|
496
610
|
}
|
|
497
|
-
return
|
|
611
|
+
return observe();
|
|
498
612
|
}
|
|
499
613
|
finally {
|
|
500
614
|
relay.close();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { afterEach, expect, it, vi } from 'vitest';
|
|
5
|
+
import { ackClaimedLetter, awaitReceipt, claimInbox, deposit, sweepAcknowledgements } from './mailbox.js';
|
|
6
|
+
import { deriveAddr, sweep, SWEEP_MAIL_KEEP_MS, writeRecord } from './registry.js';
|
|
7
|
+
import { openRelayRoot, RelayDirectoryHandle } from './filesystem.js';
|
|
8
|
+
const roots = [];
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
for (const root of roots.splice(0))
|
|
11
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
12
|
+
});
|
|
13
|
+
function fixture() {
|
|
14
|
+
const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'relay-receipt-')));
|
|
15
|
+
roots.push(root);
|
|
16
|
+
return root;
|
|
17
|
+
}
|
|
18
|
+
const addr = deriveAddr('/receiver', 'receiver');
|
|
19
|
+
function letter(id = 'proof', ts = Date.now()) {
|
|
20
|
+
return {
|
|
21
|
+
id,
|
|
22
|
+
ts,
|
|
23
|
+
kind: 'message',
|
|
24
|
+
body: 'fixture',
|
|
25
|
+
from: { addr: deriveAddr('/sender', 'sender'), name: 'sender', cwd: '/sender' },
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
it('does not infer delivery from absent roots, removed letters, or legacy roots without ACKs', async () => {
|
|
29
|
+
const root = fixture();
|
|
30
|
+
const l = letter();
|
|
31
|
+
expect(await awaitReceipt(path.join(root, 'absent'), addr, l, 0)).toBe('uncertain');
|
|
32
|
+
deposit(root, addr, l);
|
|
33
|
+
expect(await awaitReceipt(root, addr, l, 0)).toBe('queued');
|
|
34
|
+
fs.unlinkSync(path.join(root, `${addr}.inbox`, `${l.ts}-${l.id}.json`));
|
|
35
|
+
expect(await awaitReceipt(root, addr, l, 0)).toBe('uncertain');
|
|
36
|
+
});
|
|
37
|
+
it('persists exact proof before deletion and cleans empty claims on Linux and macOS', async () => {
|
|
38
|
+
const root = fixture();
|
|
39
|
+
const l = letter();
|
|
40
|
+
deposit(root, addr, l);
|
|
41
|
+
const claim = claimInbox(root, addr);
|
|
42
|
+
expect(await awaitReceipt(root, addr, l, 0)).toBe('queued');
|
|
43
|
+
expect(ackClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[0])).toBe(true);
|
|
44
|
+
expect(fs.existsSync(path.join(root, `${addr}.claims`, claim.claimToken))).toBe(false);
|
|
45
|
+
expect(await awaitReceipt(root, addr, l, 0)).toBe('delivered');
|
|
46
|
+
expect(await awaitReceipt(root, addr, { ...l, id: 'different' }, 0)).toBe('uncertain');
|
|
47
|
+
expect(await awaitReceipt(root, addr, { ...l, body: 'changed payload' }, 0)).toBe('uncertain');
|
|
48
|
+
expect(await awaitReceipt(root, addr, { ...l, from: { ...l.from, addr: deriveAddr('/other', 'other') } }, 0)).toBe('uncertain');
|
|
49
|
+
expect(ackClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[0])).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
it('keeps mail claimed if the ACK path is unsafe, without writing through symlinks', async () => {
|
|
52
|
+
const root = fixture();
|
|
53
|
+
const outside = fixture();
|
|
54
|
+
const l = letter();
|
|
55
|
+
deposit(root, addr, l);
|
|
56
|
+
const claim = claimInbox(root, addr);
|
|
57
|
+
fs.symlinkSync(outside, path.join(root, `${addr}.acks`));
|
|
58
|
+
expect(() => ackClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[0])).toThrow(/symlink/i);
|
|
59
|
+
expect(fs.existsSync(path.join(root, `${addr}.claims`, claim.claimToken, claim.fileTokens[0]))).toBe(true);
|
|
60
|
+
expect(fs.readdirSync(outside)).toEqual([]);
|
|
61
|
+
await expect(awaitReceipt(root, addr, l, 0)).rejects.toThrow(/symlink/i);
|
|
62
|
+
});
|
|
63
|
+
it('does not grant delivery proof to corrupt or mismatched payloads', async () => {
|
|
64
|
+
const root = fixture();
|
|
65
|
+
const l = letter();
|
|
66
|
+
deposit(root, addr, l);
|
|
67
|
+
const claim = claimInbox(root, addr);
|
|
68
|
+
fs.writeFileSync(path.join(root, `${addr}.claims`, claim.claimToken, claim.fileTokens[0]), '{bad');
|
|
69
|
+
ackClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[0]);
|
|
70
|
+
expect(await awaitReceipt(root, addr, l, 0)).toBe('uncertain');
|
|
71
|
+
});
|
|
72
|
+
it('preserves claimed mail when persisting proof fails and supports a successful retry', async () => {
|
|
73
|
+
const root = fixture();
|
|
74
|
+
const l = letter();
|
|
75
|
+
deposit(root, addr, l);
|
|
76
|
+
const claim = claimInbox(root, addr);
|
|
77
|
+
const write = vi.spyOn(RelayDirectoryHandle.prototype, 'writeFileAtomic').mockImplementationOnce(() => {
|
|
78
|
+
throw new Error('fixture proof write failed');
|
|
79
|
+
});
|
|
80
|
+
try {
|
|
81
|
+
expect(() => ackClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[0])).toThrow(/proof write failed/);
|
|
82
|
+
expect(fs.existsSync(path.join(root, `${addr}.claims`, claim.claimToken, claim.fileTokens[0]))).toBe(true);
|
|
83
|
+
expect(await awaitReceipt(root, addr, l, 0)).toBe('queued');
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
write.mockRestore();
|
|
87
|
+
}
|
|
88
|
+
expect(ackClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[0])).toBe(true);
|
|
89
|
+
expect(await awaitReceipt(root, addr, l, 0)).toBe('delivered');
|
|
90
|
+
});
|
|
91
|
+
it('rejects symlinked proof files without reading their targets', async () => {
|
|
92
|
+
const root = fixture();
|
|
93
|
+
const outside = fixture();
|
|
94
|
+
const l = letter();
|
|
95
|
+
deposit(root, addr, l);
|
|
96
|
+
const claim = claimInbox(root, addr);
|
|
97
|
+
const token = claim.fileTokens[0];
|
|
98
|
+
ackClaimedLetter(root, addr, claim.claimToken, token);
|
|
99
|
+
const proofPath = path.join(root, `${addr}.acks`, token);
|
|
100
|
+
const outsideFile = path.join(outside, 'fixture-proof');
|
|
101
|
+
fs.writeFileSync(outsideFile, fs.readFileSync(proofPath));
|
|
102
|
+
fs.unlinkSync(proofPath);
|
|
103
|
+
fs.symlinkSync(outsideFile, proofPath);
|
|
104
|
+
await expect(awaitReceipt(root, addr, l, 0)).rejects.toThrow(/symlink/i);
|
|
105
|
+
});
|
|
106
|
+
it('expires proof for live sessions, retains malformed markers, and protects recent proof during sweep', () => {
|
|
107
|
+
const root = fixture();
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
const old = letter('old', now - SWEEP_MAIL_KEEP_MS - 1);
|
|
110
|
+
const recent = letter('recent', now);
|
|
111
|
+
for (const l of [old, recent])
|
|
112
|
+
deposit(root, addr, l);
|
|
113
|
+
const claim = claimInbox(root, addr);
|
|
114
|
+
for (const token of claim.fileTokens)
|
|
115
|
+
ackClaimedLetter(root, addr, claim.claimToken, token);
|
|
116
|
+
const oldProofPath = path.join(root, `${addr}.acks`, `${old.ts}-${old.id}.json`);
|
|
117
|
+
const recentProofPath = path.join(root, `${addr}.acks`, `${recent.ts}-${recent.id}.json`);
|
|
118
|
+
// Retention starts at acknowledgement, not at the timestamp of long-queued mail.
|
|
119
|
+
const oldProof = JSON.parse(fs.readFileSync(oldProofPath, 'utf8'));
|
|
120
|
+
expect(oldProof.acknowledgedAt).toBeGreaterThanOrEqual(now);
|
|
121
|
+
fs.writeFileSync(oldProofPath, JSON.stringify({ ...oldProof, acknowledgedAt: now - SWEEP_MAIL_KEEP_MS - 1 }));
|
|
122
|
+
const recentProof = JSON.parse(fs.readFileSync(recentProofPath, 'utf8'));
|
|
123
|
+
fs.writeFileSync(recentProofPath, JSON.stringify({ ...recentProof, acknowledgedAt: now }));
|
|
124
|
+
writeRecord(root, {
|
|
125
|
+
addr,
|
|
126
|
+
sessionId: 'receiver',
|
|
127
|
+
name: 'receiver',
|
|
128
|
+
cwd: '/receiver',
|
|
129
|
+
pid: process.pid,
|
|
130
|
+
startedAt: now,
|
|
131
|
+
lastSeenAt: now,
|
|
132
|
+
status: 'idle',
|
|
133
|
+
});
|
|
134
|
+
sweep(root, now);
|
|
135
|
+
expect(fs.readdirSync(path.join(root, `${addr}.acks`))).toEqual([`${recent.ts}-${recent.id}.json`]);
|
|
136
|
+
writeRecord(root, {
|
|
137
|
+
addr,
|
|
138
|
+
sessionId: 'receiver',
|
|
139
|
+
name: 'receiver',
|
|
140
|
+
cwd: '/receiver',
|
|
141
|
+
pid: process.pid,
|
|
142
|
+
startedAt: now,
|
|
143
|
+
lastSeenAt: now - 60_000,
|
|
144
|
+
status: 'idle',
|
|
145
|
+
offline: true,
|
|
146
|
+
});
|
|
147
|
+
sweep(root, now, () => false);
|
|
148
|
+
expect(fs.existsSync(path.join(root, `${addr}.json`))).toBe(true);
|
|
149
|
+
fs.writeFileSync(path.join(root, `${addr}.acks`, 'malformed.json'), 'not proof');
|
|
150
|
+
expect(sweepAcknowledgements(root, addr, now + SWEEP_MAIL_KEEP_MS + 1, SWEEP_MAIL_KEEP_MS)).toBe(1);
|
|
151
|
+
});
|
|
152
|
+
it('never lets retained proof pin an expired offline record', () => {
|
|
153
|
+
const root = fixture();
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
writeRecord(root, {
|
|
156
|
+
addr,
|
|
157
|
+
sessionId: 'receiver',
|
|
158
|
+
name: 'receiver',
|
|
159
|
+
cwd: '/receiver',
|
|
160
|
+
pid: process.pid,
|
|
161
|
+
startedAt: 0,
|
|
162
|
+
lastSeenAt: now - SWEEP_MAIL_KEEP_MS - 1,
|
|
163
|
+
status: 'idle',
|
|
164
|
+
offline: true,
|
|
165
|
+
});
|
|
166
|
+
fs.mkdirSync(path.join(root, `${addr}.acks`));
|
|
167
|
+
fs.writeFileSync(path.join(root, `${addr}.acks`, 'malformed.json'), 'not proof');
|
|
168
|
+
sweep(root, now, () => true);
|
|
169
|
+
expect(fs.existsSync(path.join(root, `${addr}.json`))).toBe(false);
|
|
170
|
+
expect(fs.existsSync(path.join(root, `${addr}.acks`))).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
it('uses the platform directory-removal flag for descriptor-relative cleanup', () => {
|
|
173
|
+
const root = fixture();
|
|
174
|
+
const handle = openRelayRoot(root);
|
|
175
|
+
try {
|
|
176
|
+
handle.openDirectory('empty', true).close();
|
|
177
|
+
expect(handle.removeEmptyDirectory('empty')).toBe(true);
|
|
178
|
+
expect(fs.existsSync(path.join(root, 'empty'))).toBe(false);
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
handle.close();
|
|
182
|
+
}
|
|
183
|
+
});
|
package/dist/registry.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import * as crypto from 'node:crypto';
|
|
18
18
|
import * as fs from 'node:fs';
|
|
19
19
|
import * as path from 'node:path';
|
|
20
|
+
import { sweepAcknowledgements } from './mailbox.js';
|
|
20
21
|
import { assertPathSegment, openRelayRoot, rethrowFilesystemError, RelayDirectoryHandle, RelayFilesystemError, } from './filesystem.js';
|
|
21
22
|
export const HEARTBEAT_STALE_MS = 45_000;
|
|
22
23
|
/** A mailbox holding undelivered mail is kept this long after last contact. */
|
|
@@ -405,12 +406,17 @@ function removeClaims(directory, addr) {
|
|
|
405
406
|
*/
|
|
406
407
|
export function sweep(root, now = Date.now(), sessionExists) {
|
|
407
408
|
for (const record of listRecords(root)) {
|
|
409
|
+
const retainedAcknowledgements = sweepAcknowledgements(root, record.addr, now, SWEEP_MAIL_KEEP_MS);
|
|
408
410
|
if (presenceOf(record, now) !== 'offline')
|
|
409
411
|
continue;
|
|
410
412
|
const hasMail = dirHasJson(root, `${record.addr}.inbox`) ||
|
|
411
413
|
dirHasJson(root, `${record.addr}.asks`) ||
|
|
412
414
|
claimsHaveJson(root, record.addr);
|
|
413
415
|
const expired = now - record.lastSeenAt >= SWEEP_MAIL_KEEP_MS;
|
|
416
|
+
// Recent proof keeps a resumable record around, but never past expiry —
|
|
417
|
+
// otherwise one unreadable ack would pin a dead session (and its alias) forever.
|
|
418
|
+
if (retainedAcknowledgements > 0 && !expired)
|
|
419
|
+
continue;
|
|
414
420
|
if (hasMail && !expired)
|
|
415
421
|
continue;
|
|
416
422
|
if (!expired && (sessionExists?.(record.sessionId) ?? true))
|
|
@@ -420,6 +426,7 @@ export function sweep(root, now = Date.now(), sessionExists) {
|
|
|
420
426
|
try {
|
|
421
427
|
relay.removeDirectory(`${record.addr}.inbox`);
|
|
422
428
|
relay.removeDirectory(`${record.addr}.asks`);
|
|
429
|
+
relay.removeDirectory(`${record.addr}.acks`);
|
|
423
430
|
removeClaims(relay, record.addr);
|
|
424
431
|
try {
|
|
425
432
|
relay.unlinkFile(`${record.addr}.json`);
|
package/dist/relay.test.js
CHANGED
|
@@ -7,7 +7,7 @@ import { createRequire, syncBuiltinESMExports } from 'node:module';
|
|
|
7
7
|
import * as os from 'node:os';
|
|
8
8
|
import * as path from 'node:path';
|
|
9
9
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
10
|
-
import { BOUNDARY_PREAMBLE, formatAudit, formatDelivery, formatListing
|
|
10
|
+
import { BOUNDARY_PREAMBLE, formatAudit, formatDelivery, formatListing } from './format.js';
|
|
11
11
|
import { ackClaimedLetter, appendAudit, auditLogPath, awaitReceipt, claimInbox, clearAsk, deposit, drain, pendingAsks, previewBody, readAudit, readClaimedLetter, readIncomingAsk, readOutgoingAsk, recoverInboxClaims, requeueClaimedLetter, resolveAskByRef, trackIncomingAsk, trackOutgoingAsk, unreadCount, watchInbox, } from './mailbox.js';
|
|
12
12
|
import { BACKLOG_CAP, OutboundPolicy, inboundAccepts } from './policy.js';
|
|
13
13
|
import { claimAlias, clearAlias, deriveAddr, ensureRoot, resolveRelayRoot, isValidAliasName, listAliases, listRecords, presenceOf, readAlias, readRecord, sweep, writeAlias, writeRecord, } from './registry.js';
|
|
@@ -179,14 +179,14 @@ describe('mailbox', () => {
|
|
|
179
179
|
deposit(root, addr, letter({ id: 'id-9', ts: 2000, body: 'good' }));
|
|
180
180
|
expect(drain(root, addr).map((l) => l.body)).toEqual(['good']);
|
|
181
181
|
});
|
|
182
|
-
it('receipt:
|
|
182
|
+
it('receipt: destructive drain leaves delivery uncertain, remaining letters stay queued', async () => {
|
|
183
183
|
const root = tmpRoot();
|
|
184
184
|
const addr = 'inbox0000003';
|
|
185
185
|
const l1 = letter({ id: 'rcpt-1', ts: Date.now() });
|
|
186
186
|
deposit(root, addr, l1);
|
|
187
187
|
const drained = drain(root, addr); // receiver takes it
|
|
188
188
|
expect(drained).toHaveLength(1);
|
|
189
|
-
expect(await awaitReceipt(root, addr, l1, 400)).toBe('
|
|
189
|
+
expect(await awaitReceipt(root, addr, l1, 400)).toBe('uncertain');
|
|
190
190
|
const l2 = letter({ id: 'rcpt-2', ts: Date.now() + 1 });
|
|
191
191
|
deposit(root, addr, l2);
|
|
192
192
|
expect(await awaitReceipt(root, addr, l2, 400)).toBe('queued');
|
|
@@ -610,10 +610,6 @@ describe('format', () => {
|
|
|
610
610
|
expect(text).not.toContain('self00000000');
|
|
611
611
|
expect(text).toContain('beta (peer00) — /tmp/alpha [idle]');
|
|
612
612
|
});
|
|
613
|
-
it('pins refusal strings', () => {
|
|
614
|
-
expect(refusalUnknown('nobody', ['"alpha" (aaaa11)'])).toContain('No session matches');
|
|
615
|
-
expect(refusalAmbiguous('al', ['"alpha" (aaaa11)', '"alpine" (bbbb22)'])).toContain('ambiguous');
|
|
616
|
-
});
|
|
617
613
|
});
|
|
618
614
|
describe('relay root resolution', () => {
|
|
619
615
|
// pi's home is commonly a dotfiles symlink (`~/.pi -> ~/Developer/dotfiles/home/.pi`).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Letter } from './mailbox.js';
|
|
2
|
+
import type { SessionRecord } from './registry.js';
|
|
3
|
+
/** Exact session IDs share the target namespace with names and addresses. Never guess collisions. */
|
|
4
|
+
export declare function resolveSessionTarget(to: string, records: SessionRecord[], selfAddr: string): {
|
|
5
|
+
record?: SessionRecord;
|
|
6
|
+
error?: string;
|
|
7
|
+
};
|
|
8
|
+
interface ReplyTarget {
|
|
9
|
+
id: string;
|
|
10
|
+
addr: string;
|
|
11
|
+
ask: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** Reconstruct ordinary reply targets from the active branch, not abandoned histories or process-local delivery state. */
|
|
14
|
+
export declare function resolveReplyTarget(reference: string, pending: Letter[], branch: readonly unknown[]): {
|
|
15
|
+
target?: ReplyTarget;
|
|
16
|
+
error?: string;
|
|
17
|
+
};
|
|
18
|
+
export {};
|
package/dist/routing.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Exact session IDs share the target namespace with names and addresses. Never guess collisions. */
|
|
2
|
+
export function resolveSessionTarget(to, records, selfAddr) {
|
|
3
|
+
const peers = records.filter((r) => r.addr !== selfAddr);
|
|
4
|
+
const exact = peers.filter((r) => r.sessionId === to || r.addr === to || r.name.toLowerCase() === to.toLowerCase());
|
|
5
|
+
const matches = exact.length ? exact : peers.filter((r) => r.addr.startsWith(to));
|
|
6
|
+
const label = (r) => `${JSON.stringify(r.name)} (address ${r.addr}, sessionId ${JSON.stringify(r.sessionId)})`;
|
|
7
|
+
if (!matches.length)
|
|
8
|
+
return { error: `No session matches ${JSON.stringify(to)}. Use list or list-cwd to discover peers.` };
|
|
9
|
+
if (matches.length > 1)
|
|
10
|
+
return {
|
|
11
|
+
error: `${JSON.stringify(to)} is ambiguous: ${matches.map(label).join(', ')}. Use the intended full address.`,
|
|
12
|
+
};
|
|
13
|
+
return { record: matches[0] };
|
|
14
|
+
}
|
|
15
|
+
const ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
|
|
16
|
+
const ADDRESS = /^[a-f0-9]{12}$/;
|
|
17
|
+
/** Reconstruct ordinary reply targets from the active branch, not abandoned histories or process-local delivery state. */
|
|
18
|
+
export function resolveReplyTarget(reference, pending, branch) {
|
|
19
|
+
if (!ID.test(reference))
|
|
20
|
+
return { error: 'replyTo must be a message ID or a nonempty unique prefix.' };
|
|
21
|
+
const candidates = pending.map((ask) => ({ id: ask.id, addr: ask.from.addr, ask: true }));
|
|
22
|
+
for (const entry of branch) {
|
|
23
|
+
if (typeof entry !== 'object' || entry === null)
|
|
24
|
+
continue;
|
|
25
|
+
const e = entry;
|
|
26
|
+
if (e.type !== 'custom_message' || e.customType !== 'relay:delivery')
|
|
27
|
+
continue;
|
|
28
|
+
const d = e.details;
|
|
29
|
+
if (typeof d !== 'object' || d === null)
|
|
30
|
+
continue;
|
|
31
|
+
const data = d;
|
|
32
|
+
if (data.kind !== 'message' && data.kind !== 'reply')
|
|
33
|
+
continue;
|
|
34
|
+
if (typeof data.id !== 'string' || !ID.test(data.id) || typeof data.from !== 'object' || data.from === null)
|
|
35
|
+
continue;
|
|
36
|
+
const addr = data.from.addr;
|
|
37
|
+
if (typeof addr !== 'string' || !ADDRESS.test(addr))
|
|
38
|
+
continue;
|
|
39
|
+
candidates.push({ id: data.id, addr, ask: false });
|
|
40
|
+
}
|
|
41
|
+
const matches = new Map();
|
|
42
|
+
const exact = candidates.filter((candidate) => candidate.id === reference);
|
|
43
|
+
for (const candidate of exact.length ? exact : candidates) {
|
|
44
|
+
if (!candidate.id.startsWith(reference))
|
|
45
|
+
continue;
|
|
46
|
+
const key = `${candidate.id}\0${candidate.addr}`;
|
|
47
|
+
matches.set(key, { ...candidate, ask: candidate.ask || matches.get(key)?.ask === true });
|
|
48
|
+
}
|
|
49
|
+
if (!matches.size)
|
|
50
|
+
return {
|
|
51
|
+
error: `No received message or pending ask matches ${JSON.stringify(reference)} in the active conversation. Use its full ID or list pending asks.`,
|
|
52
|
+
};
|
|
53
|
+
if (matches.size !== 1)
|
|
54
|
+
return {
|
|
55
|
+
error: `Reply ID ${JSON.stringify(reference)} is ambiguous. Use a longer prefix or full ID. Sender addresses: ${[...new Set([...matches.values()].map((m) => m.addr))].join(', ')}.`,
|
|
56
|
+
};
|
|
57
|
+
return { target: matches.values().next().value };
|
|
58
|
+
}
|
package/filesystem.ts
CHANGED
|
@@ -25,7 +25,7 @@ const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
|
25
25
|
const DIRECTORY = fs.constants.O_DIRECTORY ?? 0;
|
|
26
26
|
const CLOEXEC = process.platform === 'darwin' ? 0x01000000 : 0x00080000;
|
|
27
27
|
const READ_DIRECTORY_FLAGS = fs.constants.O_RDONLY | NOFOLLOW | DIRECTORY | CLOEXEC;
|
|
28
|
-
const AT_REMOVEDIR = 0x80;
|
|
28
|
+
const AT_REMOVEDIR = process.platform === 'darwin' ? 0x80 : 0x200;
|
|
29
29
|
|
|
30
30
|
function loadLibc(): ReturnType<typeof koffi.load> {
|
|
31
31
|
const candidates =
|
package/format.ts
CHANGED
|
@@ -51,14 +51,6 @@ export function formatPendingAsk(letter: Letter, now: number = Date.now()): stri
|
|
|
51
51
|
return `• ${letter.id.slice(0, 8)} from "${letter.from.name}" (${age(letter.ts, now)}): ${letter.body.replace(/\s+/g, ' ').slice(0, 80)}`;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
export function refusalUnknown(to: string, reachable: string[]): string {
|
|
55
|
-
return `No session matches '${to}'. Reachable: ${reachable.length > 0 ? reachable.join(', ') : '(none)'}.`;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function refusalAmbiguous(to: string, candidates: string[]): string {
|
|
59
|
-
return `'${to}' is ambiguous; matches: ${candidates.join(', ')}. Use a full name or address prefix.`;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
54
|
/** Render recent audit entries for `/relay log`. */
|
|
63
55
|
export function formatAudit(records: AuditRecord[], now: number = Date.now()): string {
|
|
64
56
|
if (records.length === 0) return 'No audit entries yet.';
|