@volter/teams 0.1.13 → 0.1.15

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.
@@ -135,6 +135,36 @@ export function openBrowserMachine({ origin, teamId, machineId, token }, { WebSo
135
135
  });
136
136
  }
137
137
 
138
+ /**
139
+ * The machine running session `sessionId` of team `teamId`, reached with a workplace's session token (scope
140
+ * `control`) for a session its owner bound to the token's Room and Conversation: the server finds the machine.
141
+ */
142
+ export function openBrowserSession({ origin, teamId, sessionId, token }, { WebSocketImpl = globalThis.WebSocket } = {}) {
143
+ const url = `${String(origin).replace(/^http/, 'ws')}/api/v1/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(sessionId)}/channel`;
144
+ const socket = new WebSocketImpl(url, [CHANNEL_PROTOCOL, `bearer.${token}`]);
145
+ const channel = new BrowserChannel(socket);
146
+ return new Promise((resolve, reject) => {
147
+ socket.addEventListener('open', () => socket.send(frame({ p: PROTOCOL, nv: NODE_PROTOCOL })), { once: true });
148
+ channel.once('hello', (hello) => (hello.error ? reject(new Error(hello.error)) : resolve(channel)));
149
+ socket.addEventListener('error', () => reject(new Error(`could not reach session ${sessionId} through ${origin}`)), { once: true });
150
+ socket.addEventListener('close', () => reject(new Error('the server closed the session channel')), { once: true });
151
+ });
152
+ }
153
+
154
+ /**
155
+ * One line typed into the channel's session as its user's own turn. Resolves with the machine's answer:
156
+ * `{ code: 0, turn_id, door: 'runtime' | 'pane' | 'waiting' }` (waiting: filed, typed when the session's composer
157
+ * is free), or `{ code, text }` when it was not delivered; rejects when the machine refuses the stream.
158
+ */
159
+ export async function sendBrowserTurn(channel, text) {
160
+ const stream = await channel.open('mail', { request: { op: 'turn', text } });
161
+ return new Promise((resolve) => {
162
+ let answer = '';
163
+ stream.on('data', (chunk) => { answer += chunk; });
164
+ stream.on('close', () => { try { resolve(JSON.parse(answer)); } catch { resolve({ code: 1, text: answer || 'no answer from the machine' }); } });
165
+ });
166
+ }
167
+
138
168
  /** A pane's terminal stream: 'data' (text), 'hello', 'notice', 'close'; write(text), resize(cols, rows), close(). */
