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