@volter/teams 0.1.51 → 0.1.53
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/machine-core.mjs +1 -1
- package/package.json +1 -1
- package/server/delegated.mjs +18 -1
- package/server/machine-link.mjs +39 -2
package/machine-core.mjs
CHANGED
|
@@ -244,7 +244,7 @@ export class MachineCore extends EventEmitter {
|
|
|
244
244
|
}
|
|
245
245
|
const index = this.nextConnection++;
|
|
246
246
|
// A delegated caller's claim also carries the name its workplace signed for it (who typed a turn).
|
|
247
|
-
const entry = { channel, admitted, principal: claim.principal, name: typeof claim.name === 'string' ? claim.name : null, verify: () => this.#admitClaim(claim), localAdmission: () => admitFromGrants(claim, { custodianId: this.custodianId, grants: this.linkGrants }) };
|
|
247
|
+
const entry = { channel, admitted, principal: claim.principal, name: typeof claim.name === 'string' ? claim.name : null, roomContext: claim.roomContext ?? null, verify: () => this.#admitClaim(claim), localAdmission: () => admitFromGrants(claim, { custodianId: this.custodianId, grants: this.linkGrants }) };
|
|
248
248
|
this.connections.set(index, entry);
|
|
249
249
|
stream.on('close', () => { this.connections.delete(index); this.connectionClosed(index); });
|
|
250
250
|
this.log.append({ principal: entry.principal, cap: 'connect', target: '*', outcome: admitted.class, detail: 'server' });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/teams",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.53",
|
|
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/delegated.mjs
CHANGED
|
@@ -102,7 +102,24 @@ export async function delegatedSessionOf(api, token) {
|
|
|
102
102
|
// The member's name as the workplace knows it: who the session is told typed a turn. From the signed token, never
|
|
103
103
|
// from what the browser sends.
|
|
104
104
|
const name = typeof claims.name === 'string' && claims.name.trim() ? claims.name.trim().slice(0, 80) : null;
|
|
105
|
-
|
|
105
|
+
const roomContext = claims.roomContext === false ? null : { id: claims.room, name: typeof claims.roomName === 'string' ? claims.roomName.slice(0, 120) : claims.room, sender: claims.sub };
|
|
106
|
+
return { 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 };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A signed Room lifecycle notice. This scope grants no transcript read or user-turn control. */
|
|
110
|
+
export async function delegatedRoomNoticeOf(api, token) {
|
|
111
|
+
const c = await verifiedClaims(api, token);
|
|
112
|
+
if (!c || c.scope !== 'room-notice' || !c.session || c.sub !== c.session.agent) return null;
|
|
113
|
+
const n = c.notice;
|
|
114
|
+
if (![c.room, c.conversation, c.session.team, c.session.id, c.session.harness].every(v => typeof v === 'string' && v.length > 0 && v.length <= 256)) return null;
|
|
115
|
+
if (!n || !['joined', 'member-joined', 'member-left', 'left'].includes(n.kind)
|
|
116
|
+
|| typeof n.id !== 'string' || !/^[a-zA-Z0-9:_-]{1,200}$/.test(n.id)
|
|
117
|
+
|| typeof n.text !== 'string' || !n.text.trim() || n.text.length > 16000) return null;
|
|
118
|
+
const now = Math.floor(api.clock.nowMs() / 1000);
|
|
119
|
+
if (!Number.isFinite(c.iat) || c.exp - c.iat > SESSION_TOKEN_MAX_S || c.exp - now > SESSION_TOKEN_MAX_S) return null;
|
|
120
|
+
return { conversation: c.conversation, expiresAt: c.exp * 1000, issuer: c.iss, room: c.room,
|
|
121
|
+
roomName: typeof c.roomName === 'string' ? c.roomName.slice(0, 120) : c.room,
|
|
122
|
+
sessionRef: c.session.id, harness: c.session.harness, subject: c.sub, teamId: c.session.team, notice: n };
|
|
106
123
|
}
|
|
107
124
|
|
|
108
125
|
/** A token's claims when it is one this server trusts: signed by the issuer's key, for this server, in its lifetime. */
|
package/server/machine-link.mjs
CHANGED
|
@@ -56,7 +56,7 @@ export async function machineGrantRows(api, teamId, machineId) {
|
|
|
56
56
|
async function claimFor(api, auth, machineId) {
|
|
57
57
|
if (auth.kind === 'delegated') {
|
|
58
58
|
const row = await api.store.one('SELECT id, revision FROM machine_grants WHERE team_id=? AND machine_id=? AND id=? AND expires_at>?', [auth.team_id, machineId, auth.grant_id, api.clock.nowMs()]);
|
|
59
|
-
return row ? { principal: auth.principal_id, groups: [], grants: [row.id], revisions: { [row.id]: row.revision }, ...(auth.name ? { name: auth.name } : {}) } : null;
|
|
59
|
+
return row ? { principal: auth.principal_id, groups: [], grants: [row.id], revisions: { [row.id]: row.revision }, ...(auth.name ? { name: auth.name } : {}), ...(auth.roomContext ? { roomContext: auth.roomContext } : {}) } : null;
|
|
60
60
|
}
|
|
61
61
|
const machine = await api.store.one('SELECT custodian_id FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', [auth.team_id, machineId]);
|
|
62
62
|
if (!machine) return null;
|
|
@@ -231,7 +231,7 @@ async function delegatedSessionChannel(api, teamId, sessionRef, token) {
|
|
|
231
231
|
{ sql: "DELETE FROM machine_grants WHERE team_id=? AND issuer_id LIKE 'delegated:%' AND expires_at<=?", params: [teamId, now] },
|
|
232
232
|
{ sql: 'INSERT INTO machine_grants(team_id,id,machine_id,subject_id,caps_json,targets_json,issuer_id,created_at,expires_at) 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] },
|
|
233
233
|
]).catch((error) => { if (error.code !== 'not_found') throw error; return null; });
|
|
234
|
-
return { door: 'channel', teamId, machineId: target.machineId, auth: { kind: 'delegated', team_id: teamId, principal_id: principal, group_ids: [], grant_id: id, grant_issuer: issuer, expires_at: access.expiresAt, name: access.name } };
|
|
234
|
+
return { door: 'channel', teamId, machineId: target.machineId, auth: { kind: 'delegated', team_id: teamId, principal_id: principal, group_ids: [], grant_id: id, grant_issuer: issuer, expires_at: access.expiresAt, name: access.name, roomContext: access.roomContext } };
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
/**
|
|
@@ -372,3 +372,40 @@ async function acceptChannel(api, auth, machineId, duplex, closeSocket) {
|
|
|
372
372
|
upstream.on('close', (reason) => { open.delete(pair); if (!stream.closed) stream.close(reason); });
|
|
373
373
|
});
|
|
374
374
|
}
|
|
375
|
+
|
|
376
|
+
/** Deliver only signed lifecycle text to an exact owner-bound session, through durable native mail.
|
|
377
|
+
* The native --id receipt survives reconnects and server restarts; retry never invents a new id.
|
|
378
|
+
*/
|
|
379
|
+
export async function deliverSessionRoomNotice(api, access) {
|
|
380
|
+
const target = await boundSessionTarget(api, access.teamId, access);
|
|
381
|
+
if (!target || target.harness !== access.harness) throw Object.assign(new Error('session not found'), { status: 404, code: 'not_found' });
|
|
382
|
+
const machine = await api.store.one('SELECT name FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', [access.teamId, target.machineId]);
|
|
383
|
+
if (!machine) throw Object.assign(new Error('machine not found'), { status: 404, code: 'not_found' });
|
|
384
|
+
const machineName = String(machine.name).split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g, '-');
|
|
385
|
+
const to = `sc:${machineName}:${target.harness}:${target.nativeId}`;
|
|
386
|
+
const identity = JSON.stringify([access.issuer, access.room, access.conversation, target.sessionId, access.notice.id]);
|
|
387
|
+
const digest = [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(identity)))].map(v => v.toString(16).padStart(2, '0')).join('');
|
|
388
|
+
const pending = api.roomNoticeDeliveries ??= new Map();
|
|
389
|
+
if (pending.has(digest)) return pending.get(digest);
|
|
390
|
+
const delivery = (async () => {
|
|
391
|
+
const id = api.ids.next('grant'), principal = delegatedPrincipal(access), now = api.clock.nowMs();
|
|
392
|
+
await api.store.transaction([{ sql: 'INSERT INTO machine_grants(team_id,id,machine_id,subject_id,caps_json,targets_json,issuer_id,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?)',
|
|
393
|
+
params: [access.teamId, id, target.machineId, principal, JSON.stringify(['mail']), JSON.stringify([`mail:${to}`]), `delegated:room-notice:${digest}`, now, access.expiresAt] }]);
|
|
394
|
+
try {
|
|
395
|
+
const link = await linkWithin(api, key(access.teamId, target.machineId), LINK_WAIT_MS);
|
|
396
|
+
if (!link) throw new Error('machine offline; Room notice remains pending');
|
|
397
|
+
const stream = await link.channel.open('mail', { _admitted: { principal, groups: [], grants: [id], revisions: { [id]: 1 } },
|
|
398
|
+
request: { op: 'send', from: `sc:${machineName}:operator:room-${digest.slice(0, 24)}`, from_name: `Room ${access.roomName}`,
|
|
399
|
+
to, body: access.notice.text, id: `room-notice:${digest}`, queue: false } });
|
|
400
|
+
return await new Promise((resolve, reject) => {
|
|
401
|
+
let text = '';
|
|
402
|
+
const timeout = setTimeout(() => { stream.close('timeout'); reject(new Error('Room notice delivery not confirmed')); }, 15000);
|
|
403
|
+
stream.on('data', bytes => { text += new TextDecoder().decode(bytes); });
|
|
404
|
+
stream.on('close', () => { clearTimeout(timeout); try { const result = JSON.parse(text); if (result.code !== 0) throw new Error(result.text ?? 'Room notice refused'); resolve({ delivered: true, noticeId: access.notice.id }); } catch (error) { reject(error); } });
|
|
405
|
+
stream.on('error', error => { clearTimeout(timeout); reject(error); });
|
|
406
|
+
});
|
|
407
|
+
} finally { await api.store.transaction([{ sql: 'DELETE FROM machine_grants WHERE team_id=? AND id=?', params: [access.teamId, id] }]); }
|
|
408
|
+
})();
|
|
409
|
+
pending.set(digest, delivery);
|
|
410
|
+
try { return await delivery; } finally { pending.delete(digest); }
|
|
411
|
+
}
|