@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/README.md +17 -3
- package/core.ts +314 -0
- package/dist/core.d.ts +32 -0
- package/dist/core.js +223 -0
- package/dist/core.test.d.ts +1 -0
- package/dist/core.test.js +358 -0
- package/dist/filesystem.d.ts +42 -0
- package/dist/filesystem.js +614 -0
- package/dist/index.js +105 -26
- package/dist/mailbox.d.ts +30 -8
- package/dist/mailbox.js +615 -126
- package/dist/registry.js +254 -70
- package/dist/relay.test.js +204 -2
- package/filesystem.ts +635 -0
- package/index.ts +101 -25
- package/mailbox.ts +557 -122
- package/package.json +19 -1
- package/registry.ts +213 -66
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
|
-
* -
|
|
8
|
-
* the
|
|
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
|
|
18
|
+
import { randomBytes } from 'node:crypto';
|
|
17
19
|
import * as path from 'node:path';
|
|
18
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
46
|
-
*
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
144
|
+
finally {
|
|
145
|
+
inbox.close();
|
|
84
146
|
}
|
|
85
147
|
}
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
193
|
+
assertPathSegment(addr, 'relay address');
|
|
194
|
+
let relay = null;
|
|
195
|
+
let inbox = null;
|
|
105
196
|
let watcher;
|
|
106
197
|
try {
|
|
107
|
-
|
|
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
|
-
|
|
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
|
|
124
|
-
*
|
|
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
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
137
|
-
|
|
503
|
+
function askFileName(askId) {
|
|
504
|
+
assertPathSegment(askId, 'relay ask id');
|
|
505
|
+
return `${askId}.json`;
|
|
138
506
|
}
|
|
139
|
-
function
|
|
140
|
-
|
|
507
|
+
function outAskFileName(askId) {
|
|
508
|
+
assertPathSegment(askId, 'relay ask id');
|
|
509
|
+
return `out-${askId}.json`;
|
|
141
510
|
}
|
|
142
|
-
function
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
528
|
+
writeAskJson(root, addr, askFileName(letter.id), letter);
|
|
150
529
|
}
|
|
151
530
|
export function trackOutgoingAsk(root, addr, out) {
|
|
152
|
-
|
|
531
|
+
writeAskJson(root, addr, outAskFileName(out.askId), out);
|
|
153
532
|
}
|
|
154
|
-
|
|
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
|
|
157
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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 =
|
|
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
|
-
|
|
678
|
+
assertPathSegment(addr, 'relay address');
|
|
679
|
+
const relay = openRelayRoot(root);
|
|
680
|
+
if (relay === null)
|
|
681
|
+
return [];
|
|
250
682
|
try {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
267
|
-
|
|
749
|
+
finally {
|
|
750
|
+
asks.close();
|
|
268
751
|
}
|
|
269
752
|
}
|
|
270
|
-
|
|
753
|
+
catch (error) {
|
|
754
|
+
rethrowFilesystemError(error);
|
|
755
|
+
return [];
|
|
756
|
+
}
|
|
757
|
+
finally {
|
|
758
|
+
relay.close();
|
|
759
|
+
}
|
|
271
760
|
}
|