@volter/teams 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.
@@ -0,0 +1,397 @@
1
+ // The machine daemon's neutral core (company RFC 0008 §5, §6): this OS user's
2
+ // machine identity, its account log and bounds, the owner-only local door, the
3
+ // outbound link to a Teams server with admission re-derived from the machine's
4
+ // own synced grants, and the doors other products serve behind it (products.mjs).
5
+ // Everything a stream does is a door registered by name (`registerDoor`); the
6
+ // harness registers its own (machine.mjs). A stream no door claims is refused.
7
+ //
8
+ // It decides nothing on anyone's behalf. Every act is logged.
9
+ import { createServer, connect as netConnect } from 'node:net';
10
+ import { EventEmitter } from 'node:events';
11
+ import { existsSync, mkdirSync, rmSync, chmodSync } from 'node:fs';
12
+ import { randomBytes } from 'node:crypto';
13
+ import { WebSocket, createWebSocketStream } from 'ws';
14
+ import { Channel, NODE_PROTOCOL } from './channel.mjs';
15
+ import { holds, admitFromGrants, LOCAL_OPERATOR } from './admission.mjs';
16
+ import { findProduct, DAEMON_KINDS, PRODUCT_MAX_MESSAGE, productProof, sameSecret } from './products.mjs';
17
+ import { loadOrCreateKeyPair, publicKeyId } from './identity.mjs';
18
+ import { teamsHome, paths, writeJson, readLimits, AccountLog, ensureSocketDirectory } from './home.mjs';
19
+
20
+ export const MACHINE_VERSION = 2;
21
+ const LINK_BACKOFF_MIN_MS = 1_000;
22
+ const LINK_BACKOFF_MAX_MS = 30_000;
23
+ const LINK_PROTOCOL = 'supercode-machine-link.v1';
24
+ /** The capabilities a product door can be told a caller holds; `operate` stays the daemon's. */
25
+ const PRODUCT_CAPS = Object.freeze(['view', 'observe', 'interact', 'control', 'files', 'ports']);
26
+ /** How often a server-forwarded product stream is re-admitted, so a revoked grant stops it. */
27
+ const PRODUCT_READMIT_MS = 15_000;
28
+ /** How long a product has to accept or refuse an open. */
29
+ const PRODUCT_OPEN_TIMEOUT_MS = 10_000;
30
+ /** How long a re-admission waits on the server for fresh grants before deciding from the ones it has. */
31
+ const PRODUCT_REFRESH_TIMEOUT_MS = 5_000;
32
+
33
+ export class MachineCore extends EventEmitter {
34
+ /**
35
+ * `link`: { server, teamId, machineId, credential, incarnationId, configuration } to hold the
36
+ * server link; `configuration()` resolves the machine's current configuration from the server
37
+ * (custodian and machine grants). Without it the daemon serves its local door only.
38
+ */
39
+ constructor({ home = teamsHome(), floor = null, limits = null, env = process.env, link = null } = {}) {
40
+ super();
41
+ this.home = home;
42
+ this.p = paths(home);
43
+ // limits.json is read once, here: a daemon's bounds are what it started with.
44
+ this.limits = { ...readLimits(this.p.limits), ...(limits ?? {}) };
45
+ // The protocol floor this Machine speaks to: a caller below it is refused, floor named.
46
+ this.floor = floor ?? this.limits.protocolFloor;
47
+ this.socketPath = this.p.socket;
48
+ this.env = env;
49
+ this.identity = loadOrCreateKeyPair(this.p.machineKey);
50
+ this.id = publicKeyId(this.identity.publicKeyPem);
51
+ this.log = new AccountLog(this.p.log, { rotateBytes: this.limits.logRotateBytes, keep: this.limits.logKeep });
52
+ this.connections = new Map(); // connection index → { channel, admitted, principal, ... }
53
+ this.nextConnection = 1;
54
+ this.server = null;
55
+ this.address = null;
56
+ this.link = link;
57
+ this.linkSocket = null;
58
+ this.linkChannel = null;
59
+ this.linkBackoff = LINK_BACKOFF_MIN_MS;
60
+ this.linkTimer = null;
61
+ // The machine's grants as the server last reported them: id → { id, subject_id, caps, targets, expires_at }.
62
+ this.linkGrants = new Map();
63
+ this.custodianId = null;
64
+ this.stopping = false;
65
+ this.doors = new Map(); // stream name → (entry, stream, accept, refuse, index) => void
66
+ this.registerDoor('product', (entry, stream, accept, refuse) => this.#serveProduct(entry, stream, accept, refuse));
67
+ }
68
+
69
+ /** Serve streams named `name` with `door(entry, stream, accept, refuse, index)`. */
70
+ registerDoor(name, door) {
71
+ if (this.doors.has(name)) throw new Error(`the ${name} door is already registered`);
72
+ this.doors.set(name, door);
73
+ }
74
+
75
+ /** Before the local door opens: a product's own setup. */
76
+ async beforeListen() {}
77
+ /** After the local door and link close: a product's own teardown. */
78
+ async afterStop() {}
79
+ /** A connection closed: a product drops what it routed to it. */
80
+ connectionClosed(_index) {}
81
+
82
+ // ---- lifecycle -----------------------------------------------------------
83
+
84
+ async start() {
85
+ mkdirSync(this.home, { recursive: true, mode: 0o700 });
86
+ chmodSync(this.home, 0o700);
87
+ // A daemon already serving this home is found before anything of this one starts.
88
+ ensureSocketDirectory(this.socketPath);
89
+ await clearStaleSocket(this.socketPath);
90
+ await this.beforeListen();
91
+ this.server = createServer((socket) => this.#accept(socket));
92
+ await new Promise((resolveListen, reject) => {
93
+ this.server.once('error', reject);
94
+ this.server.listen(this.socketPath, () => { this.server.off('error', reject); resolveListen(); });
95
+ });
96
+ // Owner-only: the socket is the local door, and whoever opens it is this OS user.
97
+ chmodSync(this.socketPath, 0o600);
98
+ this.address = this.socketPath;
99
+ writeJson(this.p.machine, { pid: process.pid, id: this.id, socket: this.socketPath, startedAt: Date.now(), version: MACHINE_VERSION, protocol: NODE_PROTOCOL, floor: this.floor, server: this.link?.server ?? null });
100
+ this.emit('listening', this.socketPath);
101
+ if (this.link) {
102
+ await this.refreshLinkConfiguration().catch((error) => this.emit('link', { event: 'configuration-failed', error: error.message }));
103
+ this.#connectLink();
104
+ }
105
+ return this.socketPath;
106
+ }
107
+
108
+ async stop() {
109
+ this.stopping = true;
110
+ if (this.linkTimer) { clearTimeout(this.linkTimer); this.linkTimer = null; }
111
+ // A close frame, so the server sees a machine that left rather than one that vanished.
112
+ try { this.linkSocket?.close(1000, 'machine stopped'); } catch { this.linkSocket?.terminate(); }
113
+ this.linkSocket = null;
114
+ await this.beforeClose();
115
+ for (const { channel } of this.connections.values()) channel.close();
116
+ this.server?.close();
117
+ await this.afterStop();
118
+ rmSync(this.socketPath, { force: true });
119
+ if (existsSync(this.p.machine)) rmSync(this.p.machine);
120
+ }
121
+
122
+ /** Before the connections close: a product finishes or abandons what is in flight. */
123
+ async beforeClose() {}
124
+
125
+ // ---- the server link ----------------------------------------------------
126
+
127
+ /** Re-read the machine's custodian and grants from the server. */
128
+ async refreshLinkConfiguration() {
129
+ const configuration = await this.link.configuration();
130
+ this.custodianId = configuration.custodian_id ?? null;
131
+ this.linkGrants = new Map((configuration.machine_grants ?? []).map((grant) => [grant.id, grant]));
132
+ return configuration;
133
+ }
134
+
135
+ /**
136
+ * One outbound WebSocket to the server, re-dialled with backoff for as long as
137
+ * the daemon runs. The server opens a stream on it per stream a caller opens.
138
+ */
139
+ #connectLink() {
140
+ if (this.stopping || !this.link) return;
141
+ const { server, teamId, machineId, credential, incarnationId } = this.link;
142
+ const url = `${server.replace(/^http/, 'ws')}/api/v1/teams/${encodeURIComponent(teamId)}/machines/${encodeURIComponent(machineId)}/link`;
143
+ const socket = new WebSocket(url, LINK_PROTOCOL, { headers: { authorization: `Bearer ${credential}`, 'x-teams-incarnation': incarnationId }, maxPayload: 16 * 1024 * 1024 });
144
+ this.linkSocket = socket;
145
+ let settled = false;
146
+ const dropped = (error) => {
147
+ if (settled) return;
148
+ settled = true;
149
+ if (this.linkSocket === socket) { this.linkSocket = null; this.linkChannel = null; }
150
+ this.emit('link', { event: 'dropped', error: error?.message ?? null });
151
+ this.#retryLink();
152
+ };
153
+ socket.once('open', async () => {
154
+ try {
155
+ const channel = (await Channel.connect(createWebSocketStream(socket), { floor: this.floor })).keepAlive();
156
+ this.linkChannel = channel;
157
+ this.linkBackoff = LINK_BACKOFF_MIN_MS;
158
+ channel.on('stream', (stream, accept, refuse) => { void this.#onLinkStream(channel, stream, accept, refuse); });
159
+ channel.on('close', () => dropped(null));
160
+ channel.on('error', () => {});
161
+ this.emit('link', { event: 'up', server });
162
+ } catch (error) { socket.terminate(); dropped(error); }
163
+ });
164
+ socket.once('error', dropped);
165
+ socket.once('close', () => dropped(null));
166
+ }
167
+
168
+ #retryLink() {
169
+ if (this.stopping || !this.link || this.linkTimer) return;
170
+ const delay = this.linkBackoff;
171
+ this.linkBackoff = Math.min(this.linkBackoff * 2, LINK_BACKOFF_MAX_MS);
172
+ this.linkTimer = setTimeout(() => { this.linkTimer = null; this.#connectLink(); }, delay);
173
+ this.linkTimer.unref?.();
174
+ }
175
+
176
+ /** The admission a forwarded stream's claim earns here, refreshing the grants once when the claim names one we have not seen. */
177
+ async #admitClaim(claim) {
178
+ const admit = () => admitFromGrants(claim, { custodianId: this.custodianId, grants: this.linkGrants });
179
+ let admitted = admit();
180
+ const unknown = (claim?.grants ?? []).some((id) => !this.linkGrants.has(id));
181
+ const revisions = claim?.revisions && typeof claim.revisions === 'object' ? claim.revisions : null;
182
+ const stale = revisions ? (claim?.grants ?? []).some((id) => this.linkGrants.get(id)?.revision !== revisions[id]) : false;
183
+ if (!admitted || unknown || stale) {
184
+ await this.refreshLinkConfiguration().catch(() => {});
185
+ admitted = admit();
186
+ }
187
+ return admitted;
188
+ }
189
+
190
+ /** A stream the server forwarded for one caller: admitted per stream, from the claim it carries. */
191
+ async #onLinkStream(channel, stream, accept, refuse) {
192
+ const claim = stream.params?._admitted ?? null;
193
+ if (stream.params) delete stream.params._admitted;
194
+ const admitted = await this.#admitClaim(claim);
195
+ if (!admitted) {
196
+ this.log.append({ principal: claim?.principal ?? null, cap: stream.name, target: '*', outcome: 'refused', detail: 'no machine grant' });
197
+ return refuse('no machine grant for this caller');
198
+ }
199
+ const index = this.nextConnection++;
200
+ const entry = { channel, admitted, principal: claim.principal, verify: () => this.#admitClaim(claim), localAdmission: () => admitFromGrants(claim, { custodianId: this.custodianId, grants: this.linkGrants }) };
201
+ this.connections.set(index, entry);
202
+ stream.on('close', () => { this.connections.delete(index); this.connectionClosed(index); });
203
+ this.log.append({ principal: entry.principal, cap: 'connect', target: '*', outcome: admitted.class, detail: 'server' });
204
+ this.#routeStream(index, entry, stream, accept, refuse);
205
+ }
206
+
207
+ // ---- connections ---------------------------------------------------------
208
+
209
+ /** The local door: whoever opened the owner-only socket is this OS user. */
210
+ async #accept(socket) {
211
+ let channel;
212
+ try {
213
+ channel = await Channel.accept(socket, { floor: this.floor });
214
+ } catch (error) {
215
+ this.log.append({ principal: 'local', cap: 'connect', target: '*', outcome: 'refused', detail: error.message });
216
+ return;
217
+ }
218
+ const index = this.nextConnection++;
219
+ const entry = { channel, admitted: LOCAL_OPERATOR, principal: 'local' };
220
+ this.connections.set(index, entry);
221
+ channel.on('stream', (stream, accept, refuse) => this.#routeStream(index, entry, stream, accept, refuse));
222
+ channel.on('close', () => { this.connections.delete(index); this.connectionClosed(index); });
223
+ channel.on('error', () => {});
224
+ }
225
+
226
+ #routeStream(index, entry, stream, accept, refuse) {
227
+ const door = this.doors.get(stream.name);
228
+ if (!door) return refuse(`unknown stream ${stream.name}`);
229
+ return door(entry, stream, accept, refuse, index);
230
+ }
231
+
232
+ /**
233
+ * A target of a kind another product serves on this machine (`browser-profile:<name>`),
234
+ * found by that product's receipt (products.mjs). The caller's capabilities on the target
235
+ * are decided here, before the product hears of the stream; the product is told them and
236
+ * enforces what each admits on its own kind, and proves it holds the receipt's token before
237
+ * anything is relayed. `*` reaches a product's kind only for `view`. A server-forwarded
238
+ * caller is re-admitted from this machine's grants every PRODUCT_READMIT_MS and when a grant
239
+ * it holds expires, with the grants refreshed from the server at most that often: wider caps
240
+ * are sent on, and narrower ones close the stream.
241
+ */
242
+ async #serveProduct(entry, stream, accept, refuse) {
243
+ const { principal } = entry;
244
+ const target = String(stream.params?.target ?? '');
245
+ const colon = target.indexOf(':');
246
+ const kind = colon > 0 ? target.slice(0, colon) : '';
247
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(kind) || !/^[^\x00-\x1f]{1,4096}$/.test(target.slice(colon + 1))) return refuse('target must be <kind>:<name>');
248
+ if (DAEMON_KINDS.includes(kind)) return refuse(`${kind}: is served by this daemon's own streams`);
249
+ const capsOn = (admitted) => {
250
+ if (!admitted) return [];
251
+ if (admitted.class === 'operator') return [...PRODUCT_CAPS];
252
+ // A grant on `*` names no kind, so it lists (view) and reaches nothing a product serves.
253
+ const named = { class: admitted.class, grants: admitted.grants.map((grant) => ({ ...grant, targets: grant.targets.filter((t) => t !== '*') })) };
254
+ return PRODUCT_CAPS.filter((cap) => holds(named, cap, target) || (cap === 'view' && holds(admitted, 'view', target)));
255
+ };
256
+ let caps = capsOn(entry.admitted);
257
+ // A list of every target of a kind is admitted to anyone holding something on that kind:
258
+ // the product is told which grants of that kind the caller holds and lists only what they
259
+ // reach. Those grants travel with the open for this one request, and only for this kind.
260
+ const listing = target === `${kind}:*` && stream.params?.op === 'list';
261
+ const kindGrants = listing && entry.admitted?.class !== 'operator'
262
+ ? (entry.admitted?.grants ?? []).map((grant) => ({ caps: grant.caps, targets: grant.targets.filter((t) => t.startsWith(`${kind}:`) || t === '*') })).filter((grant) => grant.targets.length)
263
+ : null;
264
+ if (!caps.length && !(kindGrants && kindGrants.length)) {
265
+ this.log.append({ principal, cap: 'product', target, outcome: 'refused' });
266
+ return refuse(`no grant on ${target}`);
267
+ }
268
+ const receipt = findProduct(this.home, kind);
269
+ if (!receipt) {
270
+ this.log.append({ principal, cap: 'product', target, outcome: 'refused', detail: 'not served' });
271
+ return refuse(`${kind} is not served on this machine`);
272
+ }
273
+ const params = { ...(stream.params ?? {}) };
274
+ delete params.target;
275
+ const nonce = randomBytes(16).toString('hex');
276
+ const upstream = new WebSocket(receipt.endpoint, { maxPayload: PRODUCT_MAX_MESSAGE + 1024 });
277
+ let proven = false;
278
+ let accepted = false;
279
+ let settled = false;
280
+ let timer = null;
281
+ const unavailable = `${receipt.name} is unavailable`;
282
+ const end = () => {
283
+ settled = true;
284
+ clearTimeout(opening);
285
+ clearTimeout(timer);
286
+ if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) upstream.terminate();
287
+ };
288
+ const decline = (reason, detail = reason) => {
289
+ if (settled) return;
290
+ end();
291
+ this.log.append({ principal, cap: 'product', target, outcome: 'refused', detail: `${receipt.name}: ${detail}` });
292
+ refuse(reason);
293
+ };
294
+ let finished = false;
295
+ const finish = (reason, detail = reason) => {
296
+ if (!accepted) return decline(unavailable, detail);
297
+ if (finished) return;
298
+ finished = true;
299
+ end();
300
+ this.log.append({ principal, cap: 'product', target, outcome: 'closed', detail });
301
+ if (!stream.closed) stream.close(reason);
302
+ };
303
+ const opening = setTimeout(() => decline(unavailable, 'did not answer'), PRODUCT_OPEN_TIMEOUT_MS);
304
+ // The caller may leave before the product answers; nothing is then left open.
305
+ stream.once('close', () => { if (!accepted) { if (!settled) { end(); this.log.append({ principal, cap: 'product', target, outcome: 'abandoned' }); } } else finish('caller closed'); });
306
+ const readmit = async () => {
307
+ timer = null;
308
+ if (stream.closed || !accepted) return;
309
+ // First from the grants this machine holds, without waiting on the network.
310
+ if (!capsOn(entry.localAdmission?.()).length) return finish('no live grant');
311
+ await this.#refreshGrantsAtMost(PRODUCT_READMIT_MS);
312
+ if (stream.closed) return;
313
+ const fresh = entry.localAdmission?.() ?? null;
314
+ const now = capsOn(fresh);
315
+ // A narrowed grant ends the stream: what the product already enabled under the wider
316
+ // one (a screencast, a domain's events) must not outlive it. A wider one is passed on.
317
+ if (!now.length || caps.some((cap) => !now.includes(cap))) return finish('grant narrowed');
318
+ if (now.length !== caps.length) {
319
+ this.log.append({ principal, cap: 'product', target, outcome: 'caps widened', detail: now.join(',') });
320
+ caps = now;
321
+ if (upstream.readyState === WebSocket.OPEN) upstream.send(JSON.stringify({ type: 'caps', caps }));
322
+ }
323
+ entry.admitted = fresh;
324
+ schedule();
325
+ };
326
+ const schedule = () => {
327
+ if (!entry.localAdmission || stream.closed) return;
328
+ const expiries = (entry.admitted.grants ?? []).map((grant) => this.linkGrants.get(grant.id)?.expires_at).filter(Number.isFinite);
329
+ const untilExpiry = expiries.length ? Math.min(...expiries) - Date.now() + 50 : Infinity;
330
+ timer = setTimeout(() => { readmit().catch(() => finish('re-admission failed')); }, Math.max(50, Math.min(PRODUCT_READMIT_MS, untilExpiry)));
331
+ timer.unref?.();
332
+ };
333
+ // The product proves it holds the receipt's token before it hears who is calling.
334
+ upstream.on('open', () => upstream.send(JSON.stringify({ type: 'hello', nonce })));
335
+ upstream.on('message', (data) => {
336
+ let message;
337
+ try { message = JSON.parse(String(data)); } catch { return; }
338
+ if (!accepted) {
339
+ if (settled) return;
340
+ if (!proven) {
341
+ if (message.type !== 'hello' || typeof message.nonce !== 'string' || !sameSecret(message.proof, productProof(receipt.token, 'product', nonce))) return decline(unavailable, 'failed its proof');
342
+ proven = true;
343
+ upstream.send(JSON.stringify({ type: 'open', proof: productProof(receipt.token, 'daemon', message.nonce), principal, class: entry.admitted.class, caps, target, params, ...(kindGrants ? { grants: kindGrants } : {}) }));
344
+ return;
345
+ }
346
+ if (message.type === 'accepted') {
347
+ if (stream.closed) { end(); return; }
348
+ accepted = true;
349
+ settled = true;
350
+ clearTimeout(opening);
351
+ accept();
352
+ this.log.append({ principal, cap: 'product', target, outcome: 'opened', detail: `${receipt.name}: ${caps.join(',')}` });
353
+ if (listing) { /* answered in one message; no re-admission timer for it */ } else schedule();
354
+ stream.on('data', (text) => {
355
+ if (upstream.readyState !== WebSocket.OPEN) return;
356
+ const body = JSON.stringify({ type: 'data', data: String(text) });
357
+ if (Buffer.byteLength(body) > PRODUCT_MAX_MESSAGE) return finish('message too large');
358
+ upstream.send(body);
359
+ });
360
+ } else {
361
+ decline(message.type === 'refused' ? String(message.reason ?? 'refused').slice(0, 200) : unavailable);
362
+ }
363
+ return;
364
+ }
365
+ if (message.type !== 'data' || typeof message.data !== 'string' || stream.closed) return;
366
+ // Over the channel's frame, one message would drop the machine's whole link; it ends only this stream.
367
+ if (Buffer.byteLength(message.data) > PRODUCT_MAX_MESSAGE) return finish('message too large');
368
+ stream.write(message.data);
369
+ });
370
+ upstream.on('close', () => finish(`${receipt.name} closed`));
371
+ upstream.on('error', (error) => { if (!accepted) decline(unavailable, error.code ?? error.message); else finish(`${receipt.name} closed`, error.code ?? error.message); });
372
+ }
373
+
374
+ /** Refresh the synced grants from the server, sharing one in-flight refresh and at most once per `intervalMs`. */
375
+ #refreshGrantsAtMost(intervalMs) {
376
+ if (!this.link) return Promise.resolve();
377
+ if (this.grantRefresh) return this.grantRefresh;
378
+ if (Date.now() - (this.grantRefreshedAt ?? 0) < intervalMs) return Promise.resolve();
379
+ const deadline = new Promise((resolve) => { const t = setTimeout(resolve, PRODUCT_REFRESH_TIMEOUT_MS); t.unref?.(); });
380
+ this.grantRefresh = Promise.race([this.refreshLinkConfiguration().catch(() => {}), deadline])
381
+ .finally(() => { this.grantRefresh = null; this.grantRefreshedAt = Date.now(); });
382
+ return this.grantRefresh;
383
+ }
384
+
385
+ }
386
+
387
+ async function clearStaleSocket(path) {
388
+ if (!existsSync(path)) return;
389
+ const live = await new Promise((done) => {
390
+ const probe = netConnect(path);
391
+ probe.once('connect', () => { probe.destroy(); done(true); });
392
+ probe.once('error', () => done(false));
393
+ });
394
+ if (live) throw Object.assign(new Error(`a machine daemon is already serving ${path}`), { code: 'EADDRINUSE' });
395
+ rmSync(path, { force: true });
396
+ }
397
+
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@volter/teams",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Volter Teams, the neutral team plane: machine identity and daemon core, the local door and server link, admission from machine grants, product doors, and the Teams server's neutral routes (company RFC 0008).",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./index.mjs",
9
+ "./channel": "./channel.mjs",
10
+ "./browser-channel": "./browser-channel.mjs",
11
+ "./admission": "./admission.mjs",
12
+ "./identity": "./identity.mjs",
13
+ "./products": "./products.mjs",
14
+ "./machine-core": "./machine-core.mjs",
15
+ "./home": "./home.mjs",
16
+ "./server/core": "./server/core.mjs",
17
+ "./server/http": "./server/http.mjs",
18
+ "./server/auth": "./server/auth.mjs",
19
+ "./server/machine-link": "./server/machine-link.mjs",
20
+ "./server/request-json": "./server/request-json.mjs",
21
+ "./server/identity": "./server/identity.mjs",
22
+ "./server/events": "./server/events.mjs",
23
+ "./server/wire": "./server/wire.mjs",
24
+ "./server/migrations": "./server/migrations/index.mjs",
25
+ "./client": "./client.mjs"
26
+ },
27
+ "files": [
28
+ "*.mjs",
29
+ "server",
30
+ "README.md"
31
+ ],
32
+ "engines": {
33
+ "node": ">=22"
34
+ },
35
+ "dependencies": {
36
+ "@volter/identity": "^0.1.0",
37
+ "ws": "^8.21.0"
38
+ }
39
+ }
package/products.mjs ADDED
@@ -0,0 +1,157 @@
1
+ // Products served behind the daemon (RFC 0008 §1, §5). A product that serves
2
+ // its own kind of target on this machine (Volter Browsers: `browser-profile:`)
3
+ // writes a receipt into <teams home>/products/, the way a hosted runtime writes
4
+ // the receipt the frontend door finds:
5
+ //
6
+ // products/<name>.json { name, kinds: [<kind>...], endpoint: 'ws://127.0.0.1:<port>/<path>', token, pid, startedAt }
7
+ //
8
+ // The daemon admits a `product` stream against the caller's grants BEFORE the
9
+ // product hears of it, then dials the endpoint named in the newest live receipt.
10
+ // The token never crosses the wire; each side proves it holds it, the product
11
+ // first, so a process that took over a dead product's port learns nothing:
12
+ //
13
+ // → { type: 'hello', nonce: n1 }
14
+ // ← { type: 'hello', proof: HMAC(token, 'product|' + n1), nonce: n2 }
15
+ // → { type: 'open', proof: HMAC(token, 'daemon|' + n2), principal, class, caps, target, params }
16
+ // ← { type: 'accepted' } or { type: 'refused', reason }
17
+ //
18
+ // then each text message is relayed both ways as { type: 'data', data }. `caps`
19
+ // are the capabilities the caller holds on `target`, re-derived by the daemon; a
20
+ // product enforces what each capability admits on its own kind and trusts
21
+ // nothing else a caller sends. The daemon sends `{ type: 'caps', caps }` when a
22
+ // re-admission widens them, and closes the stream when it narrows them.
23
+ //
24
+ // One request differs: a list of every target of a kind (`<kind>:*` with params.op
25
+ // 'list') is admitted to a caller holding any grant on that kind, so `caps` may be
26
+ // empty, and the open carries `grants`, the caller's grants of that kind ({caps,
27
+ // targets}), for the product to list only what they cover. A product refuses empty
28
+ // caps on any other request.
29
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, rmSync, chmodSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ import { randomBytes, createHmac, timingSafeEqual } from 'node:crypto';
32
+
33
+ /** Kinds the daemon serves itself (the harness); a receipt can never claim them. */
34
+ export const DAEMON_KINDS = Object.freeze(['pane', 'runtime', 'root', 'port']);
35
+ const KIND = /^[a-z][a-z0-9-]{0,31}$/;
36
+
37
+ export const productsDir = (home) => join(home, 'products');
38
+
39
+ /** The largest message, in bytes, relayed either way: below the machine channel's 8 MiB frame, leaving room for its envelope. */
40
+ export const PRODUCT_MAX_MESSAGE = 8 * 1024 * 1024 - 64 * 1024;
41
+
42
+ /** One side's proof that it holds the receipt's token: HMAC-SHA256(token, role|nonce), hex. */
43
+ export const productProof = (token, role, nonce) => createHmac('sha256', token).update(`${role}|${nonce}`).digest('hex');
44
+
45
+ /** Constant-time comparison of two strings. */
46
+ export function sameSecret(a, b) {
47
+ const x = Buffer.from(String(a ?? '')), y = Buffer.from(String(b ?? ''));
48
+ return x.length === y.length && timingSafeEqual(x, y);
49
+ }
50
+
51
+ function alive(pid) {
52
+ if (!Number.isInteger(pid) || pid <= 0) return false;
53
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
54
+ }
55
+
56
+ function validReceipt(receipt) {
57
+ if (!receipt || typeof receipt !== 'object') return false;
58
+ if (typeof receipt.name !== 'string' || !KIND.test(receipt.name)) return false;
59
+ if (!Array.isArray(receipt.kinds) || !receipt.kinds.length) return false;
60
+ if (receipt.kinds.some((kind) => typeof kind !== 'string' || !KIND.test(kind) || DAEMON_KINDS.includes(kind))) return false;
61
+ if (typeof receipt.token !== 'string' || receipt.token.length < 32) return false;
62
+ let url;
63
+ try { url = new URL(receipt.endpoint); } catch { return false; }
64
+ // Loopback only: the token must never leave this machine.
65
+ return url.protocol === 'ws:' && (url.hostname === '127.0.0.1' || url.hostname === '[::1]');
66
+ }
67
+
68
+ /** The newest live receipt of a product serving `kind` on this machine, or null. */
69
+ export function findProduct(home, kind) {
70
+ let names;
71
+ try { names = readdirSync(productsDir(home)); } catch { return null; }
72
+ let best = null;
73
+ for (const file of names) {
74
+ if (!file.endsWith('.json')) continue;
75
+ let receipt;
76
+ try { receipt = JSON.parse(readFileSync(join(productsDir(home), file), 'utf8')); } catch { continue; }
77
+ if (`${receipt?.name}.json` !== file) continue;
78
+ if (!validReceipt(receipt) || !receipt.kinds.includes(kind) || !alive(receipt.pid)) continue;
79
+ if (!best || (receipt.startedAt ?? 0) > (best.startedAt ?? 0)) best = receipt;
80
+ }
81
+ return best;
82
+ }
83
+
84
+ /**
85
+ * Serve `kinds` behind this machine's daemon: listen on loopback, write the
86
+ * receipt, and hand each admitted open to `onOpen({ principal, class, caps,
87
+ * grants, target, params, send, close, onMessage, onCaps, onClose })`; `grants` is
88
+ * set on a kind's list only. Return a string
89
+ * from `onOpen` to refuse with that reason. Returns `{ close() }`, which removes
90
+ * the receipt.
91
+ */
92
+ export async function serveProduct({ home, name, kinds, onOpen }) {
93
+ if (typeof name !== 'string' || !KIND.test(name)) throw new Error('a product name is lowercase letters, digits and dashes');
94
+ if (!Array.isArray(kinds) || !kinds.length || kinds.some((kind) => typeof kind !== 'string' || !KIND.test(kind))) throw new Error('kinds are lowercase letters, digits and dashes');
95
+ const claimed = kinds.filter((kind) => DAEMON_KINDS.includes(kind));
96
+ if (claimed.length) throw new Error(`${claimed.join(', ')} are served by the daemon itself`);
97
+ const { WebSocketServer } = await import('ws');
98
+ const token = randomBytes(32).toString('hex');
99
+ const path = `/${randomBytes(8).toString('hex')}`;
100
+ const server = new WebSocketServer({ host: '127.0.0.1', port: 0, path, maxPayload: PRODUCT_MAX_MESSAGE + 1024 });
101
+ await new Promise((resolve, reject) => { server.once('listening', resolve); server.once('error', reject); });
102
+ server.on('connection', (socket) => {
103
+ const listeners = { message: new Set(), caps: new Set(), close: new Set() };
104
+ let phase = 'hello';
105
+ let challenge = null;
106
+ // A connection that never proves itself is dropped.
107
+ const deadline = setTimeout(() => { if (phase !== 'open') socket.terminate(); }, 10_000);
108
+ socket.on('error', () => socket.terminate());
109
+ socket.on('message', async (data) => {
110
+ let message;
111
+ try { message = JSON.parse(String(data)); } catch { message = null; }
112
+ if (phase === 'hello') {
113
+ if (message?.type !== 'hello' || typeof message.nonce !== 'string') { socket.terminate(); return; }
114
+ challenge = randomBytes(16).toString('hex');
115
+ phase = 'proving';
116
+ socket.send(JSON.stringify({ type: 'hello', proof: productProof(token, 'product', message.nonce), nonce: challenge }));
117
+ return;
118
+ }
119
+ if (phase === 'proving') {
120
+ if (message?.type !== 'open' || !sameSecret(message.proof, productProof(token, 'daemon', challenge))) { socket.terminate(); return; }
121
+ phase = 'opening';
122
+ clearTimeout(deadline);
123
+ const session = {
124
+ principal: message.principal, class: message.class, caps: message.caps, target: message.target, params: message.params ?? {},
125
+ grants: Array.isArray(message.grants) ? message.grants : [],
126
+ send: (text) => { if (socket.readyState === socket.OPEN) socket.send(JSON.stringify({ type: 'data', data: text })); },
127
+ close: (reason) => socket.close(1000, String(reason ?? '').slice(0, 120)),
128
+ onMessage: (fn) => listeners.message.add(fn),
129
+ onCaps: (fn) => listeners.caps.add(fn),
130
+ onClose: (fn) => listeners.close.add(fn),
131
+ };
132
+ let refusal;
133
+ try { refusal = await onOpen(session); } catch { refusal = 'the product could not open this target'; }
134
+ if (typeof refusal === 'string') { socket.send(JSON.stringify({ type: 'refused', reason: refusal })); socket.close(1000); return; }
135
+ phase = 'open';
136
+ socket.send(JSON.stringify({ type: 'accepted' }));
137
+ return;
138
+ }
139
+ if (phase !== 'open') return;
140
+ if (message?.type === 'data' && typeof message.data === 'string') for (const fn of listeners.message) fn(message.data);
141
+ else if (message?.type === 'caps' && Array.isArray(message.caps)) for (const fn of listeners.caps) fn(message.caps);
142
+ });
143
+ socket.on('close', () => { clearTimeout(deadline); for (const fn of listeners.close) fn(); });
144
+ });
145
+ const dir = productsDir(home);
146
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
147
+ chmodSync(dir, 0o700);
148
+ const file = join(dir, `${name}.json`);
149
+ const receipt = { name, kinds, endpoint: `ws://127.0.0.1:${server.address().port}${path}`, token, pid: process.pid, startedAt: Date.now() };
150
+ const temp = `${file}.${process.pid}.tmp`;
151
+ writeFileSync(temp, JSON.stringify(receipt), { mode: 0o600 });
152
+ renameSync(temp, file);
153
+ return {
154
+ receipt: { name, kinds, endpoint: receipt.endpoint },
155
+ close: () => { rmSync(file, { force: true }); for (const client of server.clients) client.terminate(); server.close(); },
156
+ };
157
+ }
@@ -0,0 +1,13 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ export const hashToken = token => createHash('sha256').update(token).digest('hex');
3
+ export function makeToken(credentialId, bytes = randomBytes(32)) { return `sct_${credentialId}.${Buffer.from(bytes).toString('base64url')}`; }
4
+ export function tokenMatches(token, digest) { const actual = Buffer.from(hashToken(token)); const expected = Buffer.from(String(digest)); return actual.length === expected.length && timingSafeEqual(actual, expected); }
5
+ export function bearer(header) { const match = /^Bearer ([^\s]+)$/.exec(header ?? ''); return match?.[1] ?? null; }
6
+ export function canRead(auth, session, grants = []) {
7
+ if (!auth || !['user','service'].includes(auth.kind) || !auth.active || session.deleted_at != null) return false;
8
+ if (session.visibility === 'team' || session.custodian_id === auth.principal_id) return true;
9
+ const now = auth.now;
10
+ return grants.some(g => (g.subject_id === auth.principal_id || auth.group_ids?.includes(g.subject_id)) && (g.expires_at == null || g.expires_at > now));
11
+ }
12
+ export function canManage(auth, session, grants = []) { if (!auth?.active || !['user','service'].includes(auth.kind) || auth.role === 'viewer') return false; return session.custodian_id === auth.principal_id || grants.some(g => g.role === 'manager' && (g.subject_id === auth.principal_id || auth.group_ids?.includes(g.subject_id)) && (g.expires_at == null || g.expires_at > auth.now)); }
13
+ export const isAdmin = auth => auth?.active && ['user','service'].includes(auth.kind) && (auth.role === 'owner' || auth.role === 'admin');