@animalabs/portal-client 0.1.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/dist/src/cache.d.ts +22 -0
- package/dist/src/cache.d.ts.map +1 -0
- package/dist/src/cache.js +69 -0
- package/dist/src/cache.js.map +1 -0
- package/dist/src/client.d.ts +93 -0
- package/dist/src/client.d.ts.map +1 -0
- package/dist/src/client.js +230 -0
- package/dist/src/client.js.map +1 -0
- package/dist/src/emitter.d.ts +8 -0
- package/dist/src/emitter.d.ts.map +1 -0
- package/dist/src/emitter.js +20 -0
- package/dist/src/emitter.js.map +1 -0
- package/dist/src/enroll.d.ts +47 -0
- package/dist/src/enroll.d.ts.map +1 -0
- package/dist/src/enroll.js +96 -0
- package/dist/src/enroll.js.map +1 -0
- package/dist/src/files.d.ts +10 -0
- package/dist/src/files.d.ts.map +1 -0
- package/dist/src/files.js +13 -0
- package/dist/src/files.js.map +1 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +13 -0
- package/dist/src/index.js.map +1 -0
- package/dist/test/enroll.test.d.ts +2 -0
- package/dist/test/enroll.test.d.ts.map +1 -0
- package/dist/test/enroll.test.js +75 -0
- package/dist/test/enroll.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +27 -0
- package/src/cache.ts +83 -0
- package/src/client.ts +281 -0
- package/src/emitter.ts +21 -0
- package/src/enroll.ts +126 -0
- package/src/files.ts +18 -0
- package/src/index.ts +14 -0
- package/test/enroll.test.ts +87 -0
- package/tsconfig.json +21 -0
package/src/emitter.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Minimal typed event emitter (no `any`, no Node EventEmitter dependency). */
|
|
2
|
+
export class TypedEmitter<Events extends Record<string, (...args: never[]) => void>> {
|
|
3
|
+
private listeners = new Map<keyof Events, Set<(...args: never[]) => void>>();
|
|
4
|
+
|
|
5
|
+
on<K extends keyof Events>(event: K, fn: Events[K]): () => void {
|
|
6
|
+
let set = this.listeners.get(event);
|
|
7
|
+
if (!set) this.listeners.set(event, (set = new Set()));
|
|
8
|
+
set.add(fn as (...args: never[]) => void);
|
|
9
|
+
return () => this.off(event, fn);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
off<K extends keyof Events>(event: K, fn: Events[K]): void {
|
|
13
|
+
this.listeners.get(event)?.delete(fn as (...args: never[]) => void);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
emit<K extends keyof Events>(event: K, ...args: Parameters<Events[K]>): void {
|
|
17
|
+
for (const fn of this.listeners.get(event) ?? []) {
|
|
18
|
+
(fn as (...a: Parameters<Events[K]>) => void)(...args);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/enroll.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-enrollment helpers.
|
|
3
|
+
*
|
|
4
|
+
* A brand-new agent with no persona/token opens a short-lived WS connection,
|
|
5
|
+
* presents an invite (an admin-minted access-rights template) plus a desired
|
|
6
|
+
* name, and receives minted credentials. The credentials are then persisted and
|
|
7
|
+
* used by a normal PortalClient `identify` (which also gives transport resume).
|
|
8
|
+
*
|
|
9
|
+
* This is deliberately separate from PortalClient: the client stays a pure
|
|
10
|
+
* identify/resume transport, and enrollment is a one-shot bootstrap.
|
|
11
|
+
*/
|
|
12
|
+
import { WebSocket } from 'ws';
|
|
13
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
14
|
+
import { dirname } from 'node:path';
|
|
15
|
+
import { PORTAL_PROTOCOL_VERSION, isServerFrame } from '@animalabs/portal-protocol';
|
|
16
|
+
|
|
17
|
+
export interface EnrollOptions {
|
|
18
|
+
url: string;
|
|
19
|
+
/** Invite code (access-rights template). */
|
|
20
|
+
invite: string;
|
|
21
|
+
/** Desired display name for the new persona. */
|
|
22
|
+
desiredName: string;
|
|
23
|
+
/** Optional avatar filename/URL. */
|
|
24
|
+
avatar?: string;
|
|
25
|
+
/** Timeout for the enroll handshake (default 15000ms). */
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
/** Provide a WebSocket impl (tests). Defaults to ws. */
|
|
28
|
+
wsFactory?: (url: string) => WebSocket;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PortalCredentials {
|
|
32
|
+
personaId: string;
|
|
33
|
+
token: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One-shot: open WS, register, return minted credentials, close. */
|
|
37
|
+
export function enroll(opts: EnrollOptions): Promise<PortalCredentials> {
|
|
38
|
+
const ws = opts.wsFactory ? opts.wsFactory(opts.url) : new WebSocket(opts.url);
|
|
39
|
+
const timeoutMs = opts.timeoutMs ?? 15_000;
|
|
40
|
+
return new Promise<PortalCredentials>((resolve, reject) => {
|
|
41
|
+
let settled = false;
|
|
42
|
+
const done = (fn: () => void) => {
|
|
43
|
+
if (settled) return;
|
|
44
|
+
settled = true;
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
try {
|
|
47
|
+
ws.close();
|
|
48
|
+
} catch {
|
|
49
|
+
/* ignore */
|
|
50
|
+
}
|
|
51
|
+
fn();
|
|
52
|
+
};
|
|
53
|
+
const timer = setTimeout(
|
|
54
|
+
() => done(() => reject(new Error('enroll timed out'))),
|
|
55
|
+
timeoutMs,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
ws.on('message', (data: Buffer | string) => {
|
|
59
|
+
let frame: unknown;
|
|
60
|
+
try {
|
|
61
|
+
frame = JSON.parse(data.toString());
|
|
62
|
+
} catch {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!isServerFrame(frame)) return;
|
|
66
|
+
const f = frame as { op: string; d?: Record<string, unknown> };
|
|
67
|
+
if (f.op === 'hello') {
|
|
68
|
+
ws.send(
|
|
69
|
+
JSON.stringify({
|
|
70
|
+
op: 'register',
|
|
71
|
+
d: {
|
|
72
|
+
protocolVersion: PORTAL_PROTOCOL_VERSION,
|
|
73
|
+
invite: opts.invite,
|
|
74
|
+
desiredName: opts.desiredName,
|
|
75
|
+
...(opts.avatar ? { avatar: opts.avatar } : {}),
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
} else if (f.op === 'registered') {
|
|
80
|
+
const d = f.d as { personaId: string; token: string };
|
|
81
|
+
done(() => resolve({ personaId: d.personaId, token: d.token }));
|
|
82
|
+
} else if (f.op === 'invalid_session') {
|
|
83
|
+
const reason = (f.d as { reason?: string })?.reason ?? 'rejected';
|
|
84
|
+
done(() => reject(new Error(`enroll rejected: ${reason}`)));
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
ws.on('error', (err: Error) => done(() => reject(err)));
|
|
88
|
+
ws.on('close', () => done(() => reject(new Error('connection closed before register'))));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Load persisted credentials, or enroll once and persist them. Idempotent: the
|
|
94
|
+
* first run of a new agent enrolls and writes `credsPath`; every run after just
|
|
95
|
+
* reads it. Returns the credentials to hand to a PortalClient.
|
|
96
|
+
*/
|
|
97
|
+
export async function loadOrEnrollCreds(opts: {
|
|
98
|
+
url: string;
|
|
99
|
+
/** Where minted creds are cached (JSON: { personaId, token }). */
|
|
100
|
+
credsPath: string;
|
|
101
|
+
/** Required only when no creds exist yet. */
|
|
102
|
+
invite?: string;
|
|
103
|
+
desiredName?: string;
|
|
104
|
+
avatar?: string;
|
|
105
|
+
wsFactory?: (url: string) => WebSocket;
|
|
106
|
+
}): Promise<PortalCredentials> {
|
|
107
|
+
if (existsSync(opts.credsPath)) {
|
|
108
|
+
const raw = JSON.parse(readFileSync(opts.credsPath, 'utf8')) as Partial<PortalCredentials>;
|
|
109
|
+
if (raw.personaId && raw.token) return { personaId: raw.personaId, token: raw.token };
|
|
110
|
+
}
|
|
111
|
+
if (!opts.invite || !opts.desiredName) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`no saved credentials at ${opts.credsPath} and no invite/desiredName to enroll with`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const creds = await enroll({
|
|
117
|
+
url: opts.url,
|
|
118
|
+
invite: opts.invite,
|
|
119
|
+
desiredName: opts.desiredName,
|
|
120
|
+
avatar: opts.avatar,
|
|
121
|
+
wsFactory: opts.wsFactory,
|
|
122
|
+
});
|
|
123
|
+
mkdirSync(dirname(opts.credsPath), { recursive: true });
|
|
124
|
+
writeFileSync(opts.credsPath, JSON.stringify(creds, null, 2) + '\n', { mode: 0o600 });
|
|
125
|
+
return creds;
|
|
126
|
+
}
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { OutgoingFile } from '@animalabs/portal-protocol';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build an `OutgoingFile` from in-memory bytes (the portable way to attach a
|
|
5
|
+
* file — works from any client/host, no relay-side filesystem access).
|
|
6
|
+
*/
|
|
7
|
+
export function fileFromBytes(
|
|
8
|
+
name: string,
|
|
9
|
+
data: Buffer | Uint8Array,
|
|
10
|
+
opts?: { contentType?: string; description?: string },
|
|
11
|
+
): OutgoingFile {
|
|
12
|
+
return {
|
|
13
|
+
name,
|
|
14
|
+
bytes: Buffer.from(data).toString('base64'),
|
|
15
|
+
...(opts?.contentType ? { contentType: opts.contentType } : {}),
|
|
16
|
+
...(opts?.description ? { description: opts.description } : {}),
|
|
17
|
+
};
|
|
18
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @animalabs/portal-client
|
|
3
|
+
*
|
|
4
|
+
* General-purpose client for the portal relay: WS transport, client-side cache,
|
|
5
|
+
* typed RPC, and transport-level reconnect/resume. No agent semantics — that's
|
|
6
|
+
* portal-mcpl's job.
|
|
7
|
+
*/
|
|
8
|
+
export { PortalClient } from './client.js';
|
|
9
|
+
export type { PortalClientOptions, PortalClientEvents } from './client.js';
|
|
10
|
+
export { ClientCache } from './cache.js';
|
|
11
|
+
export { TypedEmitter } from './emitter.js';
|
|
12
|
+
export { enroll, loadOrEnrollCreds } from './enroll.js';
|
|
13
|
+
export type { EnrollOptions, PortalCredentials } from './enroll.js';
|
|
14
|
+
export { fileFromBytes } from './files.js';
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { WebSocketServer } from 'ws';
|
|
4
|
+
import { mkdtempSync, existsSync, readFileSync, rmSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { enroll, loadOrEnrollCreds } from '../src/enroll.js';
|
|
8
|
+
|
|
9
|
+
const PORT = 8795;
|
|
10
|
+
|
|
11
|
+
/** Minimal relay stand-in: sends `hello`, answers `register` with `registered`
|
|
12
|
+
* (or `invalid_session` for invite "bad"). Counts how many registers it saw. */
|
|
13
|
+
function fakeRelay(port: number) {
|
|
14
|
+
let registers = 0;
|
|
15
|
+
const wss = new WebSocketServer({ port, host: '127.0.0.1' });
|
|
16
|
+
wss.on('connection', (ws) => {
|
|
17
|
+
ws.send(JSON.stringify({ op: 'hello', d: { protocolVersion: 1, heartbeatIntervalMs: 30000 } }));
|
|
18
|
+
ws.on('message', (data) => {
|
|
19
|
+
const frame = JSON.parse(data.toString());
|
|
20
|
+
if (frame.op !== 'register') return;
|
|
21
|
+
registers++;
|
|
22
|
+
if (frame.d.invite === 'bad') {
|
|
23
|
+
ws.send(JSON.stringify({ op: 'invalid_session', d: { resumable: false, reason: 'invite unknown' } }));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
ws.send(
|
|
27
|
+
JSON.stringify({
|
|
28
|
+
op: 'registered',
|
|
29
|
+
d: {
|
|
30
|
+
personaId: `minted-${registers}`,
|
|
31
|
+
token: `tok-${registers}`,
|
|
32
|
+
persona: { id: `minted-${registers}`, displayName: frame.d.desiredName, avatarUrl: '' },
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
return {
|
|
39
|
+
url: `ws://127.0.0.1:${port}`,
|
|
40
|
+
registers: () => registers,
|
|
41
|
+
close: () => new Promise<void>((r) => wss.close(() => r())),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('enroll: returns minted creds; rejects bad invite', async () => {
|
|
46
|
+
const relay = fakeRelay(PORT);
|
|
47
|
+
try {
|
|
48
|
+
const creds = await enroll({ url: relay.url, invite: 'good', desiredName: 'Claude Code' });
|
|
49
|
+
assert.equal(creds.personaId, 'minted-1');
|
|
50
|
+
assert.equal(creds.token, 'tok-1');
|
|
51
|
+
|
|
52
|
+
await assert.rejects(
|
|
53
|
+
() => enroll({ url: relay.url, invite: 'bad', desiredName: 'x' }),
|
|
54
|
+
/invite unknown/,
|
|
55
|
+
);
|
|
56
|
+
} finally {
|
|
57
|
+
await relay.close();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('loadOrEnrollCreds: enrolls once then reuses the cached file', async () => {
|
|
62
|
+
const relay = fakeRelay(PORT + 1);
|
|
63
|
+
const dir = mkdtempSync(join(tmpdir(), 'portal-creds-'));
|
|
64
|
+
const credsPath = join(dir, 'nested', 'creds.json');
|
|
65
|
+
try {
|
|
66
|
+
const a = await loadOrEnrollCreds({ url: relay.url, credsPath, invite: 'good', desiredName: 'cc' });
|
|
67
|
+
assert.equal(relay.registers(), 1);
|
|
68
|
+
assert.ok(existsSync(credsPath));
|
|
69
|
+
|
|
70
|
+
// second call: no new register, returns the same persisted creds
|
|
71
|
+
const b = await loadOrEnrollCreds({ url: relay.url, credsPath, invite: 'good', desiredName: 'cc' });
|
|
72
|
+
assert.equal(relay.registers(), 1);
|
|
73
|
+
assert.deepEqual(a, b);
|
|
74
|
+
|
|
75
|
+
const onDisk = JSON.parse(readFileSync(credsPath, 'utf8'));
|
|
76
|
+
assert.equal(onDisk.personaId, a.personaId);
|
|
77
|
+
|
|
78
|
+
// without saved creds and without an invite → error
|
|
79
|
+
await assert.rejects(
|
|
80
|
+
() => loadOrEnrollCreds({ url: relay.url, credsPath: join(dir, 'absent.json') }),
|
|
81
|
+
/no saved credentials/,
|
|
82
|
+
);
|
|
83
|
+
} finally {
|
|
84
|
+
rmSync(dir, { recursive: true, force: true });
|
|
85
|
+
await relay.close();
|
|
86
|
+
}
|
|
87
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": ".",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"declarationMap": true,
|
|
15
|
+
"sourceMap": true,
|
|
16
|
+
"composite": true
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*", "test/**/*"],
|
|
19
|
+
"exclude": ["node_modules", "dist"],
|
|
20
|
+
"references": [{ "path": "../portal-protocol" }]
|
|
21
|
+
}
|