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