139
169
  export async function attachBrowserPane(channel, { pane, mode = 'observe', cols = 120, rows = 40 }) {
140
170
  const stream = await channel.open('terminal', { pane, mode, cols, rows });
package/machine-core.mjs CHANGED
@@ -207,7 +207,8 @@ export class MachineCore extends EventEmitter {
207
207
  return refuse('no machine grant for this caller');
208
208
  }
209
209
  const index = this.nextConnection++;
210
- const entry = { channel, admitted, principal: claim.principal, verify: () => this.#admitClaim(claim), localAdmission: () => admitFromGrants(claim, { custodianId: this.custodianId, grants: this.linkGrants }) };
210
+ // A delegated caller's claim also carries the name its workplace signed for it (who typed a turn).
211
+ const entry = { channel, admitted, principal: claim.principal, name: typeof claim.name === 'string' ? claim.name : null, verify: () => this.#admitClaim(claim), localAdmission: () => admitFromGrants(claim, { custodianId: this.custodianId, grants: this.linkGrants }) };
211
212
  this.connections.set(index, entry);
212
213
  stream.on('close', () => { this.connections.delete(index); this.connectionClosed(index); });
213
214
  this.log.append({ principal: entry.principal, cap: 'connect', target: '*', outcome: admitted.class, detail: 'server' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/teams",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
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
@@ -66,4 +66,18 @@ export async function maintain(api){const now=api.clock.nowMs();await api.store.
66
66
  {sql:'INSERT OR IGNORE INTO capture_expiries(team_id,source_id,id,expired_at) SELECT team_id,source_id,id,? FROM uploads WHERE committed=0 AND created_at+86400000<=?',params:[now,now]},
67
67
  {sql:'DELETE FROM uploads WHERE committed=0 AND created_at+86400000<=?',params:[now]},
68
68
  {sql:'DELETE FROM journal WHERE created_at+86400000<=?',params:[now]},
69
- ]);}
69
+ ]);
70
+ // Every capture writes its session a new version with a full copy of its messages, so versions pile up (hundreds
71
+ // per session) until listing them outgrew a Worker's memory. Kept: each session's current version, the newest
72
+ // version from each of its sources (the next capture and a correlation read that one), and every version an
73
+ // unexpired list, search or history cursor names. The rest go, a bounded batch per run.
74
+ const stale=await api.store.query(`SELECT v.team_id,v.session_id,v.revision FROM session_versions v JOIN sessions s ON s.team_id=v.team_id AND s.id=v.session_id
75
+ WHERE v.revision<>s.revision
76
+ AND EXISTS (SELECT 1 FROM session_versions n WHERE n.team_id=v.team_id AND n.session_id=v.session_id AND n.revision>v.revision AND json_extract(n.source_projection_json,'$.source_id') IS json_extract(v.source_projection_json,'$.source_id'))
77
+ AND NOT EXISTS (SELECT 1 FROM cursors c, json_each(c.snapshot_json) j WHERE c.team_id=v.team_id AND c.kind IN ('list','search') AND c.expires_at>? AND json_extract(j.value,'$[0]')=v.session_id AND json_extract(j.value,'$[1]')=v.revision)
78
+ AND NOT EXISTS (SELECT 1 FROM cursors c WHERE c.team_id=v.team_id AND c.kind='history' AND c.expires_at>? AND json_extract(c.snapshot_json,'$.session_id')=v.session_id AND json_extract(c.snapshot_json,'$.revision')=v.revision)
79
+ LIMIT ${VERSION_PRUNE_BATCH}`,[now,now]);
80
+ if(stale.length)await api.store.transaction(stale.flatMap(({team_id,session_id,revision})=>['messages','session_artifacts','session_versions'].map(table=>({sql:`DELETE FROM ${table} WHERE team_id=? AND session_id=? AND revision=?`,params:[team_id,session_id,revision]}))));
81
+ return {pruned_versions:stale.length};}
82
+ /** Session versions `maintain` removes per run: small enough for one Durable Object alarm, drains a backlog in hours. */
83
+ const VERSION_PRUNE_BATCH=50;
@@ -87,7 +87,7 @@ const SESSION_TOKEN_MAX_S = 15 * 60;
87
87
 
88
88
  /**
89
89
  * What a session-view token admits (RH2 ADR 0008), or null: { subject, issuer, teamId, sessionRef, room, conversation,
90
- * scope, expiresAt }. It names one session (by its id or the harness's own) of one team, and the Room and Conversation
90
+ * scope, name, expiresAt }. It names one session (by its id or the harness's own) of one team, and the Room and Conversation
91
91
  * viewing it; the server admits it only for a session its owner bound to that Room and Conversation.
92
92
  */
