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