@nicknisi/pi-relay 0.1.0 → 0.2.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 +25 -3
- package/dist/format.d.ts +3 -1
- package/dist/format.js +13 -0
- package/dist/index.js +174 -36
- package/dist/mailbox.d.ts +21 -0
- package/dist/mailbox.js +77 -0
- package/dist/policy.d.ts +5 -3
- package/dist/policy.js +9 -6
- package/dist/registry.d.ts +16 -0
- package/dist/registry.js +72 -0
- package/dist/relay.test.js +174 -3
- package/format.ts +14 -1
- package/index.ts +183 -31
- package/mailbox.ts +93 -0
- package/package.json +1 -1
- package/policy.ts +9 -6
- package/registry.ts +86 -0
package/dist/registry.d.ts
CHANGED
|
@@ -34,6 +34,22 @@ export declare function ensureRoot(root: string): void;
|
|
|
34
34
|
export declare function recordPath(root: string, addr: string): string;
|
|
35
35
|
export declare function inboxDir(root: string, addr: string): string;
|
|
36
36
|
export declare function asksDir(root: string, addr: string): string;
|
|
37
|
+
export interface AliasRecord {
|
|
38
|
+
name: string;
|
|
39
|
+
addr: string;
|
|
40
|
+
sessionId: string;
|
|
41
|
+
claimedAt: number;
|
|
42
|
+
}
|
|
43
|
+
export declare function aliasesDir(root: string): string;
|
|
44
|
+
export declare function aliasPath(root: string, name: string): string;
|
|
45
|
+
export declare function writeAlias(root: string, alias: AliasRecord): void;
|
|
46
|
+
/** Alias names: lowercase alnum start, then alnum/_/-, 1-32 chars. */
|
|
47
|
+
export declare function isValidAliasName(name: string): boolean;
|
|
48
|
+
export declare function readAlias(root: string, name: string): AliasRecord | null;
|
|
49
|
+
export declare function listAliases(root: string): AliasRecord[];
|
|
50
|
+
/** Last-claim-wins: overwrites any prior owner. */
|
|
51
|
+
export declare function claimAlias(root: string, name: string, addr: string, sessionId: string): AliasRecord;
|
|
52
|
+
export declare function clearAlias(root: string, name: string): void;
|
|
37
53
|
export declare function writeRecord(root: string, record: SessionRecord): void;
|
|
38
54
|
export declare function readRecord(root: string, addr: string): SessionRecord | null;
|
|
39
55
|
/** Read-only listing, oldest first. Never mutates anything. */
|
package/dist/registry.js
CHANGED
|
@@ -41,6 +41,71 @@ export function inboxDir(root, addr) {
|
|
|
41
41
|
export function asksDir(root, addr) {
|
|
42
42
|
return path.join(root, `${addr}.asks`);
|
|
43
43
|
}
|
|
44
|
+
export function aliasesDir(root) {
|
|
45
|
+
return path.join(root, 'aliases');
|
|
46
|
+
}
|
|
47
|
+
export function aliasPath(root, name) {
|
|
48
|
+
return path.join(aliasesDir(root), `${name}.json`);
|
|
49
|
+
}
|
|
50
|
+
export function writeAlias(root, alias) {
|
|
51
|
+
const file = aliasPath(root, alias.name);
|
|
52
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
53
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
54
|
+
fs.writeFileSync(tmp, JSON.stringify(alias, null, 2), { mode: 0o600 });
|
|
55
|
+
fs.renameSync(tmp, file);
|
|
56
|
+
}
|
|
57
|
+
/** Alias names: lowercase alnum start, then alnum/_/-, 1-32 chars. */
|
|
58
|
+
export function isValidAliasName(name) {
|
|
59
|
+
return /^[a-z0-9][a-z0-9_-]{0,31}$/.test(name);
|
|
60
|
+
}
|
|
61
|
+
export function readAlias(root, name) {
|
|
62
|
+
// The name can come from LLM-controlled tool input — never let it reach
|
|
63
|
+
// the filesystem unvalidated (e.g. '@../../../../tmp/x').
|
|
64
|
+
if (!isValidAliasName(name))
|
|
65
|
+
return null;
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(fs.readFileSync(aliasPath(root, name), 'utf8'));
|
|
68
|
+
if (parsed && typeof parsed.name === 'string' && typeof parsed.addr === 'string') {
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function listAliases(root) {
|
|
78
|
+
let entries;
|
|
79
|
+
try {
|
|
80
|
+
entries = fs.readdirSync(aliasesDir(root), { withFileTypes: true });
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
if (!entry.isFile() || !entry.name.endsWith('.json'))
|
|
88
|
+
continue;
|
|
89
|
+
const alias = readAlias(root, entry.name.slice(0, -'.json'.length));
|
|
90
|
+
if (alias)
|
|
91
|
+
out.push(alias);
|
|
92
|
+
}
|
|
93
|
+
return out.sort((a, b) => a.claimedAt - b.claimedAt);
|
|
94
|
+
}
|
|
95
|
+
/** Last-claim-wins: overwrites any prior owner. */
|
|
96
|
+
export function claimAlias(root, name, addr, sessionId) {
|
|
97
|
+
const alias = { name, addr, sessionId, claimedAt: Date.now() };
|
|
98
|
+
writeAlias(root, alias);
|
|
99
|
+
return alias;
|
|
100
|
+
}
|
|
101
|
+
export function clearAlias(root, name) {
|
|
102
|
+
try {
|
|
103
|
+
fs.unlinkSync(aliasPath(root, name));
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// already gone
|
|
107
|
+
}
|
|
108
|
+
}
|
|
44
109
|
export function writeRecord(root, record) {
|
|
45
110
|
ensureRoot(root);
|
|
46
111
|
const file = recordPath(root, record.addr);
|
|
@@ -146,4 +211,11 @@ export function sweep(root, now = Date.now(), sessionExists) {
|
|
|
146
211
|
// already gone
|
|
147
212
|
}
|
|
148
213
|
}
|
|
214
|
+
// Aliases are swept when the owning session dies: an alias whose record
|
|
215
|
+
// was just reaped (or never existed) is reclaimed. A resumable-but-offline
|
|
216
|
+
// session keeps its record, so it keeps its alias too.
|
|
217
|
+
for (const alias of listAliases(root)) {
|
|
218
|
+
if (readRecord(root, alias.addr) === null)
|
|
219
|
+
clearAlias(root, alias.name);
|
|
220
|
+
}
|
|
149
221
|
}
|
package/dist/relay.test.js
CHANGED
|
@@ -6,10 +6,10 @@ import * as fs from 'node:fs';
|
|
|
6
6
|
import * as os from 'node:os';
|
|
7
7
|
import * as path from 'node:path';
|
|
8
8
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
9
|
-
import { BOUNDARY_PREAMBLE, formatDelivery, formatListing, refusalAmbiguous, refusalUnknown } from './format.js';
|
|
10
|
-
import { awaitReceipt, clearAsk, deposit, drain, pendingAsks, readOutgoingAsk, trackIncomingAsk, trackOutgoingAsk, unreadCount, watchInbox, } from './mailbox.js';
|
|
9
|
+
import { BOUNDARY_PREAMBLE, formatAudit, formatDelivery, formatListing, refusalAmbiguous, refusalUnknown, } from './format.js';
|
|
10
|
+
import { appendAudit, auditLogPath, awaitReceipt, clearAsk, deposit, drain, pendingAsks, previewBody, readAudit, readOutgoingAsk, resolveAskByRef, trackIncomingAsk, trackOutgoingAsk, unreadCount, watchInbox, } from './mailbox.js';
|
|
11
11
|
import { BACKLOG_CAP, OutboundPolicy, inboundAccepts } from './policy.js';
|
|
12
|
-
import { deriveAddr, listRecords, presenceOf, sweep, writeRecord } from './registry.js';
|
|
12
|
+
import { claimAlias, clearAlias, deriveAddr, isValidAliasName, listAliases, listRecords, presenceOf, readAlias, readRecord, sweep, writeAlias, writeRecord, } from './registry.js';
|
|
13
13
|
const dirs = [];
|
|
14
14
|
function tmpRoot() {
|
|
15
15
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-relay-test-'));
|
|
@@ -179,6 +179,167 @@ describe('mailbox', () => {
|
|
|
179
179
|
expect(drain(root, addr).map((l) => l.body)).toEqual(['while you were out']);
|
|
180
180
|
});
|
|
181
181
|
});
|
|
182
|
+
describe('aliases', () => {
|
|
183
|
+
function alias(over = {}) {
|
|
184
|
+
return { name: 'ci', addr: 'aaaa1111bbbb', sessionId: 'sid-1', claimedAt: Date.now(), ...over };
|
|
185
|
+
}
|
|
186
|
+
it('claim is last-claim-wins and persists on disk across restart', () => {
|
|
187
|
+
const root = tmpRoot();
|
|
188
|
+
writeAlias(root, alias({ addr: 'old00000000', sessionId: 'old-sid' }));
|
|
189
|
+
expect(readAlias(root, 'ci')?.addr).toBe('old00000000');
|
|
190
|
+
// a new session claims the same name — overwrites
|
|
191
|
+
claimAlias(root, 'ci', 'new00000001', 'new-sid');
|
|
192
|
+
expect(readAlias(root, 'ci')?.addr).toBe('new00000001');
|
|
193
|
+
expect(listAliases(root).map((a) => a.name)).toEqual(['ci']);
|
|
194
|
+
});
|
|
195
|
+
it('sweep reaps an alias whose owning session record is gone', () => {
|
|
196
|
+
const root = tmpRoot();
|
|
197
|
+
// live owner keeps its alias
|
|
198
|
+
const live = record({ addr: 'live00000001', pid: process.pid });
|
|
199
|
+
writeRecord(root, live);
|
|
200
|
+
claimAlias(root, 'ci', live.addr, live.sessionId);
|
|
201
|
+
sweep(root);
|
|
202
|
+
expect(readAlias(root, 'ci')?.addr).toBe(live.addr);
|
|
203
|
+
// owner record reaped (offline, empty, not resumable) → alias reaped too
|
|
204
|
+
const dead = record({ addr: 'dead00000002', offline: true, pid: 2_000_000_010, sessionId: 'gone' });
|
|
205
|
+
writeRecord(root, dead);
|
|
206
|
+
claimAlias(root, 'dotfiles', dead.addr, dead.sessionId);
|
|
207
|
+
sweep(root, Date.now(), () => false); // nothing resumable
|
|
208
|
+
expect(readAlias(root, 'ci')?.addr).toBe(live.addr); // untouched
|
|
209
|
+
expect(readAlias(root, 'dotfiles')).toBeNull();
|
|
210
|
+
});
|
|
211
|
+
it('a resumable-but-offline owner keeps its alias (deliverable while down)', () => {
|
|
212
|
+
const root = tmpRoot();
|
|
213
|
+
const down = record({ addr: 'down00000003', offline: true, pid: 2_000_000_011, sessionId: 'can-resume' });
|
|
214
|
+
writeRecord(root, down);
|
|
215
|
+
claimAlias(root, 'ci', down.addr, down.sessionId);
|
|
216
|
+
sweep(root, Date.now(), (id) => id === 'can-resume');
|
|
217
|
+
expect(readAlias(root, 'ci')?.addr).toBe(down.addr);
|
|
218
|
+
});
|
|
219
|
+
it('clearAlias is idempotent', () => {
|
|
220
|
+
const root = tmpRoot();
|
|
221
|
+
writeAlias(root, alias());
|
|
222
|
+
clearAlias(root, 'ci');
|
|
223
|
+
clearAlias(root, 'ci'); // no throw
|
|
224
|
+
expect(readAlias(root, 'ci')).toBeNull();
|
|
225
|
+
expect(readRecord(root, 'aaaa1111bbbb')).toBeNull(); // sanity: readRecord on missing → null
|
|
226
|
+
});
|
|
227
|
+
it('alias names are validated — traversal never reaches the filesystem', () => {
|
|
228
|
+
const root = tmpRoot();
|
|
229
|
+
expect(isValidAliasName('ci')).toBe(true);
|
|
230
|
+
expect(isValidAliasName('dotfiles-2')).toBe(true);
|
|
231
|
+
expect(isValidAliasName('../../tmp/x')).toBe(false);
|
|
232
|
+
expect(isValidAliasName('..')).toBe(false);
|
|
233
|
+
expect(isValidAliasName('a/b')).toBe(false);
|
|
234
|
+
expect(isValidAliasName('UPPER')).toBe(false); // case-sensitive storage, lowercase-only names
|
|
235
|
+
expect(isValidAliasName('')).toBe(false);
|
|
236
|
+
// readAlias refuses invalid names outright (defense in depth behind resolveTarget)
|
|
237
|
+
expect(readAlias(root, '../../../../etc/passwd')).toBeNull();
|
|
238
|
+
expect(readAlias(root, 'UPPER')).toBeNull();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
describe('audit log', () => {
|
|
242
|
+
it('append-only: deposit writes one line, never the full body', () => {
|
|
243
|
+
const root = tmpRoot();
|
|
244
|
+
const addr = 'aud00000001';
|
|
245
|
+
const long = 'x'.repeat(500);
|
|
246
|
+
deposit(root, addr, letter({ id: 'a-1', body: long, ts: 1000 }));
|
|
247
|
+
const entries = readAudit(root, 100);
|
|
248
|
+
expect(entries).toHaveLength(1);
|
|
249
|
+
const e = entries[0];
|
|
250
|
+
expect(e.event).toBe('deposit');
|
|
251
|
+
expect(e.kind).toBe('message');
|
|
252
|
+
expect(e.from).toBe(letter().from.addr);
|
|
253
|
+
expect(e.to).toBe(addr);
|
|
254
|
+
expect(e.messageId).toBe('a-1');
|
|
255
|
+
// preview is bounded — the full 500-char body never lands in the log
|
|
256
|
+
expect(e.preview).toBe(previewBody(long));
|
|
257
|
+
expect(e.preview.length).toBeLessThan(long.length);
|
|
258
|
+
});
|
|
259
|
+
it('append-only survives corruption and returns a bounded tail', () => {
|
|
260
|
+
const root = tmpRoot();
|
|
261
|
+
for (let i = 0; i < 3; i++)
|
|
262
|
+
deposit(root, 'aud00000002', letter({ id: `a-${i}`, ts: 1000 + i }));
|
|
263
|
+
// a corrupt line must not poison reads (append-only, skip-on-parse-fail)
|
|
264
|
+
fs.appendFileSync(path.join(root, 'audit.log'), '{not json}\n');
|
|
265
|
+
const entries = readAudit(root, 2);
|
|
266
|
+
expect(entries).toHaveLength(2);
|
|
267
|
+
expect(entries.map((e) => e.messageId)).toEqual(['a-1', 'a-2']);
|
|
268
|
+
});
|
|
269
|
+
it('deliver and deposit both record (evidence survives drain-as-receipt)', () => {
|
|
270
|
+
const root = tmpRoot();
|
|
271
|
+
const addr = 'aud00000003';
|
|
272
|
+
deposit(root, addr, letter({ id: 'd-1', kind: 'ask', body: 'can I push?' }));
|
|
273
|
+
// drain consumes (unlinks) the letter, but the audit line stays
|
|
274
|
+
drain(root, addr);
|
|
275
|
+
const entries = readAudit(root);
|
|
276
|
+
expect(entries.filter((e) => e.event === 'deposit' && e.messageId === 'd-1')).toHaveLength(1);
|
|
277
|
+
// a deliver record is appended by index.ts deliver(); simulate it here:
|
|
278
|
+
appendAudit(root, {
|
|
279
|
+
ts: Date.now(),
|
|
280
|
+
event: 'deliver',
|
|
281
|
+
kind: 'ask',
|
|
282
|
+
from: letter().from.addr,
|
|
283
|
+
to: addr,
|
|
284
|
+
messageId: 'd-1',
|
|
285
|
+
preview: previewBody('can I push?'),
|
|
286
|
+
});
|
|
287
|
+
const after = readAudit(root).filter((e) => e.messageId === 'd-1');
|
|
288
|
+
expect(after.map((e) => e.event)).toEqual(['deposit', 'deliver']);
|
|
289
|
+
});
|
|
290
|
+
it('audit log rotates at the 1 MB cap, keeping one previous generation', () => {
|
|
291
|
+
const root = tmpRoot();
|
|
292
|
+
const big = 'y'.repeat(1024);
|
|
293
|
+
// ~1.1 MB of audit records
|
|
294
|
+
for (let i = 0; i < 1100; i++) {
|
|
295
|
+
appendAudit(root, {
|
|
296
|
+
ts: 1000 + i,
|
|
297
|
+
event: 'deposit',
|
|
298
|
+
kind: 'message',
|
|
299
|
+
from: 'from-addr',
|
|
300
|
+
to: 'to-addr',
|
|
301
|
+
messageId: `m-${i}`,
|
|
302
|
+
preview: big,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
const file = auditLogPath(root);
|
|
306
|
+
expect(fs.existsSync(`${file}.1`)).toBe(true); // rotated generation exists
|
|
307
|
+
expect(fs.statSync(file).size).toBeLessThan(1024 * 1024); // fresh log is small
|
|
308
|
+
expect(readAudit(root, 10).length).toBeGreaterThan(0); // still readable after rotation
|
|
309
|
+
});
|
|
310
|
+
it('previewBody strips ANSI/CSI escapes (peer-controlled text reaches raw terminals)', () => {
|
|
311
|
+
expect(previewBody('hello \x1b[31mred\x1b[0m world')).toBe('hello red world');
|
|
312
|
+
expect(previewBody('\x1b[2J\x1b[Hbye')).toBe('bye');
|
|
313
|
+
});
|
|
314
|
+
it('resolveAskByRef matches by id or unique prefix, never by inference', () => {
|
|
315
|
+
const root = tmpRoot();
|
|
316
|
+
const addr = 'ask00000001';
|
|
317
|
+
const a1 = letter({ id: 'aaaa1111-0000-0000-0000-000000000001', kind: 'ask' });
|
|
318
|
+
const a2 = letter({ id: 'bbbb2222-0000-0000-0000-000000000002', kind: 'ask' });
|
|
319
|
+
trackIncomingAsk(root, addr, a1);
|
|
320
|
+
trackIncomingAsk(root, addr, a2);
|
|
321
|
+
expect(resolveAskByRef(root, addr, a1.id)?.id).toBe(a1.id); // exact id
|
|
322
|
+
expect(resolveAskByRef(root, addr, 'bbbb2222')?.id).toBe(a2.id); // prefix
|
|
323
|
+
expect(resolveAskByRef(root, addr, 'nope')).toBeNull(); // no match
|
|
324
|
+
// one pending ask alone is NOT implicitly the reply target — caller must pass its ref
|
|
325
|
+
expect(resolveAskByRef(root, addr, '')).toBeNull();
|
|
326
|
+
});
|
|
327
|
+
it('formatAudit renders entries and handles the empty case', () => {
|
|
328
|
+
expect(formatAudit([])).toContain('No audit entries');
|
|
329
|
+
const root = tmpRoot();
|
|
330
|
+
deposit(root, 'aud00000004', letter({ id: 'f-1', kind: 'message', body: 'hello world' }));
|
|
331
|
+
const text = formatAudit(readAudit(root));
|
|
332
|
+
expect(text).toContain('deposit');
|
|
333
|
+
expect(text).toContain('f-1'.slice(0, 8));
|
|
334
|
+
expect(text).toContain('hello world'); // preview present
|
|
335
|
+
// a long body is only present as its bounded preview
|
|
336
|
+
const root2 = tmpRoot();
|
|
337
|
+
const long = 'A'.repeat(500);
|
|
338
|
+
deposit(root2, 'aud00000005', letter({ id: 'f-2', body: long }));
|
|
339
|
+
const text2 = formatAudit(readAudit(root2));
|
|
340
|
+
expect(text2).not.toContain(long);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
182
343
|
describe('inbound refuse', () => {
|
|
183
344
|
it('refused mail is never consumed, so receipts honestly read queued', async () => {
|
|
184
345
|
expect(inboundAccepts('refuse')).toBe(false);
|
|
@@ -209,6 +370,16 @@ describe('policy', () => {
|
|
|
209
370
|
now += 11_000;
|
|
210
371
|
expect(p.check('same', 0).ok).toBe(true);
|
|
211
372
|
});
|
|
373
|
+
it('dedupe is per-target: a broadcast of one body reaches distinct peers', () => {
|
|
374
|
+
const p = new OutboundPolicy();
|
|
375
|
+
expect(p.check('main moved', 0, 'peerA').ok).toBe(true);
|
|
376
|
+
p.recordSend('main moved', 'peerA');
|
|
377
|
+
// same body, different peer → not deduped (loop-breaking is per-peer)
|
|
378
|
+
expect(p.check('main moved', 0, 'peerB').ok).toBe(true);
|
|
379
|
+
p.recordSend('main moved', 'peerB');
|
|
380
|
+
// same body, same peer again → still deduped inside the window
|
|
381
|
+
expect(p.check('main moved', 0, 'peerA').ok).toBe(false);
|
|
382
|
+
});
|
|
212
383
|
it('rate limits at 8 per 30s', () => {
|
|
213
384
|
const p = new OutboundPolicy();
|
|
214
385
|
for (let i = 0; i < 8; i++) {
|
package/format.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* pinned by tests; change them deliberately.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type { Letter } from './mailbox.js';
|
|
6
|
+
import type { AuditRecord, Letter } from './mailbox.js';
|
|
7
7
|
import type { Presence, SessionRecord } from './registry.js';
|
|
8
8
|
|
|
9
9
|
export const BOUNDARY_PREAMBLE =
|
|
@@ -58,3 +58,16 @@ export function refusalUnknown(to: string, reachable: string[]): string {
|
|
|
58
58
|
export function refusalAmbiguous(to: string, candidates: string[]): string {
|
|
59
59
|
return `'${to}' is ambiguous; matches: ${candidates.join(', ')}. Use a full name or address prefix.`;
|
|
60
60
|
}
|
|
61
|
+
|
|
62
|
+
/** Render recent audit entries for `/relay log`. */
|
|
63
|
+
export function formatAudit(records: AuditRecord[], now: number = Date.now()): string {
|
|
64
|
+
if (records.length === 0) return 'No audit entries yet.';
|
|
65
|
+
const head = `relay audit · ${records.length} entr${records.length === 1 ? 'y' : 'ies'}`;
|
|
66
|
+
const lines = [head];
|
|
67
|
+
for (const r of records) {
|
|
68
|
+
const when = age(r.ts, now);
|
|
69
|
+
const dir = `${shortAddr(r.from)} → ${shortAddr(r.to)}`;
|
|
70
|
+
lines.push(`• ${r.event} ${r.kind} ${dir} id ${r.messageId.slice(0, 8)} ${when} ${r.preview}`);
|
|
71
|
+
}
|
|
72
|
+
return lines.join('\n');
|
|
73
|
+
}
|