@volter/teams 0.1.88 → 0.1.90
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 +34 -3
- package/viewer/viewer.mjs +26 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/teams",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.90",
|
|
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
|
/**
|
|
@@ -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
|
@@ -224,7 +224,27 @@ async function showPane(connect, key, control) {
|
|
|
224
224
|
fit.fit();
|
|
225
225
|
let pane = null;
|
|
226
226
|
// Only a controller sizes the pane; an observer sees it at the size its controller keeps.
|
|
227
|
-
|
|
227
|
+
// xterm fitting changes layout. Defer it out of the observer's delivery cycle
|
|
228
|
+
// so a normal pane resize does not become a browser ResizeObserver failure.
|
|
229
|
+
let fitFrame = null;
|
|
230
|
+
let sentSize = null;
|
|
231
|
+
const observer = new ResizeObserver(() => {
|
|
232
|
+
if (fitFrame !== null) return;
|
|
233
|
+
fitFrame = requestAnimationFrame(() => {
|
|
234
|
+
fitFrame = null;
|
|
235
|
+
fit.fit();
|
|
236
|
+
const size = `${terminal.cols}x${terminal.rows}`;
|
|
237
|
+
if (control && pane && size !== sentSize) {
|
|
238
|
+
sentSize = size;
|
|
239
|
+
pane.resize(terminal.cols, terminal.rows);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
observer.observe(element);
|
|
244
|
+
window.addEventListener('pagehide', () => {
|
|
245
|
+
observer.disconnect();
|
|
246
|
+
if (fitFrame !== null) cancelAnimationFrame(fitFrame);
|
|
247
|
+
}, { once: true });
|
|
228
248
|
if (control) terminal.onData((data) => pane?.write(data));
|
|
229
249
|
const attach = async () => {
|
|
230
250
|
status('Connecting…');
|
|
@@ -237,10 +257,12 @@ async function showPane(connect, key, control) {
|
|
|
237
257
|
pane.on('close', (reason) => {
|
|
238
258
|
pane = null;
|
|
239
259
|
channel.close();
|
|
240
|
-
//
|
|
241
|
-
if (
|
|
260
|
+
// Revocation stays ended. A fresh manual retry asks the workplace for authority again.
|
|
261
|
+
if (/access_revoked/.test(String(reason))) { failed('terminal', new Error('Access ended: the workplace no longer grants this pane.')); return; }
|
|
262
|
+
// Token expiry reconnects; other closes reach the host's existing unavailable / retry state.
|
|
263
|
+
if (!/token_expired/.test(String(reason))) { failed('terminal', new Error(`Disconnected: ${reason ?? 'closed'}`)); return; }
|
|
242
264
|
status('Reconnecting…');
|
|
243
|
-
attach().catch((error) =>
|
|
265
|
+
attach().catch((error) => failed('terminal', error));
|
|
244
266
|
});
|
|
245
267
|
};
|
|
246
268
|
await attach();
|