@volter/teams 0.1.52 → 0.1.54

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/machine-core.mjs CHANGED
@@ -75,6 +75,8 @@ export class MachineCore extends EventEmitter {
75
75
 
76
76
  /** Before the local door opens: a product's own setup. */
77
77
  async beforeListen() {}
78
+ /** The socket is published; product workers may now use its local doors. */
79
+ async afterListen() {}
78
80
  /** After the local door and link close: a product's own teardown. */
79
81
  async afterStop() {}
80
82
  /** A connection closed: a product drops what it routed to it. */
@@ -94,6 +96,7 @@ export class MachineCore extends EventEmitter {
94
96
  await this.afterStop().catch(() => {});
95
97
  throw error;
96
98
  }
99
+ await this.afterListen();
97
100
  if (this.link) {
98
101
  await this.refreshLinkConfiguration().catch((error) => this.emit('link', { event: 'configuration-failed', error: error.message }));
99
102
  this.#connectLink();
@@ -435,14 +438,22 @@ export class MachineCore extends EventEmitter {
435
438
  const SOCKET_WATCH_MS = 5_000;
436
439
 
437
440
  async function clearStaleSocket(path) {
438
- // A named pipe has no file to leave behind: only a live daemon holds its name.
439
- if (!isNamedPipe(path) && !existsSync(path)) return;
440
- const live = await new Promise((done) => {
441
+ // Only a refused connection proves stale. A timeout or permission failure
442
+ // does not authorize deleting a potentially live daemon's socket.
443
+ let inode;
444
+ if (!isNamedPipe(path)) {
445
+ try { inode = statSync(path).ino; } catch (error) { if (error.code === 'ENOENT') return; throw error; }
446
+ }
447
+ const live = await new Promise((done, reject) => {
441
448
  const probe = netConnect(path);
442
- probe.once('connect', () => { probe.destroy(); done(true); });
443
- probe.once('error', () => done(false));
449
+ const finish = (error, value) => { probe.destroy(); error ? reject(error) : done(value); };
450
+ probe.setTimeout(1000, () => finish(Object.assign(new Error(`machine socket did not answer: ${path}`), { code: 'ETIMEDOUT' })));
451
+ probe.once('connect', () => finish(null, true));
452
+ probe.once('error', error => ['ECONNREFUSED', 'ENOENT'].includes(error.code) ? finish(null, false) : finish(error));
444
453
  });
445
454
  if (live) throw Object.assign(new Error(`a machine daemon is already serving ${path}`), { code: 'EADDRINUSE' });
446
- if (!isNamedPipe(path)) rmSync(path, { force: true });
455
+ if (!isNamedPipe(path)) {
456
+ try { if (statSync(path).ino === inode) rmSync(path); }
457
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
458
+ }
447
459
  }
448
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/teams",
3
- "version": "0.1.52",
3
+ "version": "0.1.54",
4
4
  "type": "module",
5
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
6
  "license": "MIT",
package/server/core.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { reconcileMachinePresence } from './machine-events.mjs';
1
2
  // The Teams server, independent of its host. `createTeamsApi` builds the API
2
3
  // object every route and door reads; a host attaches a store (`api.attach`),
3
4
  // bootstraps the server identity once, serves requests through http.mjs's
@@ -58,6 +59,7 @@ export async function bootstrapServer(api,{initTeam,bootstrap}){
58
59
 
59
60
  /** Expire cursors, idempotency records, staged captures and uploads, and old journal rows. */
60
61
  export async function maintain(api){const now=api.clock.nowMs();
62
+ await reconcileMachinePresence(api);
61
63
  // Replay records expire after a day; keeping them forever can fill the object and stop heartbeats.
62
64
  // Delete them before any retention step that may update a surviving version.
63
65
  const expired=await api.store.query('SELECT rowid FROM idempotency WHERE expires_at<=? LIMIT 1000',[now]);
@@ -106,6 +106,22 @@ export async function delegatedSessionOf(api, token) {
106
106
  return { roomContext, conversation: claims.conversation, expiresAt: claims.exp * 1000, issuer: claims.iss, name, room: claims.room, scope: claims.scope, sessionRef: session.id, subject: claims.sub, teamId: session.team };
107
107
  }
108
108
 
109
+ /** A signed Room lifecycle notice. This scope grants no transcript read or user-turn control. */
110
+ export async function delegatedRoomNoticeOf(api, token) {
111
+ const c = await verifiedClaims(api, token);
112
+ if (!c || c.scope !== 'room-notice' || !c.session || c.sub !== c.session.agent) return null;
113
+ const n = c.notice;
114
+ if (![c.room, c.conversation, c.session.team, c.session.id, c.session.harness].every(v => typeof v === 'string' && v.length > 0 && v.length <= 256)) return null;
115
+ if (!n || !['joined', 'member-joined', 'member-left', 'left'].includes(n.kind)
116
+ || typeof n.id !== 'string' || !/^[a-zA-Z0-9:_-]{1,200}$/.test(n.id)
117
+ || typeof n.text !== 'string' || !n.text.trim() || n.text.length > 16000) return null;
118
+ const now = Math.floor(api.clock.nowMs() / 1000);
119
+ if (!Number.isFinite(c.iat) || c.exp - c.iat > SESSION_TOKEN_MAX_S || c.exp - now > SESSION_TOKEN_MAX_S) return null;
120
+ return { conversation: c.conversation, expiresAt: c.exp * 1000, issuer: c.iss, room: c.room,
121
+ roomName: typeof c.roomName === 'string' ? c.roomName.slice(0, 120) : c.room,
122
+ sessionRef: c.session.id, harness: c.session.harness, subject: c.sub, teamId: c.session.team, notice: n };
123
+ }
124
+
109
125
  /** A token's claims when it is one this server trusts: signed by the issuer's key, for this server, in its lifetime. */
110
126
  async function verifiedClaims(api, token) {
111
127
  if (!api.delegatedIssuer || !api.publicOrigin || !looksDelegated(token)) return null;
package/server/http.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { machineEventStream } from './machine-events.mjs';
1
2
  import { randomBytes } from 'node:crypto';
2
3
  import { bearer, hashToken, canManage, isAdmin, makeToken } from './auth.mjs';
3
4
  import { ContractError, canonicalJson, validationFailure, cursorHash, cursorToken, sha256 } from './wire.mjs';
@@ -48,7 +49,7 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
48
49
  if(auth.kind==='user'&&auth.team_id===null){const membership=await api.store.one('SELECT role,active FROM memberships WHERE team_id=? AND principal_id=?',[teamId,auth.principal_id]);if(!membership?.active)fail(401,'authentication_required','authentication required');auth.role=membership.role;auth.active=membership.active;auth.team_id=teamId;auth.group_ids=(await api.store.query('SELECT group_id FROM group_members WHERE team_id=? AND principal_id=?',[teamId,auth.principal_id])).map(x=>x.group_id);}else if(auth.team_id!==teamId||!auth.active)fail(401,'authentication_required','authentication required');
49
50
  if(req.method!=='GET')res._auditRecord={teamId:auth.team_id,actorId:auth.principal_id,operation:`${req.method} ${path}`};
50
51
  if(['machine','ingest'].includes(auth.kind)){
51
- const machineDoor=auth.kind==='machine'&&(/^\/machines\/[^/]+\/configuration$/.test(sub)||sub===`/machines/${auth.machine_id}/heartbeat`||sub.startsWith(`/machines/${auth.machine_id}/effects`)||new RegExp(`^/machines/${auth.machine_id}/attachments/[^/]+(?:/frames)?$`).test(sub));
52
+ const machineDoor=auth.kind==='machine'&&(sub==='/machines/events'||/^\/machines\/[^/]+\/configuration$/.test(sub)||sub===`/machines/${auth.machine_id}/heartbeat`||sub.startsWith(`/machines/${auth.machine_id}/effects`)||new RegExp(`^/machines/${auth.machine_id}/attachments/[^/]+(?:/frames)?$`).test(sub));
52
53
  const sourceDoor=sub.startsWith(`/sources/${auth.source_id}/captures`)||sub.startsWith(`/sources/${auth.source_id}/uploads`);
53
54
  if(!machineDoor&&!sourceDoor)fail(403,'forbidden','credential kind cannot use this operation');
54
55
  }
@@ -66,6 +67,7 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
66
67
  if(req.method==='DELETE'&&(m=/^\/groups\/([^/]+)$/.exec(sub)))return await groupRoute(api,req,res,auth,m[1],null,url,requestId);
67
68
  if((req.method==='PUT'||req.method==='DELETE')&&(m=/^\/groups\/([^/]+)\/members\/([^/]+)$/.exec(sub)))return await groupRoute(api,req,res,auth,m[1],m[2],url,requestId);
68
69
  if(req.method==='POST'&&sub==='/machines')return await enroll(api,req,res,auth,requestId);
70
+ if(req.method==='GET'&&sub==='/machines/events')return await machineEventStream({api,req,res,auth,url,requestId,fail});
69
71
  if(req.method==='GET'&&sub==='/machines')return await machineAdminRoute(api,req,res,auth,null,false,requestId);
70
72
  if(req.method==='GET'&&(m=/^\/machines\/([^/]+)$/.exec(sub)))return await machineAdminRoute(api,req,res,auth,m[1],false,requestId);
71
73
  if(req.method==='GET'&&(m=/^\/machines\/([^/]+)\/roots$/.exec(sub)))return await machineAdminRoute(api,req,res,auth,m[1],true,requestId);
@@ -0,0 +1,73 @@
1
+ // A retained transition log, shared by mailbox subscriptions and board supervision.
2
+ import { writeSse } from './events.mjs';
3
+
4
+ export function machineTransition(api, team, machine, linked) {
5
+ // Serialize the read/change/event across asynchronous store calls. A replaced
6
+ // link remains linked; only the current link's close records a departure.
7
+ const next = (api.machineTransitionWork ?? Promise.resolve()).then(async () => {
8
+ const row = await api.store.one('SELECT name,revoked_at FROM machines WHERE team_id=? AND id=?', [team, machine]);
9
+ if (!row) return;
10
+ const present = linked ?? api.machineLinks?.has(`${team}\0${machine}`);
11
+ const value = present && row.revoked_at == null ? 1 : 0;
12
+ const before = await api.store.one('SELECT linked FROM machine_presence WHERE team_id=? AND machine_id=?', [team, machine]);
13
+ if (before?.linked === value) return;
14
+ const at = api.clock.nowMs();
15
+ await api.store.transaction([
16
+ { sql: 'INSERT INTO machine_presence VALUES (?,?,?,?,?) ON CONFLICT(team_id,machine_id) DO UPDATE SET name=excluded.name,linked=excluded.linked,changed_at=excluded.changed_at', params: [team,machine,row.name,value,at] },
17
+ { sql: 'INSERT INTO machine_presence_events(team_id,machine_id,name,linked,created_at) VALUES (?,?,?,?,?)', params: [team,machine,row.name,value,at] },
18
+ ]);
19
+ api.events.publish(team, { type: 'machine_transition' });
20
+ });
21
+ api.machineTransitionWork = next.catch(error => { console.error(`machine transition ${machine}: ${error.message}`); });
22
+ return next;
23
+ }
24
+
25
+ export async function reconcileMachinePresence(api) {
26
+ await api.machineTransitionWork;
27
+ for(const id of api.machineLinks?.keys()??[]){const [team,machine]=id.split('\0');await machineTransition(api,team,machine,null);}
28
+ const rows = await api.store.query('SELECT team_id,machine_id FROM machine_presence WHERE linked=1');
29
+ for (const row of rows) if (!api.machineLinks?.has(`${row.team_id}\0${row.machine_id}`)) await machineTransition(api,row.team_id,row.machine_id,null);
30
+ const before=api.clock.nowMs()-7*86400000;
31
+ await api.store.transaction([
32
+ {sql:'INSERT INTO machine_presence_cursors SELECT team_id,MAX(sequence) FROM machine_presence_events WHERE created_at<? GROUP BY team_id ON CONFLICT(team_id) DO UPDATE SET pruned_through=MAX(pruned_through,excluded.pruned_through)',params:[before]},
33
+ {sql:'DELETE FROM machine_presence_events WHERE created_at<?',params:[before]},
34
+ ]);
35
+ }
36
+
37
+ export async function machineEventStream({api,req,res,auth,url,requestId,fail}) {
38
+ const after = url.searchParams.get('after') ?? req.headers['last-event-id'];
39
+ if (after != null && (!/^\d+$/.test(after) || !Number.isSafeInteger(Number(after)))) fail(400,'invalid_request','after must be a machine event cursor');
40
+ const count = api.openStreams.get(auth.credential_id) ?? 0;
41
+ if (count >= 10) fail(429,'rate_limited','too many open streams');
42
+ api.openStreams.set(auth.credential_id,count+1);
43
+ let closed=false, cursor=after==null?null:Number(after), work=Promise.resolve();
44
+ const close=()=>{ if(closed)return;closed=true;clearInterval(timer);unsubscribe();req.off('close',close);api.events.off('shutdown',close);api.openStreams.set(auth.credential_id,Math.max(0,(api.openStreams.get(auth.credential_id)??1)-1));res.end(); };
45
+ const write=event=>{if(closed)return;if(res.writableLength>1024*1024){close();return;}writeSse(res,event);};
46
+ const drain=async()=>{
47
+ if(closed)return;
48
+ const credential=await api.store.one('SELECT 1 ok FROM credentials c JOIN memberships m ON m.team_id=? AND m.principal_id=c.principal_id WHERE c.id=? AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>?) AND m.active=1',[auth.team_id,auth.credential_id,api.clock.nowMs()]);
49
+ if(!credential){write({type:'access_revoked'});close();return;}
50
+ await api.machineTransitionWork;
51
+ const bounds=await api.store.one('SELECT COALESCE((SELECT MAX(sequence) FROM machine_presence_events WHERE team_id=?),(SELECT pruned_through FROM machine_presence_cursors WHERE team_id=?),0) last,COALESCE((SELECT pruned_through FROM machine_presence_cursors WHERE team_id=?),0) floor',[auth.team_id,auth.team_id,auth.team_id]);
52
+ if(cursor===null || cursor>(bounds.last??0) || cursor<bounds.floor) {
53
+ const snapshot=await api.store.snapshot([
54
+ {kind:'one',sql:'SELECT COALESCE((SELECT MAX(sequence) FROM machine_presence_events WHERE team_id=?),(SELECT pruned_through FROM machine_presence_cursors WHERE team_id=?),0) cursor',params:[auth.team_id,auth.team_id]},
55
+ {kind:'query',sql:'SELECT m.id,m.name,COALESCE(p.linked,0) linked,p.changed_at FROM machines m LEFT JOIN machine_presence p ON p.team_id=m.team_id AND p.machine_id=m.id WHERE m.team_id=? AND m.revoked_at IS NULL ORDER BY m.id',params:[auth.team_id]},
56
+ ]);
57
+ const reset=cursor!==null;cursor=snapshot[0].cursor??0;
58
+ write({type:'machine_snapshot',cursor:String(cursor),reset,items:snapshot[1]});
59
+ }
60
+ for (;;) {
61
+ const rows=await api.store.query('SELECT * FROM machine_presence_events WHERE team_id=? AND sequence>? ORDER BY sequence LIMIT 256',[auth.team_id,cursor]);
62
+ for(const row of rows){cursor=row.sequence;write({type:row.linked?'machine_linked':'machine_unlinked',cursor:String(cursor),machine_id:row.machine_id,name:row.name,at:row.created_at});}
63
+ if(rows.length<256||closed)break;
64
+ }
65
+ };
66
+ const enqueue=()=>{work=work.then(drain).catch(error=>{console.error(`machine events: ${error.message}`);close();});return work;};
67
+ const unsubscribe=api.events.subscribe(`team:${auth.team_id}`,event=>{if(['machine_transition','authorization_changed'].includes(event.type))void enqueue();});
68
+ const timer=setInterval(()=>{void enqueue().then(()=>{if(!closed)res.write(': heartbeat\n\n');});},15000);timer.unref?.();
69
+ req.once('close',close);api.events.once('shutdown',close);
70
+ res.writeHead(200,{'Content-Type':'text/event-stream','Cache-Control':'no-store','X-Request-ID':requestId});
71
+ await enqueue();
72
+ write({type:'machine_ready',cursor:String(cursor)});
73
+ }
@@ -14,6 +14,7 @@
14
14
  // A delegated caller (./delegated.mjs) holds no Teams credential: its verified token, when it names exactly a surface
15
15
  // the machine's custodian published to that workplace (./publications.mjs), becomes one machine grant on that target
16
16
  // and the caps the token names, expiring with the token; its channel closes then, or when the surface is unpublished.
17
+ import { machineTransition } from './machine-events.mjs';
17
18
  import { randomUUID } from 'node:crypto';
18
19
  import { Channel } from '../channel.mjs';
19
20
  import { bearer } from './auth.mjs';
@@ -298,9 +299,12 @@ async function acceptLink(api, teamId, machineId, duplex, closeSocket) {
298
299
  const link = { channel, close: (reason) => closeSocket(reason) };
299
300
  links(api).set(id, link);
300
301
  previous?.close('replaced');
302
+ void machineTransition(api,teamId,machineId,true).catch(() => {});
301
303
  channel.on('error', () => {});
302
304
  channel.on('close', () => {
303
- if (links(api).get(id) === link) links(api).delete(id);
305
+ if (links(api).get(id) !== link) return;
306
+ links(api).delete(id);
307
+ void machineTransition(api,teamId,machineId,false).catch(() => {});
304
308
  // A caller's open streams ride this link; they end with it, and the caller reopens.
305
309
  for (const entry of channels(api)) if (entry.teamId === teamId && entry.machineId === machineId) entry.dropStreams('machine_offline');
306
310
  });
@@ -372,3 +376,40 @@ async function acceptChannel(api, auth, machineId, duplex, closeSocket) {
372
376
  upstream.on('close', (reason) => { open.delete(pair); if (!stream.closed) stream.close(reason); });
373
377
  });
374
378
  }
379
+
380
+ /** Deliver only signed lifecycle text to an exact owner-bound session, through durable native mail.
381
+ * The native --id receipt survives reconnects and server restarts; retry never invents a new id.
382
+ */
383
+ export async function deliverSessionRoomNotice(api, access) {
384
+ const target = await boundSessionTarget(api, access.teamId, access);
385
+ if (!target || target.harness !== access.harness) throw Object.assign(new Error('session not found'), { status: 404, code: 'not_found' });
386
+ const machine = await api.store.one('SELECT name FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', [access.teamId, target.machineId]);
387
+ if (!machine) throw Object.assign(new Error('machine not found'), { status: 404, code: 'not_found' });
388
+ const machineName = String(machine.name).split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g, '-');
389
+ const to = `sc:${machineName}:${target.harness}:${target.nativeId}`;
390
+ const identity = JSON.stringify([access.issuer, access.room, access.conversation, target.sessionId, access.notice.id]);
391
+ const digest = [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(identity)))].map(v => v.toString(16).padStart(2, '0')).join('');
392
+ const pending = api.roomNoticeDeliveries ??= new Map();
393
+ if (pending.has(digest)) return pending.get(digest);
394
+ const delivery = (async () => {
395
+ const id = api.ids.next('grant'), principal = delegatedPrincipal(access), now = api.clock.nowMs();
396
+ await api.store.transaction([{ sql: 'INSERT INTO machine_grants(team_id,id,machine_id,subject_id,caps_json,targets_json,issuer_id,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?)',
397
+ params: [access.teamId, id, target.machineId, principal, JSON.stringify(['mail']), JSON.stringify([`mail:${to}`]), `delegated:room-notice:${digest}`, now, access.expiresAt] }]);
398
+ try {
399
+ const link = await linkWithin(api, key(access.teamId, target.machineId), LINK_WAIT_MS);
400
+ if (!link) throw new Error('machine offline; Room notice remains pending');
401
+ const stream = await link.channel.open('mail', { _admitted: { principal, groups: [], grants: [id], revisions: { [id]: 1 } },
402
+ request: { op: 'send', from: `sc:${machineName}:operator:room-${digest.slice(0, 24)}`, from_name: `Room ${access.roomName}`,
403
+ to, body: access.notice.text, id: `room-notice:${digest}`, queue: false } });
404
+ return await new Promise((resolve, reject) => {
405
+ let text = '';
406
+ const timeout = setTimeout(() => { stream.close('timeout'); reject(new Error('Room notice delivery not confirmed')); }, 15000);
407
+ stream.on('data', bytes => { text += new TextDecoder().decode(bytes); });
408
+ stream.on('close', () => { clearTimeout(timeout); try { const result = JSON.parse(text); if (result.code !== 0) throw new Error(result.text ?? 'Room notice refused'); resolve({ delivered: true, noticeId: access.notice.id }); } catch (error) { reject(error); } });
409
+ stream.on('error', error => { clearTimeout(timeout); reject(error); });
410
+ });
411
+ } finally { await api.store.transaction([{ sql: 'DELETE FROM machine_grants WHERE team_id=? AND id=?', params: [access.teamId, id] }]); }
412
+ })();
413
+ pending.set(digest, delivery);
414
+ try { return await delivery; } finally { pending.delete(digest); }
415
+ }
@@ -0,0 +1,14 @@
1
+ export default `
2
+ CREATE TABLE machine_presence (
3
+ team_id TEXT NOT NULL, machine_id TEXT NOT NULL, name TEXT NOT NULL,
4
+ linked INTEGER NOT NULL, changed_at INTEGER NOT NULL,
5
+ PRIMARY KEY(team_id,machine_id)
6
+ );
7
+ CREATE TABLE machine_presence_events (
8
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT, team_id TEXT NOT NULL,
9
+ machine_id TEXT NOT NULL, name TEXT NOT NULL, linked INTEGER NOT NULL,
10
+ created_at INTEGER NOT NULL
11
+ );
12
+ CREATE TABLE machine_presence_cursors (team_id TEXT PRIMARY KEY, pruned_through INTEGER NOT NULL);
13
+ CREATE INDEX machine_presence_events_team ON machine_presence_events(team_id,sequence);
14
+ `;
@@ -8,7 +8,8 @@ import m006 from './006.mjs';
8
8
  import m007 from './007.mjs';
9
9
  import m008 from './008.mjs';
10
10
  import m009 from './009.mjs';
11
- export const MIGRATIONS = [m001, m002, m003, m004, m005, m006, m007, m008, m009];
11
+ import m010 from './010.mjs';
12
+ export const MIGRATIONS = [m001, m002, m003, m004, m005, m006, m007, m008, m009, m010];
12
13
  /** A message row's `until_revision` while its session's current version still holds it (schema 9). */
13
14
  export const OPEN_REVISION = Number.MAX_SAFE_INTEGER;
14
15
  /** SQL: message row `m` belongs to the version at revision ?1 (bind the revision twice). */