@volter/teams 0.1.14 → 0.1.16
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/delegated.mjs +5 -2
- package/server/http.mjs +3 -1
- package/server/machine-link.mjs +54 -4
- package/server/migrations/008.mjs +10 -0
- package/server/migrations/index.mjs +2 -1
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.16",
|
|
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/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/http.mjs
CHANGED
|
@@ -255,7 +255,9 @@ async function machineAdminRoute(api,req,res,auth,machineId,roots,requestId){
|
|
|
255
255
|
async function revokeMachine(api,req,res,auth,machineId,requestId){
|
|
256
256
|
const machine=await api.store.one('SELECT * FROM machines WHERE team_id=? AND id=?',[auth.team_id,machineId]);if(!machine)fail(404,'not_found','not found');if(machine.custodian_id!==auth.principal_id&&!isAdmin(auth))fail(403,'forbidden','forbidden');requireRevision(req,machine.revision,'machine');const now=api.clock.nowMs(),guards=[...authGuards(auth,now),{sql:'SELECT 1 ok FROM machines WHERE team_id=? AND id=? AND revision=? AND revoked_at IS NULL AND (custodian_id=? OR ?=1)',params:[auth.team_id,machineId,machine.revision,auth.principal_id,isAdmin(auth)?1:0],code:'revision_mismatch',status:409,message:'machine changed'}],statements=[{sql:'UPDATE machines SET revoked_at=?,revision=revision+1 WHERE team_id=? AND id=?',params:[now,auth.team_id,machineId]},{sql:'UPDATE credentials SET revoked_at=? WHERE team_id=? AND machine_id=? AND revoked_at IS NULL',params:[now,auth.team_id,machineId]},{sql:"UPDATE sources SET state='revoked',revision=revision+1 WHERE team_id=? AND machine_id=?",params:[auth.team_id,machineId]},{sql:"UPDATE sync_rules SET state='removed',revision=revision+1 WHERE team_id=? AND machine_id=? AND state!='removed'",params:[auth.team_id,machineId]},{sql:"UPDATE attachments SET state='revoked',closed_at=? WHERE team_id=? AND machine_id=? AND state IN ('pending','active')",params:[now,auth.team_id,machineId]},{sql:"UPDATE operations SET state='unknown' WHERE team_id=? AND id IN (SELECT operation_id FROM native_effects WHERE team_id=? AND machine_id=? AND state IN ('pending','running'))",params:[auth.team_id,auth.team_id,machineId]},{sql:"UPDATE native_effects SET state='unknown',result_json=? WHERE team_id=? AND machine_id=? AND state IN ('pending','running')",params:[JSON.stringify({state:'unknown',error:null}),auth.team_id,machineId]},{sql:'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',params:[auth.team_id]}];await guardedMutation(api,res,auth,guards,statements,{status:204,body:null,targetId:machineId});api.events.publish(auth.team_id,{type:'authorization_changed',machine_id:machineId});closeMachineChannels(api,auth.team_id,machineId,{reason:'machine_revoked'});send(res,204,null,requestId);
|
|
257
257
|
}
|
|
258
|
-
async function heartbeat(api,req,res,auth,machineId,requestId){if(auth.kind!=='machine'||auth.machine_id!==machineId)fail(403,'forbidden','exact machine credential required');const machine=await api.store.one('SELECT * FROM machines WHERE team_id=? AND id=?',[auth.team_id,machineId]);if(!machine||machine.revoked_at!=null)fail(403,'forbidden','machine enrollment is not active');const b=await parseJson(req);exact(b,['incarnation_id','sequence','bindings','roots','host_key_id']);if(b.host_key_id!==undefined&&(typeof b.host_key_id!=='string'||!/^[A-Za-z0-9_-]{16,128}$/.test(b.host_key_id)))fail(400,'invalid_request','invalid host_key_id');if(typeof b.incarnation_id!=='string'||!Number.isSafeInteger(b.sequence)||b.sequence<1||!Array.isArray(b.bindings)||!Array.isArray(b.roots))fail(400,'invalid_request','invalid heartbeat');if(machine.incarnation_id===b.incarnation_id&&b.sequence<=machine.heartbeat_sequence)return send(res,200,result(api,auth,machineJson(machine,api.clock.nowMs(),api)),requestId);const now=api.clock.nowMs(),newIncarnation=machine.incarnation_id!==null&&machine.incarnation_id!==b.incarnation_id,statements=[{sql:'UPDATE machines SET incarnation_id=?,heartbeat_sequence=?,last_seen_at=?,host_key_id=COALESCE(?,host_key_id),revision=revision+1 WHERE team_id=? AND id=?',params:[b.incarnation_id,b.sequence,now,b.host_key_id??null,auth.team_id,machineId]},{sql:'DELETE FROM machine_roots WHERE team_id=? AND machine_id=?',params:[auth.team_id,machineId]}];if(newIncarnation)statements.push({sql:"UPDATE attachments SET state='closed',closed_at=? WHERE team_id=? AND machine_id=? AND state IN ('pending','active')",params:[now,auth.team_id,machineId]},{sql:"UPDATE operations SET state='unknown' WHERE team_id=? AND id IN (SELECT operation_id FROM native_effects WHERE team_id=? AND machine_id=? AND state IN ('pending','running'))",params:[auth.team_id,auth.team_id,machineId]},{sql:"UPDATE native_effects SET state='unknown',result_json=? WHERE team_id=? AND machine_id=? AND state IN ('pending','running')",params:[JSON.stringify({state:'unknown',error:null}),auth.team_id,machineId]});statements.push({sql:'DELETE FROM bindings WHERE team_id=? AND machine_id=? AND id NOT IN (SELECT binding_id FROM attachments WHERE team_id=? AND machine_id=?) AND id NOT IN (SELECT binding_id FROM native_effects WHERE team_id=? AND machine_id=? AND binding_id IS NOT NULL)',params:[auth.team_id,machineId,auth.team_id,machineId,auth.team_id,machineId]})
|
|
258
|
+
async function heartbeat(api,req,res,auth,machineId,requestId){if(auth.kind!=='machine'||auth.machine_id!==machineId)fail(403,'forbidden','exact machine credential required');const machine=await api.store.one('SELECT * FROM machines WHERE team_id=? AND id=?',[auth.team_id,machineId]);if(!machine||machine.revoked_at!=null)fail(403,'forbidden','machine enrollment is not active');const b=await parseJson(req);exact(b,['incarnation_id','sequence','bindings','roots','host_key_id','activity']);if(b.activity!==undefined&&(!Array.isArray(b.activity)||b.activity.length>4096))fail(400,'invalid_request','invalid activity');if(b.host_key_id!==undefined&&(typeof b.host_key_id!=='string'||!/^[A-Za-z0-9_-]{16,128}$/.test(b.host_key_id)))fail(400,'invalid_request','invalid host_key_id');if(typeof b.incarnation_id!=='string'||!Number.isSafeInteger(b.sequence)||b.sequence<1||!Array.isArray(b.bindings)||!Array.isArray(b.roots))fail(400,'invalid_request','invalid heartbeat');if(machine.incarnation_id===b.incarnation_id&&b.sequence<=machine.heartbeat_sequence)return send(res,200,result(api,auth,machineJson(machine,api.clock.nowMs(),api)),requestId);const now=api.clock.nowMs(),newIncarnation=machine.incarnation_id!==null&&machine.incarnation_id!==b.incarnation_id,statements=[{sql:'UPDATE machines SET incarnation_id=?,heartbeat_sequence=?,last_seen_at=?,host_key_id=COALESCE(?,host_key_id),revision=revision+1 WHERE team_id=? AND id=?',params:[b.incarnation_id,b.sequence,now,b.host_key_id??null,auth.team_id,machineId]},{sql:'DELETE FROM machine_roots WHERE team_id=? AND machine_id=?',params:[auth.team_id,machineId]}];if(newIncarnation)statements.push({sql:"UPDATE attachments SET state='closed',closed_at=? WHERE team_id=? AND machine_id=? AND state IN ('pending','active')",params:[now,auth.team_id,machineId]},{sql:"UPDATE operations SET state='unknown' WHERE team_id=? AND id IN (SELECT operation_id FROM native_effects WHERE team_id=? AND machine_id=? AND state IN ('pending','running'))",params:[auth.team_id,auth.team_id,machineId]},{sql:"UPDATE native_effects SET state='unknown',result_json=? WHERE team_id=? AND machine_id=? AND state IN ('pending','running')",params:[JSON.stringify({state:'unknown',error:null}),auth.team_id,machineId]});statements.push({sql:'DELETE FROM bindings WHERE team_id=? AND machine_id=? AND id NOT IN (SELECT binding_id FROM attachments WHERE team_id=? AND machine_id=?) AND id NOT IN (SELECT binding_id FROM native_effects WHERE team_id=? AND machine_id=? AND binding_id IS NOT NULL)',params:[auth.team_id,machineId,auth.team_id,machineId,auth.team_id,machineId]});// What each synced session's harness is doing now: this heartbeat's report replaces the source's last one.
|
|
259
|
+
if(Array.isArray(b.activity)){const source=await api.store.one("SELECT id FROM sources WHERE team_id=? AND machine_id=? AND kind='connector'",[auth.team_id,machineId]);if(source){statements.push({sql:'DELETE FROM session_activity WHERE team_id=? AND source_id=?',params:[auth.team_id,source.id]});for(const item of b.activity){exact(item,['harness','native_id','turn','since'],'activity');if(typeof item.harness!=='string'||!item.harness||item.harness.length>64||typeof item.native_id!=='string'||!item.native_id||item.native_id.length>256||typeof item.turn!=='string'||!item.turn||item.turn.length>32||(item.since!=null&&!Number.isSafeInteger(item.since)))fail(400,'invalid_request','invalid activity');statements.push({sql:'INSERT OR REPLACE INTO session_activity(team_id,source_id,harness,native_id,turn,since,observed_at) VALUES (?,?,?,?,?,?,?)',params:[auth.team_id,source.id,item.harness,item.native_id,item.turn,item.since??null,now]});}}}
|
|
260
|
+
for(const root of b.roots){exact(root,['id','machine_id','name','harnesses'],'root');if(root.machine_id!==machineId)fail(400,'invalid_request','root machine mismatch');statements.push({sql:'INSERT INTO machine_roots(team_id,machine_id,id,value_json) VALUES (?,?,?,?)',params:[auth.team_id,machineId,root.id,JSON.stringify(root)]});}for(const binding of b.bindings)statements.push({sql:'INSERT INTO bindings(team_id,id,machine_id,session_id,incarnation_id,descriptor_json,observed_at) SELECT ?,?,?,o.session_id,?,?,? FROM origins o WHERE o.team_id=? AND o.source_id=? AND o.harness=? AND o.native_id=? AND o.deleted_at IS NULL ON CONFLICT(team_id,id) DO UPDATE SET machine_id=excluded.machine_id,session_id=excluded.session_id,incarnation_id=excluded.incarnation_id,descriptor_json=excluded.descriptor_json,observed_at=excluded.observed_at',params:[auth.team_id,binding.id,machineId,b.incarnation_id,JSON.stringify(binding),now,auth.team_id,auth.source_id,binding.harness,binding.native_session_id]});const updated={...machine,incarnation_id:b.incarnation_id,heartbeat_sequence:b.sequence,last_seen_at:now,revision:machine.revision+1},response=result(api,auth,machineJson(updated,now,api)),guards=[{sql:"SELECT 1 ok FROM credentials c JOIN machines m ON m.team_id=c.team_id AND m.id=c.machine_id JOIN sources s ON s.team_id=c.team_id AND s.id=c.source_id JOIN memberships mem ON mem.team_id=m.team_id AND mem.principal_id=m.custodian_id WHERE c.id=? AND c.kind='machine' AND c.machine_id=? AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>?) AND m.revoked_at IS NULL AND mem.active=1 AND s.state!='revoked' AND (m.incarnation_id IS NULL OR m.incarnation_id!=? OR m.heartbeat_sequence<?)",params:[auth.credential_id,machineId,now,b.incarnation_id,b.sequence],code:'authentication_required',status:401,message:'machine authorization or heartbeat sequence changed'}];await guardedMutation(api,res,auth,guards,statements,{status:200,body:response,targetId:machineId});send(res,200,response,requestId);}
|
|
259
261
|
async function machineConfiguration(api,res,auth,machineId,requestId){
|
|
260
262
|
if(auth.kind!=='machine'||auth.machine_id!==machineId)fail(auth.kind==='machine'?404:403,auth.kind==='machine'?'not_found':'forbidden','not found');
|
|
261
263
|
const [machine,rules]=await api.store.snapshot([
|
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;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Schema migration 008 (SQLite DDL), applied in order by store-core migrate().
|
|
2
|
+
export default `-- Schema 8: what each synced session's harness is doing now (session view "working"). A connector reports the
|
|
3
|
+
-- activity of the sessions its rules sync with every heartbeat, replacing its source's rows; a reader takes a row as
|
|
4
|
+
-- current only while its heartbeat is fresh.
|
|
5
|
+
CREATE TABLE session_activity (
|
|
6
|
+
team_id TEXT NOT NULL, source_id TEXT NOT NULL, harness TEXT NOT NULL, native_id TEXT NOT NULL,
|
|
7
|
+
turn TEXT NOT NULL, since INTEGER, observed_at INTEGER NOT NULL,
|
|
8
|
+
PRIMARY KEY(team_id,source_id,harness,native_id)
|
|
9
|
+
) STRICT;
|
|
10
|
+
`;
|
|
@@ -6,4 +6,5 @@ import m004 from './004.mjs';
|
|
|
6
6
|
import m005 from './005.mjs';
|
|
7
7
|
import m006 from './006.mjs';
|
|
8
8
|
import m007 from './007.mjs';
|
|
9
|
-
|
|
9
|
+
import m008 from './008.mjs';
|
|
10
|
+
export const MIGRATIONS = [m001, m002, m003, m004, m005, m006, m007, m008];
|