@volter/teams 0.1.87 → 0.1.89
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/package.json +1 -1
- package/server/machine-link.mjs +66 -7
- package/viewer/viewer.mjs +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/teams",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.89",
|
|
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/machine-link.mjs
CHANGED
|
@@ -206,6 +206,12 @@ function installedGuard(access,action){
|
|
|
206
206
|
params:[access.installationId,access.teamId,access.installedAt,action],status:404,code:'not_found',message:'integration authorization changed'};
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
/**
|
|
210
|
+
* A token still unexpired after the workplace stopped admitting its access (its Task closed, its contract ended, its
|
|
211
|
+
* binding revoked) mints no grant: the workplace is asked before every open, not only by an open channel's recheck.
|
|
212
|
+
*/
|
|
213
|
+
const ACCESS_ENDED = { status: 403, code: 'access_revoked', message: 'the workplace no longer admits this access' };
|
|
214
|
+
|
|
209
215
|
async function delegatedChannel(api, teamId, machineId, token) {
|
|
210
216
|
const access = await delegatedAccessOf(api, token);
|
|
211
217
|
if (!access) return { status: 401, code: 'authentication_required', message: 'authentication required' };
|
|
@@ -214,6 +220,7 @@ async function delegatedChannel(api, teamId, machineId, token) {
|
|
|
214
220
|
if (!machine) return { status: 404, code: 'not_found', message: 'machine not found' };
|
|
215
221
|
const publication = await publicationFor(api, access);
|
|
216
222
|
if (!publication) return { status: 404, code: 'not_found', message: 'machine not found' };
|
|
223
|
+
if (await delegatedStillAdmits({ issuer: access.issuer, token }) === false) return ACCESS_ENDED;
|
|
217
224
|
const now = api.clock.nowMs(), id = api.ids.next('grant'), principal = delegatedPrincipal(access), issuer = delegatedGrantIssuer(access.issuer, publication.id);
|
|
218
225
|
try {
|
|
219
226
|
// The publication, and its publisher's standing, must still hold as the grant is written: an unpublish or a
|
|
@@ -231,7 +238,7 @@ async function delegatedChannel(api, teamId, machineId, token) {
|
|
|
231
238
|
if (error.code === 'not_found') return { status: 404, code: 'not_found', message: 'machine not found' };
|
|
232
239
|
throw error;
|
|
233
240
|
}
|
|
234
|
-
return { door: 'channel', teamId, machineId, auth: { kind: 'delegated', installation_id:access.installationId, team_id: teamId, principal_id: principal, group_ids: [], grant_id: id, grant_issuer: issuer, expires_at: access.expiresAt } };
|
|
241
|
+
return { door: 'channel', teamId, machineId, auth: { kind: 'delegated', installation_id:access.installationId, team_id: teamId, principal_id: principal, group_ids: [], grant_id: id, grant_issuer: issuer, expires_at: access.expiresAt, issuer: access.issuer, token } };
|
|
235
242
|
}
|
|
236
243
|
|
|
237
244
|
/**
|
|
@@ -247,6 +254,7 @@ async function delegatedSessionChannel(api, teamId, sessionRef, token) {
|
|
|
247
254
|
if (access.teamId !== teamId || !['view', 'control'].includes(access.scope) || access.sessionRef !== sessionRef) return unknown;
|
|
248
255
|
const target = await boundSessionTarget(api, teamId, access);
|
|
249
256
|
if (!target) return unknown;
|
|
257
|
+
if (await delegatedStillAdmits({ issuer: access.issuer, token }) === false) return ACCESS_ENDED;
|
|
250
258
|
const now = api.clock.nowMs(), id = api.ids.next('grant'), principal = delegatedPrincipal(access);
|
|
251
259
|
const issuer = `delegated:${new URL(access.issuer).host}:session:${target.sessionId}`;
|
|
252
260
|
await api.store.guardedTransaction([
|
|
@@ -256,7 +264,7 @@ async function delegatedSessionChannel(api, teamId, sessionRef, token) {
|
|
|
256
264
|
{ sql: "DELETE FROM machine_grants WHERE team_id=? AND issuer_id LIKE 'delegated:%' AND expires_at<=?", params: [teamId, now] },
|
|
257
265
|
{ sql: 'INSERT INTO machine_grants(team_id,id,machine_id,subject_id,caps_json,targets_json,issuer_id,created_at,expires_at,app_installation_id) VALUES (?,?,?,?,?,?,?,?,?,?)', params: [teamId, id, target.machineId, principal, JSON.stringify(access.scope === 'control' ? ['observe', 'interact'] : ['observe']), JSON.stringify([`session:${target.harness}/${target.nativeId}`]), issuer, now, access.expiresAt, access.installationId] },
|
|
258
266
|
]).catch((error) => { if (error.code !== 'not_found') throw error; return null; });
|
|
259
|
-
return { door: 'channel', teamId, machineId: target.machineId, auth: { kind: 'delegated', installation_id:access.installationId, team_id: teamId, principal_id: principal, group_ids: [], grant_id: id, grant_issuer: issuer, expires_at: access.expiresAt, name: access.name, roomContext: access.roomContext } };
|
|
267
|
+
return { door: 'channel', teamId, machineId: target.machineId, auth: { kind: 'delegated', installation_id:access.installationId, team_id: teamId, principal_id: principal, group_ids: [], grant_id: id, grant_issuer: issuer, expires_at: access.expiresAt, name: access.name, roomContext: access.roomContext, issuer: access.issuer, token } };
|
|
260
268
|
}
|
|
261
269
|
|
|
262
270
|
/**
|
|
@@ -264,21 +272,49 @@ async function delegatedSessionChannel(api, teamId, sessionRef, token) {
|
|
|
264
272
|
* the harness session the token names. Null unless the session's owner bound it to the token's Room and Conversation.
|
|
265
273
|
*/
|
|
266
274
|
async function boundSessionTarget(api, teamId, access) {
|
|
275
|
+
return (await sessionDeliveryResolution(api, teamId, access)).target;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The same delivery checks, explainable only through a session custodian's authenticated door.
|
|
279
|
+
* No token is accepted and no grant, binding or native message is created by this read.
|
|
280
|
+
*/
|
|
281
|
+
export async function sessionDeliveryResolution(api, teamId, access, diagnostics = false) {
|
|
267
282
|
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]);
|
|
283
|
+
let reason = 'session_not_found', origins = null;
|
|
268
284
|
for (const found of named) {
|
|
269
285
|
let root = found;
|
|
270
286
|
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]);
|
|
271
|
-
if (!root || root.canonical_id) continue;
|
|
287
|
+
if (!root || root.canonical_id) { reason = 'session_family_unresolved'; continue; }
|
|
272
288
|
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]);
|
|
273
|
-
if (!bound) continue;
|
|
289
|
+
if (!bound) { reason = 'binding_not_found'; continue; }
|
|
274
290
|
const origin = await api.store.one(`SELECT o.harness, o.native_id, o.session_id, src.machine_id FROM origins o
|
|
275
291
|
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
|
|
276
292
|
JOIN machines m ON m.team_id=src.team_id AND m.id=src.machine_id AND m.revoked_at IS NULL
|
|
277
293
|
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=?))
|
|
278
294
|
ORDER BY (o.native_id=?) DESC, o.sequence DESC LIMIT 1`, [teamId, teamId, root.id, root.id, access.sessionRef]);
|
|
279
|
-
if (origin)
|
|
295
|
+
if (origin) {
|
|
296
|
+
const target = { sessionId: root.id, harness: origin.harness, nativeId: origin.native_id, machineId: origin.machine_id };
|
|
297
|
+
return { state: 'ready', reason: null, target };
|
|
298
|
+
}
|
|
299
|
+
reason = 'no_live_origin';
|
|
300
|
+
if (diagnostics) {
|
|
301
|
+
// LEFT joins retain the row that the delivery's inner joins discarded.
|
|
302
|
+
const rows = await api.store.query(`SELECT o.harness, o.source_id, o.deleted_at, src.state source_state, src.machine_id,
|
|
303
|
+
m.id enrolled_machine, m.revoked_at machine_revoked_at FROM origins o
|
|
304
|
+
LEFT JOIN sources src ON src.team_id=o.team_id AND src.id=o.source_id
|
|
305
|
+
LEFT JOIN machines m ON m.team_id=src.team_id AND m.id=src.machine_id
|
|
306
|
+
WHERE o.team_id=? AND o.session_id IN (SELECT id FROM sessions WHERE team_id=? AND deleted_at IS NULL AND (id=? OR canonical_id=?))
|
|
307
|
+
ORDER BY o.sequence DESC LIMIT 51`, [teamId, teamId, root.id, root.id]);
|
|
308
|
+
const live = rows.filter(row => row.deleted_at == null), active = live.filter(row => row.source_state === 'active');
|
|
309
|
+
const attached = active.filter(row => row.machine_id != null), enrolled = attached.filter(row => row.enrolled_machine != null);
|
|
310
|
+
reason = !rows.length ? 'origin_not_found' : !live.length ? 'origin_deleted' : !active.length ? 'source_not_active'
|
|
311
|
+
: !attached.length ? 'source_without_machine' : !enrolled.length ? 'machine_not_found' : 'machine_revoked';
|
|
312
|
+
origins = { truncated: rows.length > 50, items: rows.slice(0,50).map(row => ({ harness: row.harness,
|
|
313
|
+
source_id: row.source_id, source_state: row.source_state, origin_deleted: row.deleted_at != null,
|
|
314
|
+
machine_id: row.machine_id, machine_present: row.enrolled_machine != null, machine_revoked: row.machine_revoked_at != null })) };
|
|
315
|
+
}
|
|
280
316
|
}
|
|
281
|
-
return null;
|
|
317
|
+
return { state: 'unavailable', reason, target: null, ...(origins ? { origins } : {}) };
|
|
282
318
|
}
|
|
283
319
|
|
|
284
320
|
/** Close every delegated channel whose grant came from one publication (issuer from delegatedGrantIssuer): it was unpublished. */
|
|
@@ -335,6 +371,22 @@ async function acceptLink(api, teamId, machineId, duplex, closeSocket) {
|
|
|
335
371
|
}
|
|
336
372
|
|
|
337
373
|
const LINK_WAIT_MS = 10_000;
|
|
374
|
+
/** How often an open delegated channel asks its workplace whether the token's access still holds. */
|
|
375
|
+
const DELEGATED_RECHECK_MS = 15_000;
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Whether the workplace that issued a delegated token still admits the access it was issued for (RH2's status door,
|
|
379
|
+
* `POST <issuer>/api/v3/delegated/status`): false only on the workplace's own answer. An unreachable workplace or an
|
|
380
|
+
* unreadable answer is not a refusal; an open then proceeds and its channel ends at the token's expiry.
|
|
381
|
+
*/
|
|
382
|
+
async function delegatedStillAdmits(auth) {
|
|
383
|
+
try {
|
|
384
|
+
const response = await fetch(`${auth.issuer}/api/v3/delegated/status`, { body: JSON.stringify({ token: auth.token }), headers: { accept: 'application/json', 'content-type': 'application/json' }, method: 'POST', signal: AbortSignal.timeout(10_000) });
|
|
385
|
+
if (!response.ok) return null;
|
|
386
|
+
const { data } = await response.json();
|
|
387
|
+
return typeof data?.live === 'boolean' ? data.live : null;
|
|
388
|
+
} catch { return null; }
|
|
389
|
+
}
|
|
338
390
|
/** How long a typed line is retried on a machine that is linked but too loaded to open its stream. */
|
|
339
391
|
const TURN_RETRY_MS = 120_000;
|
|
340
392
|
|
|
@@ -362,8 +414,15 @@ async function acceptChannel(api, auth, machineId, duplex, closeSocket) {
|
|
|
362
414
|
channels(api).add(entry);
|
|
363
415
|
// A delegated channel ends when its token does; the viewer reopens it with a fresh one.
|
|
364
416
|
const expiry = (auth.kind === 'delegated'||(auth.installation_id&&auth.expires_at-api.clock.nowMs()<=2147483647)) ? setTimeout(() => entry.close('token_expired'), Math.max(0, auth.expires_at - api.clock.nowMs())) : null;
|
|
417
|
+
// It also ends when the workplace stops admitting the access the token was issued for (a binding revoked, a Task
|
|
418
|
+
// closed, a contract ended): an act already under way ends with its access, and the grant goes with it.
|
|
419
|
+
const recheck = auth.kind === 'delegated' && auth.token ? setInterval(() => void delegatedStillAdmits(auth).then(async (live) => {
|
|
420
|
+
if (live !== false) return;
|
|
421
|
+
await api.store.transaction([{ sql: 'DELETE FROM machine_grants WHERE team_id=? AND id=?', params: [auth.team_id, auth.grant_id] }]).catch(() => {});
|
|
422
|
+
entry.close('access_revoked');
|
|
423
|
+
}), DELEGATED_RECHECK_MS) : null;
|
|
365
424
|
channel.on('error', () => {});
|
|
366
|
-
channel.on('close', () => { if (expiry) clearTimeout(expiry); channels(api).delete(entry); entry.dropStreams('caller closed'); });
|
|
425
|
+
channel.on('close', () => { if (expiry) clearTimeout(expiry); if (recheck) clearInterval(recheck); channels(api).delete(entry); entry.dropStreams('caller closed'); });
|
|
367
426
|
channel.on('stream', async (stream, accept, refuse) => {
|
|
368
427
|
const claim = await claimFor(api, auth, machineId).catch(() => null);
|
|
369
428
|
if (!claim) return refuse('no machine grant for this caller');
|
package/viewer/viewer.mjs
CHANGED
|
@@ -238,6 +238,7 @@ async function showPane(connect, key, control) {
|
|
|
238
238
|
pane = null;
|
|
239
239
|
channel.close();
|
|
240
240
|
// The server ends a channel when its token does: reopen with a fresh one. Any other close is shown and stays.
|
|
241
|
+
if (/access_revoked/.test(String(reason))) { status('Access ended: the workplace no longer grants this pane.'); return; }
|
|
241
242
|
if (!/token_expired/.test(String(reason))) { status(`Disconnected: ${reason ?? 'closed'}`); return; }
|
|
242
243
|
status('Reconnecting…');
|
|
243
244
|
attach().catch((error) => status(error.message));
|