93
93
  export async function delegatedSessionOf(api, token) {
@@ -99,7 +99,10 @@ export async function delegatedSessionOf(api, token) {
99
99
  if (!['view', 'control'].includes(claims.scope)) return null;
100
100
  const now = Math.floor(api.clock.nowMs() / 1000);
101
101
  if (claims.exp - now > SESSION_TOKEN_MAX_S || (typeof claims.iat === 'number' && claims.exp - claims.iat > SESSION_TOKEN_MAX_S)) return null;
102
- return { conversation: claims.conversation, expiresAt: claims.exp * 1000, issuer: claims.iss, room: claims.room, scope: claims.scope, sessionRef: session.id, subject: claims.sub, teamId: session.team };
102
+ // The member's name as the workplace knows it: who the session is told typed a turn. From the signed token, never
103
+ // from what the browser sends.
104
+ const name = typeof claims.name === 'string' && claims.name.trim() ? claims.name.trim().slice(0, 80) : null;
105
+ return { 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 };
103
106
  }
104
107
 
105
108
  /** A token's claims when it is one this server trusts: signed by the issuer's key, for this server, in its lifetime. */
@@ -16,7 +16,7 @@
16
16
  // and the caps the token names, expiring with the token; its channel closes then, or when the surface is unpublished.
17
17
  import { Channel } from '../channel.mjs';
18
18
  import { bearer } from './auth.mjs';
19
- import { delegatedAccessOf, delegatedPrincipal, looksDelegated, surfaceCaps } from './delegated.mjs';
19
+ import { delegatedAccessOf, delegatedPrincipal, delegatedSessionOf, looksDelegated, surfaceCaps } from './delegated.mjs';
20
20
  import { delegatedGrantIssuer, publicationFor } from './publications.mjs';
21
21
 
22
22
  const LINK_PROTOCOL = 'supercode-machine-link.v1';
@@ -55,7 +55,7 @@ export async function machineGrantRows(api, teamId, machineId) {
55
55
  async function claimFor(api, auth, machineId) {
56
56
  if (auth.kind === 'delegated') {
57
57
  const row = await api.store.one('SELECT id, revision FROM machine_grants WHERE team_id=? AND machine_id=? AND id=? AND expires_at>?', [auth.team_id, machineId, auth.grant_id, api.clock.nowMs()]);
58
- return row ? { principal: auth.principal_id, groups: [], grants: [row.id], revisions: { [row.id]: row.revision } } : null;
58
+ return row ? { principal: auth.principal_id, groups: [], grants: [row.id], revisions: { [row.id]: row.revision }, ...(auth.name ? { name: auth.name } : {}) } : null;
59
59
  }
60
60
  const machine = await api.store.one('SELECT custodian_id FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', [auth.team_id, machineId]);
61
61
  if (!machine) return null;
@@ -151,10 +151,14 @@ export async function machineGrantRoute({ api, req, res, auth, machineId, grantI
151
151
  */
152
152
  export async function authorizeMachineUpgrade(api, { authenticate, url, headers }) {
153
153
  if (api.publicOrigin && headers.host !== new URL(api.publicOrigin).host) return { status: 400, code: 'invalid_request', message: 'request Host does not match configured public origin' };
154
- const match = /^\/api\/v1\/teams\/([^/]+)\/machines\/([^/]+)\/(link|channel)$/.exec(new URL(url, 'http://local').pathname);
154
+ const pathname = new URL(url, 'http://local').pathname;
155
+ const presented = bearerFrom(headers);
156
+ // A Room member typing into a session bound to their Room: the session names the machine, the server finds it.
157
+ const session = /^\/api\/v1\/teams\/([^/]+)\/sessions\/([^/]+)\/channel$/.exec(pathname);
158
+ if (session) return api.delegatedIssuer && looksDelegated(presented) ? await delegatedSessionChannel(api, session[1], decodeURIComponent(session[2]), presented) : { status: 404, code: 'not_found', message: 'not found' };
159
+ const match = /^\/api\/v1\/teams\/([^/]+)\/machines\/([^/]+)\/(link|channel)$/.exec(pathname);
155
160
  if (!match) return { status: 404, code: 'not_found', message: 'not found' };
156
161
  const [, teamId, machineId, door] = match;
157
- const presented = bearerFrom(headers);
158
162
  if (door === 'channel' && api.delegatedIssuer && looksDelegated(presented)) return await delegatedChannel(api, teamId, machineId, presented);
159
163
  const auth = await authenticate(api, presented);
160
164
  if (!auth) return { status: 401, code: 'authentication_required', message: 'authentication required' };
@@ -205,6 +209,52 @@ async function delegatedChannel(api, teamId, machineId, token) {
205
209
  return { door: 'channel', teamId, machineId, auth: { kind: 'delegated', team_id: teamId, principal_id: principal, group_ids: [], grant_id: id, grant_issuer: issuer, expires_at: access.expiresAt } };
206
210
  }
207
211
 
212
+ /**
213
+ * A session-view token's channel (RH2 ADR 0008): the member's own browser types a turn into one session its owner
214
+ * bound to the token's Room and Conversation. `control` scope only; the grant minted is `interact` on exactly that
215
+ * session (`session:<harness>/<native id>`) on the machine it runs on, for the token's life, and the claim carries
216
+ * the member's name from the token. Anything else is refused as a session that does not exist.
217
+ */
218
+ async function delegatedSessionChannel(api, teamId, sessionRef, token) {
219
+ const unknown = { status: 404, code: 'not_found', message: 'session not found' };
220
+ const access = await delegatedSessionOf(api, token);
221
+ if (!access) return { status: 401, code: 'authentication_required', message: 'authentication required' };
222
+ if (access.teamId !== teamId || access.scope !== 'control' || access.sessionRef !== sessionRef) return unknown;
223
+ const target = await boundSessionTarget(api, teamId, access);
224
+ if (!target) return unknown;
225
+ const now = api.clock.nowMs(), id = api.ids.next('grant'), principal = delegatedPrincipal(access);
226
+ const issuer = `delegated:${new URL(access.issuer).host}:session:${target.sessionId}`;
227
+ await api.store.guardedTransaction([
228
+ { sql: 'SELECT 1 ok FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', params: [teamId, target.machineId], code: 'not_found', status: 404, message: 'session not found' },
229
+ ], [
230
+ { sql: "DELETE FROM machine_grants WHERE team_id=? AND issuer_id LIKE 'delegated:%' AND expires_at<=?", params: [teamId, now] },
231
+ { sql: 'INSERT INTO machine_grants(team_id,id,machine_id,subject_id,caps_json,targets_json,issuer_id,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?)', params: [teamId, id, target.machineId, principal, JSON.stringify(['interact']), JSON.stringify([`session:${target.harness}/${target.nativeId}`]), issuer, now, access.expiresAt] },
232
+ ]).catch((error) => { if (error.code !== 'not_found') throw error; return null; });
233
+ return { door: 'channel', teamId, machineId: target.machineId, auth: { kind: 'delegated', team_id: teamId, principal_id: principal, group_ids: [], grant_id: id, grant_issuer: issuer, expires_at: access.expiresAt, name: access.name } };
234
+ }
235
+
236
+ /**
237
+ * Where a bound session runs: its family's newest origin from an active connector on an enrolled machine, preferring
238
+ * the harness session the token names. Null unless the session's owner bound it to the token's Room and Conversation.
239
+ */
240
+ async function boundSessionTarget(api, teamId, access) {
241
+ const named = await api.store.query('SELECT id, canonical_id FROM sessions WHERE team_id=? AND deleted_at IS NULL AND (id=? OR id IN (SELECT session_id FROM origins WHERE team_id=? AND native_id=? AND deleted_at IS NULL))', [teamId, access.sessionRef, teamId, access.sessionRef]);
242
+ for (const found of named) {
243
+ let root = found;
244
+ for (let hops = 0; root?.canonical_id && hops < 16; hops += 1) root = await api.store.one('SELECT id, canonical_id FROM sessions WHERE team_id=? AND id=? AND deleted_at IS NULL', [teamId, root.canonical_id]);
245
+ if (!root || root.canonical_id) continue;
246
+ const bound = await api.store.one('SELECT 1 ok FROM session_view_bindings WHERE team_id=? AND session_id=? AND issuer=? AND room=? AND conversation=?', [teamId, root.id, access.issuer, access.room, access.conversation]);
247
+ if (!bound) continue;
248
+ const origin = await api.store.one(`SELECT o.harness, o.native_id, o.session_id, src.machine_id FROM origins o
249
+ JOIN sources src ON src.team_id=o.team_id AND src.id=o.source_id AND src.state='active' AND src.machine_id IS NOT NULL
250
+ JOIN machines m ON m.team_id=src.team_id AND m.id=src.machine_id AND m.revoked_at IS NULL
251
+ WHERE o.team_id=? AND o.deleted_at IS NULL AND o.session_id IN (SELECT id FROM sessions WHERE team_id=? AND deleted_at IS NULL AND (id=? OR canonical_id=?))
252
+ ORDER BY (o.native_id=?) DESC, o.sequence DESC LIMIT 1`, [teamId, teamId, root.id, root.id, access.sessionRef]);
253
+ if (origin) return { sessionId: root.id, harness: origin.harness, nativeId: origin.native_id, machineId: origin.machine_id };
254
+ }
255
+ return null;
256
+ }
257
+
208
258
  /** Close every delegated channel whose grant came from one publication (issuer from delegatedGrantIssuer): it was unpublished. */
209
259
  export function closeDelegatedChannels(api, teamId, machineId, grantIssuer, reason = 'unpublished') {
210
260
  let closed = 0;