@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.
- package/browser-channel.mjs +30 -0
- package/machine-core.mjs +2 -1
- package/package.json +1 -1
- package/server/core.mjs +15 -1
- package/server/delegated.mjs +5 -2
- package/server/machine-link.mjs +54 -4
package/browser-channel.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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;
|
package/server/delegated.mjs
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/server/machine-link.mjs
CHANGED
|
@@ -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
|
|
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;
|