@nicknisi/pi-relay 0.1.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 +42 -6
- 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/format.d.ts +3 -1
- package/dist/format.js +13 -0
- package/dist/index.js +175 -46
- package/dist/mailbox.d.ts +40 -3
- package/dist/mailbox.js +655 -95
- package/dist/policy.d.ts +5 -3
- package/dist/policy.js +9 -6
- package/dist/registry.d.ts +16 -0
- package/dist/registry.js +301 -45
- package/dist/relay.test.js +349 -4
- package/filesystem.ts +635 -0
- package/format.ts +14 -1
- package/index.ts +185 -40
- package/mailbox.ts +613 -91
- package/package.json +19 -1
- package/policy.ts +9 -6
- package/registry.ts +275 -42
package/dist/mailbox.js
CHANGED
|
@@ -9,22 +9,73 @@
|
|
|
9
9
|
* - A corrupt letter is discarded on read, so it cannot poison every drain.
|
|
10
10
|
* - Consumption is the receipt: the sender learns "delivered" only when the
|
|
11
11
|
* letter actually disappeared from the target's inbox.
|
|
12
|
+
* - Every deposit and every delivery appends one append-only audit line —
|
|
13
|
+
* drain-as-receipt must not destroy evidence. The log never holds a full
|
|
14
|
+
* body, only a short preview.
|
|
12
15
|
*/
|
|
13
|
-
import
|
|
16
|
+
import { randomBytes } from 'node:crypto';
|
|
14
17
|
import * as path from 'node:path';
|
|
15
|
-
import {
|
|
18
|
+
import { assertPathSegment, openRelayRoot, rethrowFilesystemError, RelayDirectoryHandle, RelayFilesystemError, } from './filesystem.js';
|
|
16
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}$/;
|
|
17
23
|
function letterFileName(letter) {
|
|
18
|
-
|
|
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;
|
|
19
39
|
}
|
|
20
|
-
|
|
40
|
+
function assertInboxClaimToken(claimToken) {
|
|
41
|
+
if (!INBOX_CLAIM_TOKEN_PATTERN.test(claimToken)) {
|
|
42
|
+
throw new TypeError(`Invalid relay inbox claim token: ${claimToken}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
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. */
|
|
21
51
|
export function deposit(root, toAddr, letter) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
+
}
|
|
28
79
|
}
|
|
29
80
|
/**
|
|
30
81
|
* Take every letter in the inbox, oldest first. Each letter is unlinked as
|
|
@@ -33,52 +84,98 @@ export function deposit(root, toAddr, letter) {
|
|
|
33
84
|
* caller must re-deposit any letter it fails to deliver (checkInbox does).
|
|
34
85
|
*/
|
|
35
86
|
export function drain(root, addr) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
names = fs
|
|
40
|
-
.readdirSync(dir)
|
|
41
|
-
.filter((f) => f.endsWith('.json'))
|
|
42
|
-
.sort();
|
|
43
|
-
}
|
|
44
|
-
catch {
|
|
87
|
+
assertPathSegment(addr, 'relay address');
|
|
88
|
+
const relay = openRelayRoot(root);
|
|
89
|
+
if (relay === null)
|
|
45
90
|
return [];
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
let raw;
|
|
51
|
-
try {
|
|
52
|
-
raw = fs.readFileSync(file, 'utf8');
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
57
|
-
try {
|
|
58
|
-
fs.unlinkSync(file);
|
|
59
|
-
}
|
|
60
|
-
catch {
|
|
61
|
-
continue; // another drainer took it
|
|
62
|
-
}
|
|
91
|
+
try {
|
|
92
|
+
const inbox = relay.openDirectory(`${addr}.inbox`);
|
|
93
|
+
if (inbox === null)
|
|
94
|
+
return [];
|
|
63
95
|
try {
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
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
|
+
}
|
|
67
135
|
}
|
|
136
|
+
return out;
|
|
68
137
|
}
|
|
69
|
-
|
|
70
|
-
|
|
138
|
+
finally {
|
|
139
|
+
inbox.close();
|
|
71
140
|
}
|
|
72
141
|
}
|
|
73
|
-
|
|
142
|
+
catch (error) {
|
|
143
|
+
rethrowFilesystemError(error);
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
relay.close();
|
|
148
|
+
}
|
|
74
149
|
}
|
|
75
150
|
export function unreadCount(root, addr) {
|
|
151
|
+
assertPathSegment(addr, 'relay address');
|
|
152
|
+
const relay = openRelayRoot(root);
|
|
153
|
+
if (relay === null)
|
|
154
|
+
return 0;
|
|
76
155
|
try {
|
|
77
|
-
|
|
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
|
+
}
|
|
78
171
|
}
|
|
79
|
-
catch {
|
|
172
|
+
catch (error) {
|
|
173
|
+
rethrowFilesystemError(error);
|
|
80
174
|
return 0;
|
|
81
175
|
}
|
|
176
|
+
finally {
|
|
177
|
+
relay.close();
|
|
178
|
+
}
|
|
82
179
|
}
|
|
83
180
|
/**
|
|
84
181
|
* Watch an inbox for new mail. fs.watch with a 3s poll fallback (watch can
|
|
@@ -87,108 +184,571 @@ export function unreadCount(root, addr) {
|
|
|
87
184
|
* a process alive.
|
|
88
185
|
*/
|
|
89
186
|
export function watchInbox(root, addr, onMail) {
|
|
90
|
-
|
|
91
|
-
|
|
187
|
+
assertPathSegment(addr, 'relay address');
|
|
188
|
+
let relay = null;
|
|
189
|
+
let inbox = null;
|
|
92
190
|
let watcher;
|
|
93
191
|
try {
|
|
94
|
-
|
|
192
|
+
relay = openRelayRoot(root, true);
|
|
193
|
+
inbox = relay.openDirectory(`${addr}.inbox`, true);
|
|
194
|
+
watcher = inbox.watch(onMail);
|
|
95
195
|
watcher.unref();
|
|
96
196
|
}
|
|
97
|
-
catch {
|
|
98
|
-
|
|
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
|
|
99
204
|
}
|
|
100
205
|
const poll = setInterval(onMail, 3000);
|
|
101
206
|
poll.unref();
|
|
102
207
|
return () => {
|
|
103
208
|
watcher?.close();
|
|
104
209
|
clearInterval(poll);
|
|
210
|
+
inbox?.close();
|
|
211
|
+
relay?.close();
|
|
105
212
|
};
|
|
106
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
|
+
}
|
|
107
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
|
+
}
|
|
108
473
|
/**
|
|
109
474
|
* Consumption receipt: after depositing to a LIVE target, wait briefly for
|
|
110
|
-
* the letter to vanish
|
|
111
|
-
*
|
|
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.
|
|
112
477
|
*/
|
|
113
478
|
export async function awaitReceipt(root, toAddr, letter, timeoutMs = 1500) {
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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();
|
|
120
495
|
}
|
|
121
|
-
return fs.existsSync(file) ? 'queued' : 'delivered';
|
|
122
496
|
}
|
|
123
|
-
function
|
|
124
|
-
|
|
497
|
+
function askFileName(askId) {
|
|
498
|
+
assertPathSegment(askId, 'relay ask id');
|
|
499
|
+
return `${askId}.json`;
|
|
125
500
|
}
|
|
126
|
-
function
|
|
127
|
-
|
|
501
|
+
function outAskFileName(askId) {
|
|
502
|
+
assertPathSegment(askId, 'relay ask id');
|
|
503
|
+
return `out-${askId}.json`;
|
|
128
504
|
}
|
|
129
|
-
function
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
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
|
+
}
|
|
134
520
|
}
|
|
135
521
|
export function trackIncomingAsk(root, addr, letter) {
|
|
136
|
-
|
|
522
|
+
writeAskJson(root, addr, askFileName(letter.id), letter);
|
|
137
523
|
}
|
|
138
524
|
export function trackOutgoingAsk(root, addr, out) {
|
|
139
|
-
|
|
525
|
+
writeAskJson(root, addr, outAskFileName(out.askId), out);
|
|
140
526
|
}
|
|
141
|
-
|
|
527
|
+
function readAskFile(root, addr, fileName) {
|
|
528
|
+
assertPathSegment(addr, 'relay address');
|
|
529
|
+
const relay = openRelayRoot(root);
|
|
530
|
+
if (relay === null)
|
|
531
|
+
return null;
|
|
142
532
|
try {
|
|
143
|
-
const
|
|
144
|
-
|
|
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
|
+
}
|
|
145
543
|
}
|
|
146
|
-
catch {
|
|
544
|
+
catch (error) {
|
|
545
|
+
rethrowFilesystemError(error);
|
|
147
546
|
return null;
|
|
148
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;
|
|
149
555
|
}
|
|
150
556
|
export function readOutgoingAsk(root, addr, askId) {
|
|
557
|
+
const parsed = readAskFile(root, addr, outAskFileName(askId));
|
|
558
|
+
return parsed && typeof parsed.toAddr === 'string' ? parsed : null;
|
|
559
|
+
}
|
|
560
|
+
/** Remove both sides of an ask id (incoming and/or outgoing). Idempotent. */
|
|
561
|
+
export function clearAsk(root, addr, askId) {
|
|
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;
|
|
570
|
+
try {
|
|
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
|
+
}
|
|
580
|
+
}
|
|
581
|
+
finally {
|
|
582
|
+
asks.close();
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
finally {
|
|
586
|
+
relay.close();
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
const AUDIT_PREVIEW_CHARS = 80;
|
|
590
|
+
/** Whitespace-collapsed body preview — never the full payload. */
|
|
591
|
+
export function previewBody(body) {
|
|
592
|
+
// Strip ANSI/CSI escapes — the preview is peer-controlled text that can
|
|
593
|
+
// reach a raw terminal via the non-UI /relay log print path.
|
|
594
|
+
// eslint-disable-next-line no-control-regex
|
|
595
|
+
return body
|
|
596
|
+
.replace(/\x1b\[[0-9;?]*[a-zA-Z]|\x1b./g, '')
|
|
597
|
+
.replace(/\s+/g, ' ')
|
|
598
|
+
.trim()
|
|
599
|
+
.slice(0, AUDIT_PREVIEW_CHARS);
|
|
600
|
+
}
|
|
601
|
+
export function auditLogPath(root) {
|
|
602
|
+
return path.join(root, 'audit.log');
|
|
603
|
+
}
|
|
604
|
+
/** Audit log cap: at ~1 MB the current log becomes audit.log.1 (single generation) and a fresh log starts. */
|
|
605
|
+
const AUDIT_MAX_BYTES = 1024 * 1024;
|
|
606
|
+
function appendAuditToDirectory(directory, record) {
|
|
151
607
|
try {
|
|
152
|
-
const
|
|
153
|
-
|
|
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');
|
|
154
612
|
}
|
|
155
613
|
catch {
|
|
156
|
-
|
|
614
|
+
// audit failure (including an unsafe path) never breaks the mail path
|
|
157
615
|
}
|
|
158
616
|
}
|
|
159
|
-
/**
|
|
160
|
-
export function
|
|
161
|
-
|
|
617
|
+
/** Append one audit record. Best-effort: never throws (audit must not break delivery). */
|
|
618
|
+
export function appendAudit(root, record) {
|
|
619
|
+
let relay = null;
|
|
620
|
+
try {
|
|
621
|
+
relay = openRelayRoot(root, true);
|
|
622
|
+
appendAuditToDirectory(relay, record);
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
// audit failure (including an unsafe path) never breaks the mail path
|
|
626
|
+
}
|
|
627
|
+
finally {
|
|
628
|
+
relay?.close();
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
/** Read the last `limit` audit entries (oldest-first within that tail). */
|
|
632
|
+
export function readAudit(root, limit = 50) {
|
|
633
|
+
const relay = openRelayRoot(root);
|
|
634
|
+
if (relay === null)
|
|
635
|
+
return [];
|
|
636
|
+
let raw;
|
|
637
|
+
try {
|
|
638
|
+
raw = relay.readFile('audit.log');
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
rethrowFilesystemError(error);
|
|
642
|
+
return [];
|
|
643
|
+
}
|
|
644
|
+
finally {
|
|
645
|
+
relay.close();
|
|
646
|
+
}
|
|
647
|
+
if (raw === null)
|
|
648
|
+
return [];
|
|
649
|
+
const out = [];
|
|
650
|
+
for (const line of raw.split('\n')) {
|
|
651
|
+
if (line.length === 0)
|
|
652
|
+
continue;
|
|
162
653
|
try {
|
|
163
|
-
|
|
654
|
+
const parsed = JSON.parse(line);
|
|
655
|
+
if (parsed && typeof parsed.ts === 'number' && typeof parsed.event === 'string')
|
|
656
|
+
out.push(parsed);
|
|
164
657
|
}
|
|
165
658
|
catch {
|
|
166
|
-
//
|
|
659
|
+
// skip corrupt line — append-only, never fatal
|
|
167
660
|
}
|
|
168
661
|
}
|
|
662
|
+
return out.slice(-limit);
|
|
663
|
+
}
|
|
664
|
+
/** Resolve a pending ask by explicit replyTo id or unique prefix. No inference. */
|
|
665
|
+
export function resolveAskByRef(root, addr, replyTo) {
|
|
666
|
+
if (!replyTo)
|
|
667
|
+
return null; // empty prefix matches every id — an explicit ref is required
|
|
668
|
+
return pendingAsks(root, addr).find((a) => a.id === replyTo || a.id.startsWith(replyTo)) ?? null;
|
|
169
669
|
}
|
|
170
670
|
/** Asks we have received and not yet answered, oldest first. */
|
|
171
671
|
export function pendingAsks(root, addr) {
|
|
172
|
-
|
|
672
|
+
assertPathSegment(addr, 'relay address');
|
|
673
|
+
const relay = openRelayRoot(root);
|
|
674
|
+
if (relay === null)
|
|
675
|
+
return [];
|
|
173
676
|
try {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
+
}
|
|
178
712
|
}
|
|
179
|
-
catch {
|
|
713
|
+
catch (error) {
|
|
714
|
+
rethrowFilesystemError(error);
|
|
180
715
|
return [];
|
|
181
716
|
}
|
|
182
|
-
|
|
183
|
-
|
|
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 [];
|
|
184
731
|
try {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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));
|
|
188
742
|
}
|
|
189
|
-
|
|
190
|
-
|
|
743
|
+
finally {
|
|
744
|
+
asks.close();
|
|
191
745
|
}
|
|
192
746
|
}
|
|
193
|
-
|
|
747
|
+
catch (error) {
|
|
748
|
+
rethrowFilesystemError(error);
|
|
749
|
+
return [];
|
|
750
|
+
}
|
|
751
|
+
finally {
|
|
752
|
+
relay.close();
|
|
753
|
+
}
|
|
194
754
|
}
|