@volter/teams 0.2.43 → 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.
@@ -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);
@@ -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.43",
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/redact.mjs CHANGED
@@ -134,23 +134,29 @@ const anyKeyword = new RegExp([...new Set(RULES.flatMap((rule) => rule.keywords)
134
134
  const CUSTODY_LOOK_MS = 30_000;
135
135
  let custodyCache = null, custodyStamp = null, custodyLookedAt = 0;
136
136
  const stampOf = (file) => { try { const s = statSync(file); return `${s.ino}:${s.size}:${s.mtimeMs}`; } catch { return '-'; } };
137
- function custodyFiles(env) {
137
+ const custodyUnreadable=()=>Object.assign(new Error('a declared custody file or its registry could not be read; capture transfer refused'),{code:'custody_unreadable'});
138
+ function custodyFiles(env, fresh) {
138
139
  const home = env.SUPERCODE_HOME || join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'supercode');
139
140
  let files = String(env.SUPERCODE_CUSTODY_FILES ?? '').split(delimiter).filter(Boolean);
140
- try { files = files.concat(JSON.parse(readFileSync(join(home, 'custody.json'), 'utf8')).files ?? []); } catch { /* none named */ }
141
+ try {
142
+ const named=JSON.parse(readFileSync(join(home, 'custody.json'), 'utf8')).files??[];
143
+ if(fresh&&(!Array.isArray(named)||named.some(file=>typeof file!=='string')))throw custodyUnreadable();
144
+ files=files.concat(named);
145
+ } catch(error) { if(fresh&&error?.code!=='ENOENT')throw custodyUnreadable(); }
141
146
  return { files, stamp: [join(home, 'custody.json'), ...files].map(stampOf).join('|') };
142
147
  }
143
- /** Each custody value (at least 8 characters) with the variable that holds it, longest first. */
144
- export function custodyValues(env = process.env) {
148
+ /** Each custody value (at least 8 characters) with the variable that holds it, longest first. `fresh` bypasses both
149
+ * the interval and stat memo and refuses unreadable declared files or a malformed/unreadable registry. */
150
+ export function custodyValues(env = process.env, { fresh = false } = {}) {
145
151
  const now = Date.now();
146
- if (custodyCache && now - custodyLookedAt < CUSTODY_LOOK_MS) return custodyCache;
152
+ if (!fresh && custodyCache && now - custodyLookedAt < CUSTODY_LOOK_MS) return custodyCache;
147
153
  custodyLookedAt = now;
148
- const { files, stamp } = custodyFiles(env);
149
- if (custodyCache && stamp === custodyStamp) return custodyCache;
154
+ const { files, stamp } = custodyFiles(env, fresh);
155
+ if (!fresh && custodyCache && stamp === custodyStamp) return custodyCache;
150
156
  custodyStamp = stamp;
151
157
  const values = new Map();
152
158
  for (const file of files) {
153
- let text = ''; try { if (existsSync(file)) text = readFileSync(file, 'utf8'); } catch { continue; }
159
+ let text = ''; try { if (fresh||existsSync(file)) text = readFileSync(file, 'utf8'); } catch { if(fresh)throw custodyUnreadable();continue; }
154
160
  for (const line of text.split('\n')) {
155
161
  const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
156
162
  if (!m) continue;
@@ -169,10 +175,10 @@ export function custodyValues(env = process.env) {
169
175
  let custodyBasis = null;
170
176
  /** What a redaction was made under (the rule set and the custody values), as a digest: text found clean under one
171
177
  * basis is clean under it again, and needs another look only when it changes. */
172
- export function redactionBasis(env = process.env) { custodyValues(env); return custodyBasis; }
178
+ export function redactionBasis(env = process.env, options) { custodyValues(env, options); return custodyBasis; }
173
179
  /** One custody observation and its basis, for a batch that must not change policy between its values. */
174
- export function redactionSnapshot(env = process.env) {
175
- const values = custodyValues(env);
180
+ export function redactionSnapshot(env = process.env, options) {
181
+ const values = custodyValues(env, options);
176
182
  return { values, basis: custodyBasis };
177
183
  }
178
184
 
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;
@@ -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);
@@ -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
- if (!subject) fail(400, 'invalid_request', 'subject must be an active member, service account or group of this team');
187
- const problem = machineGrantProblem({ ...body, created_at: now }, { agent: subject.kind === 'agent', lifetime: true, now });
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 { status: 401, code: 'authentication_required', message: 'authentication required' };
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
- await api.store.transaction([{ sql: 'DELETE FROM machine_grants WHERE team_id=? AND id=?', params: [auth.team_id, auth.grant_id] }]).catch(() => {});
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', () => {});
@@ -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
- return m && principal && m[1] === principal ? String(text).slice(m[0].length) : text;
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 = [] } = {}) {