@volter/teams 0.1.88 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/teams",
3
- "version": "0.1.88",
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",
@@ -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
  /**
@@ -363,6 +371,22 @@ async function acceptLink(api, teamId, machineId, duplex, closeSocket) {
363
371
  }
364
372
 
365
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
+ }
366
390
  /** How long a typed line is retried on a machine that is linked but too loaded to open its stream. */
367
391
  const TURN_RETRY_MS = 120_000;
368
392
 
@@ -390,8 +414,15 @@ async function acceptChannel(api, auth, machineId, duplex, closeSocket) {
390
414
  channels(api).add(entry);
391
415
  // A delegated channel ends when its token does; the viewer reopens it with a fresh one.
392
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;
393
424
  channel.on('error', () => {});
394
- 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'); });
395
426
  channel.on('stream', async (stream, accept, refuse) => {
396
427
  const claim = await claimFor(api, auth, machineId).catch(() => null);
397
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));