@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.
@@ -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
  }
@@ -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
+ }