@volter/teams 0.2.44 → 0.2.45
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.d.ts +6 -0
- package/browser-channel.mjs +1 -0
- package/package.json +1 -1
- package/server/apps.mjs +1 -1
- package/server/delegated.mjs +14 -1
- package/server/machine-link.mjs +40 -9
- package/session-follow.mjs +4 -1
package/browser-channel.d.ts
CHANGED
|
@@ -15,6 +15,12 @@ export interface BrowserStream extends ChannelEvents {
|
|
|
15
15
|
close(reason?: string | null): void;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** The node's request/reply doors over an admitted stream. */
|
|
19
|
+
export class StreamRpc {
|
|
20
|
+
constructor(stream: BrowserStream);
|
|
21
|
+
call<T = unknown>(method: string, params?: Record<string, unknown>, options?: { timeoutMs?: number }): Promise<T>;
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
/** A browser's channel to one machine, through its Teams server. Events: `hello` (the machine's greeting), `close`. */
|
|
19
25
|
export declare class BrowserChannel implements ChannelEvents {
|
|
20
26
|
constructor(socket: WebSocket);
|
package/browser-channel.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// browser WebSocket, with no Node built-ins. A browser cannot set headers on a
|
|
4
4
|
// WebSocket, so the caller's bearer rides as a `bearer.<token>` subprotocol
|
|
5
5
|
// beside the door's own; the server never echoes it back.
|
|
6
|
+
export { StreamRpc } from './stream-rpc.mjs';
|
|
6
7
|
const PROTOCOL = 'supercode-machine/1';
|
|
7
8
|
const NODE_PROTOCOL = 2;
|
|
8
9
|
const CHANNEL_PROTOCOL = 'supercode-machine-channel.v1';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/teams",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.45",
|
|
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/apps.mjs
CHANGED
|
@@ -15,7 +15,7 @@ export const APP_ACTIONS = Object.freeze([
|
|
|
15
15
|
'teams.read', 'members.read', 'members.write', 'groups.read', 'groups.write',
|
|
16
16
|
'sessions.read', 'sessions.write', 'sessions.control', 'sessions.access',
|
|
17
17
|
'machines.read', 'machines.control', 'machines.write', 'machines.access', 'sources.read', 'sources.write',
|
|
18
|
-
'identity.login', 'delegation.surfaces', 'delegation.sessions', 'delegation.notices',
|
|
18
|
+
'identity.login', 'delegation.surfaces', 'delegation.sessions', 'delegation.notices', 'delegation.machines',
|
|
19
19
|
'events.read', 'events.publish',
|
|
20
20
|
]);
|
|
21
21
|
const TOKEN_MS = 90 * 86400000, CODE_MS = 10 * 60000, DELEGATION_MS = 15 * 60000;
|
package/server/delegated.mjs
CHANGED
|
@@ -109,6 +109,19 @@ export async function delegatedSessionOf(api, token) {
|
|
|
109
109
|
return { installationId:claims.installation_id, installedAt:claims.installed_at, roomContext, 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 };
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Who a workplace's principal is, to work on a machine its custodian granted them (RH2's machine token: a rented machine,
|
|
114
|
+
* RH2 RFC 0017 §4), or null: { subject, issuer, teamId, machineId, expiresAt }. It grants nothing itself: the channel
|
|
115
|
+
* opens only on a live `operate` grant the machine's custodian gave that delegated principal (machine-link.mjs).
|
|
116
|
+
*/
|
|
117
|
+
export async function delegatedMachineOf(api, token) {
|
|
118
|
+
const claims = await verifiedClaims(api, token, 'delegation.machines');
|
|
119
|
+
if (!claims || claims.scope !== 'operate' || typeof claims.machine?.team !== 'string' || typeof claims.machine?.id !== 'string') return null;
|
|
120
|
+
const now = Math.floor(api.clock.nowMs() / 1000);
|
|
121
|
+
if (typeof claims.iat !== 'number' || claims.iat > now + SKEW_S || claims.exp - claims.iat > MAX_LIFETIME_S || claims.exp - now > MAX_LIFETIME_S + SKEW_S) return null;
|
|
122
|
+
return { installationId: claims.installation_id, installedAt: claims.installed_at, expiresAt: claims.exp * 1000, issuer: claims.iss, machineId: claims.machine.id, subject: claims.sub, teamId: claims.machine.team };
|
|
123
|
+
}
|
|
124
|
+
|
|
112
125
|
/** A signed Room lifecycle notice. This scope grants no transcript read or user-turn control. */
|
|
113
126
|
export async function delegatedRoomNoticeOf(api, token) {
|
|
114
127
|
const c = await verifiedClaims(api, token, 'delegation.notices');
|
|
@@ -135,7 +148,7 @@ async function verifiedClaims(api, token, action) {
|
|
|
135
148
|
// Only installed issuer origins may select keys; an untrusted claim never triggers a fetch.
|
|
136
149
|
// Cache by issuer as well as kid so staging cannot reuse a production key identifier.
|
|
137
150
|
const claims = json(body);
|
|
138
|
-
const teamId=claims.surface?.config?.team??claims.session?.team;
|
|
151
|
+
const teamId=claims.surface?.config?.team??claims.session?.team??claims.machine?.team;
|
|
139
152
|
if(typeof teamId!=='string'||claims.aud!==api.publicOrigin)return null;
|
|
140
153
|
const installation=await installedIssuer(api,claims.iss,action,teamId);if(!installation||!Number.isFinite(claims.iat)||claims.iat<Math.floor(installation.created_at/1000))return null;
|
|
141
154
|
const key = await issuerKey(api, claims.iss, header.kid);
|
package/server/machine-link.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appAuthentication } from './apps.mjs';
|
|
1
|
+
import { appAuthentication, installedIssuer } from './apps.mjs';
|
|
2
2
|
// Machine doors through the server (design §11).
|
|
3
3
|
//
|
|
4
4
|
// GET /api/v1/teams/{team}/machines/{id}/link the machine's one outbound link
|
|
@@ -22,7 +22,7 @@ import { machineGrantProblem } from '../admission.mjs';
|
|
|
22
22
|
import { lineSplitter } from '../lines.mjs';
|
|
23
23
|
import { APP_LINEAGE, APP_LINEAGE_REVOKED, bearer } from './auth.mjs';
|
|
24
24
|
import { AGENT_ACTIVE } from './agent-authority.mjs';
|
|
25
|
-
import { delegatedAccessOf, delegatedPrincipal, delegatedSessionOf, looksDelegated, surfaceCaps } from './delegated.mjs';
|
|
25
|
+
import { delegatedAccessOf, delegatedMachineOf, delegatedPrincipal, delegatedSessionOf, looksDelegated, surfaceCaps } from './delegated.mjs';
|
|
26
26
|
import { delegatedGrantIssuer, publicationFor } from './publications.mjs';
|
|
27
27
|
|
|
28
28
|
const LINK_PROTOCOL = 'supercode-machine-link.v1';
|
|
@@ -83,7 +83,12 @@ export async function agentMachineClaim(api, auth, machineId) {
|
|
|
83
83
|
/** What the caller may claim on the machine right now, or null (indistinguishable from no machine). */
|
|
84
84
|
async function claimFor(api, auth, machineId) {
|
|
85
85
|
if (auth.agent_credential) return agentMachineClaim(api, auth, machineId);
|
|
86
|
-
if(auth.installation_id&&auth.kind!=='delegated'&&!await appAuthentication(api,auth))return null;
|
|
86
|
+
if(auth.installation_id&&auth.kind!=='delegated'&&auth.kind!=='delegated-machine'&&!await appAuthentication(api,auth))return null;
|
|
87
|
+
// A workplace principal on a machine its custodian granted them: exactly that grant, while it lasts.
|
|
88
|
+
if (auth.kind === 'delegated-machine') {
|
|
89
|
+
const row = await api.store.one('SELECT id, revision FROM machine_grants WHERE team_id=? AND machine_id=? AND id=? AND subject_id=? AND expires_at>?', [auth.team_id, machineId, auth.grant_id, auth.principal_id, api.clock.nowMs()]);
|
|
90
|
+
return row ? { principal: auth.principal_id, groups: [], grants: [row.id], revisions: { [row.id]: row.revision } } : null;
|
|
91
|
+
}
|
|
87
92
|
if (auth.kind === 'delegated') {
|
|
88
93
|
const row = await api.store.one(`SELECT g.id,g.revision FROM machine_grants g JOIN app_installations i ON i.id=g.app_installation_id JOIN apps a ON a.id=i.app_id
|
|
89
94
|
JOIN memberships installer ON installer.team_id=i.team_id AND installer.principal_id=i.installer_id
|
|
@@ -183,8 +188,12 @@ export async function machineGrantRoute({ api, req, res, auth, machineId, grantI
|
|
|
183
188
|
const body = await parseJson(req); exact(body, ['subject_id', 'caps', 'targets', 'expires_at']);
|
|
184
189
|
const now = api.clock.nowMs();
|
|
185
190
|
const subject = typeof body.subject_id === 'string' ? await api.store.one(`SELECT p.kind FROM memberships m JOIN principals p ON p.id=m.principal_id WHERE m.team_id=? AND m.principal_id=? AND m.active=1 UNION ALL SELECT 'group' kind FROM groups_table WHERE team_id=? AND id=?`, [auth.team_id, body.subject_id, auth.team_id, body.subject_id]) : null;
|
|
186
|
-
|
|
187
|
-
|
|
191
|
+
// A workplace's principal (a run's agent that rented the machine, RH2 RFC 0017 §4), named as the workplace names it to
|
|
192
|
+
// this server: only a workplace this team installed to name principals on machines (`delegation.machines`).
|
|
193
|
+
const delegated = !subject && typeof body.subject_id === 'string' ? /^delegated:([^:]+):(.+)$/.exec(body.subject_id) : null;
|
|
194
|
+
const workplace = delegated ? (await installedIssuer(api, `https://${delegated[1]}`, 'delegation.machines', auth.team_id)) ?? (await installedIssuer(api, `http://${delegated[1]}`, 'delegation.machines', auth.team_id)) : null;
|
|
195
|
+
if (!subject && !workplace) fail(400, 'invalid_request', 'subject must be an active member, service account or group of this team, or a principal of a workplace it installed for machines');
|
|
196
|
+
const problem = machineGrantProblem({ ...body, created_at: now }, { agent: subject?.kind === 'agent', lifetime: true, now });
|
|
188
197
|
if (problem) fail(400, 'invalid_request', problem);
|
|
189
198
|
const id = api.ids.next('grant');
|
|
190
199
|
const row = { id, machine_id: machineId, subject_id: body.subject_id, caps: body.caps, targets: body.targets, issuer_id: auth.principal_id, created_at: now, expires_at: body.expires_at, revision: 1 };
|
|
@@ -267,7 +276,7 @@ const ACCESS_ENDED = { status: 403, code: 'access_revoked', message: 'the workpl
|
|
|
267
276
|
|
|
268
277
|
async function delegatedChannel(api, teamId, machineId, token) {
|
|
269
278
|
const access = await delegatedAccessOf(api, token);
|
|
270
|
-
if (!access) return
|
|
279
|
+
if (!access) return await delegatedMachineChannel(api, teamId, machineId, token);
|
|
271
280
|
if (access.teamId !== teamId || access.machineId !== machineId) return { status: 404, code: 'not_found', message: 'machine not found' };
|
|
272
281
|
const machine = await api.store.one('SELECT 1 ok FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', [teamId, machineId]);
|
|
273
282
|
if (!machine) return { status: 404, code: 'not_found', message: 'machine not found' };
|
|
@@ -294,6 +303,27 @@ async function delegatedChannel(api, teamId, machineId, token) {
|
|
|
294
303
|
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 } };
|
|
295
304
|
}
|
|
296
305
|
|
|
306
|
+
/**
|
|
307
|
+
* A workplace principal on a machine its custodian granted them (a rented machine, RH2 RFC 0017 §4: "`operate` on a
|
|
308
|
+
* machine in the seller's supercode Team, granted to the run agent's RH2-issued identity"). The token only says who they
|
|
309
|
+
* are; what they may do is the custodian's own grant to that delegated principal (`delegated:<issuer host>:<subject>`),
|
|
310
|
+
* for as long as it lasts, and the workplace is asked whether it still admits them before the open and while the channel
|
|
311
|
+
* lives. No such grant is no machine.
|
|
312
|
+
*/
|
|
313
|
+
async function delegatedMachineChannel(api, teamId, machineId, token) {
|
|
314
|
+
const access = await delegatedMachineOf(api, token);
|
|
315
|
+
if (!access) return { status: 401, code: 'authentication_required', message: 'authentication required' };
|
|
316
|
+
if (access.teamId !== teamId || access.machineId !== machineId) return { status: 404, code: 'not_found', message: 'machine not found' };
|
|
317
|
+
const principal = delegatedPrincipal(access);
|
|
318
|
+
const grant = await api.store.one(`SELECT g.id, g.expires_at FROM machine_grants g JOIN machines m ON m.team_id=g.team_id AND m.id=g.machine_id AND m.revoked_at IS NULL
|
|
319
|
+
WHERE g.team_id=? AND g.machine_id=? AND g.subject_id=? AND g.expires_at>? AND g.issuer_id NOT LIKE 'delegated:%' ORDER BY g.expires_at DESC LIMIT 1`, [teamId, machineId, principal, api.clock.nowMs()]);
|
|
320
|
+
if (!grant) return { status: 404, code: 'not_found', message: 'machine not found' };
|
|
321
|
+
try { await api.store.guardedTransaction([installedGuard(access, 'delegation.machines')], []); }
|
|
322
|
+
catch (error) { if (error.code === 'not_found') return { status: 404, code: 'not_found', message: 'machine not found' }; throw error; }
|
|
323
|
+
if (await delegatedStillAdmits({ issuer: access.issuer, token }) === false) return ACCESS_ENDED;
|
|
324
|
+
return { door: 'channel', teamId, machineId, auth: { kind: 'delegated-machine', installation_id: access.installationId, team_id: teamId, principal_id: principal, group_ids: [], grant_id: grant.id, expires_at: Number(grant.expires_at), issuer: access.issuer, token } };
|
|
325
|
+
}
|
|
326
|
+
|
|
297
327
|
/**
|
|
298
328
|
* A session-view token's channel (RH2 ADR 0008): the member's own browser types a turn into one session its owner
|
|
299
329
|
* bound to the token's Room and Conversation. Both scopes can observe its mail; only `control` can interact with that
|
|
@@ -536,7 +566,7 @@ async function acceptChannel(api, auth, machineId, duplex, closeSocket) {
|
|
|
536
566
|
};
|
|
537
567
|
channels(api).add(entry);
|
|
538
568
|
// A delegated channel ends when its token does; the viewer reopens it with a fresh one.
|
|
539
|
-
const expiry = (auth.kind === 'delegated'||auth.agent_credential||(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;
|
|
569
|
+
const expiry = (auth.kind === 'delegated'||auth.kind === 'delegated-machine'||auth.agent_credential||(auth.installation_id&&auth.expires_at-api.clock.nowMs()<=2147483647)) ? setTimeout(() => entry.close('token_expired'), Math.min(2147483647, Math.max(0, auth.expires_at - api.clock.nowMs()))) : null;
|
|
540
570
|
const armGrantExpiry = claim => {
|
|
541
571
|
if (!Number.isSafeInteger(claim.expires_at) || claim.expires_at >= grantDeadline) return;
|
|
542
572
|
grantDeadline = claim.expires_at;
|
|
@@ -550,9 +580,10 @@ async function acceptChannel(api, auth, machineId, duplex, closeSocket) {
|
|
|
550
580
|
.catch(() => entry.close('access_unknown')), 5000) : null;
|
|
551
581
|
// It also ends when the workplace stops admitting the access the token was issued for (a binding revoked, a Task
|
|
552
582
|
// closed, a contract ended): an act already under way ends with its access, and the grant goes with it.
|
|
553
|
-
const recheck = auth.kind === 'delegated' && auth.token ? setInterval(() => void delegatedStillAdmits(auth).then(async (live) => {
|
|
583
|
+
const recheck = (auth.kind === 'delegated' || auth.kind === 'delegated-machine') && auth.token ? setInterval(() => void delegatedStillAdmits(auth).then(async (live) => {
|
|
554
584
|
if (live !== false) return;
|
|
555
|
-
|
|
585
|
+
// A delegated grant goes with its access; a custodian's own grant (a rented machine's) is the custodian's to revoke.
|
|
586
|
+
if (auth.kind === 'delegated') await api.store.transaction([{ sql: 'DELETE FROM machine_grants WHERE team_id=? AND id=?', params: [auth.team_id, auth.grant_id] }]).catch(() => {});
|
|
556
587
|
entry.close('access_revoked');
|
|
557
588
|
}), DELEGATED_RECHECK_MS) : null;
|
|
558
589
|
channel.on('error', () => {});
|
package/session-follow.mjs
CHANGED
|
@@ -167,10 +167,13 @@ const automationImage = seed => `https://brand.volter.ai/bot/svg?seed=${encodeUR
|
|
|
167
167
|
* A Room member's words, without the envelope Teams types ahead of them into the session ('[Room "<name>" ("room_…");
|
|
168
168
|
* sender "<name>" ("prn_…")] ' then the words). Only that exact quote is taken off, and only when the sender it names is
|
|
169
169
|
* the principal recorded as typing the turn; any other text, a forged or quoted envelope included, is shown as typed.
|
|
170
|
+
* A member who typed through their Room's delegated token is recorded as `delegated:<issuer host>:<principal>`
|
|
171
|
+
* (delegatedPrincipal), which is that same sender.
|
|
170
172
|
*/
|
|
171
173
|
function roomWords(text, principal) {
|
|
172
174
|
const m = /^\s*\[Room "(?:[^"\\]|\\.)*" \("room_[A-Za-z0-9]+"\); sender "(?:[^"\\]|\\.)*" \("(prn_[A-Za-z0-9]+)"\)\]\s*/.exec(String(text ?? ''));
|
|
173
|
-
|
|
175
|
+
const typist = typeof principal === 'string' && principal.startsWith('delegated:') ? principal.slice(principal.lastIndexOf(':') + 1) : principal;
|
|
176
|
+
return m && typist && m[1] === typist ? String(text).slice(m[0].length) : text;
|
|
174
177
|
}
|
|
175
178
|
|
|
176
179
|
export function withPeople(transcript, messages, { agentName = null, sessionId = '', personImage: person = personImage, agentImage: agent = agentImage, locale = [] } = {}) {
|