@volter/teams 0.1.9 → 0.1.11

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.9",
3
+ "version": "0.1.11",
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",
@@ -5,15 +5,26 @@
5
5
  // config this server's team, the machine and the target) and `view` or `control`; its audience is this server's
6
6
  // public origin. Verified with WebCrypto, so the same code runs in Node and in a Worker.
7
7
  //
8
+ // A valid signature is not enough. The workplace decides who in a Room may see or drive a surface, but only the
9
+ // machine's custodian decides what is on offer: a token opens a surface only when the custodian published exactly that
10
+ // pane or folder to this issuer (server/publications.mjs, checked by the channel door in machine-link.mjs). Here, a
11
+ // token is refused outright when it names a wildcard or the whole filesystem, or lives longer than MAX_LIFETIME_S.
12
+ //
8
13
  // What a Surface grants on the machine, in the daemon's own caps (sdk/volter-teams/admission.mjs):
9
14
  // pane view: observe the pane control: observe it and type into it
10
15
  // folder view: list and read under the root control: every file door under the root
16
+ import { publishableTarget } from './publications.mjs';
17
+
11
18
  const CAPS = {
12
19
  folder: { control: ['files'], view: ['observe'] },
13
20
  pane: { control: ['observe', 'control'], view: ['observe'] },
14
21
  };
15
22
  const KEYS_TTL_MS = 5 * 60 * 1000;
16
23
  const SKEW_S = 30;
24
+ // A delegated token lives at most 15 minutes (exp - iat, and exp - now). The viewer asks its page for a fresh one
25
+ // (`delegated-token-needed`) when its channel closes at expiry, so a short life costs a reconnect, not access; and it
26
+ // bounds what one leaked token, or one a workplace signed before its key was rotated out, can still do.
27
+ export const MAX_LIFETIME_S = 15 * 60;
17
28
 
18
29
  const decoder = new TextDecoder();
19
30
  const fromBase64Url = (text) => Uint8Array.from(atob(text.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - text.length % 4) % 4)), (c) => c.charCodeAt(0));
@@ -45,11 +56,51 @@ async function issuerKey(api, kid) {
45
56
  }
46
57
 
47
58
  /**
48
- * What a delegated token admits, or null when it is not one this server trusts: { subject, issuer, teamId, machineId,
49
- * target, caps, expiresAt, scope, kind }. Anything unverifiable, expired, for another audience or naming no machine
50
- * target is null, and the caller refuses it exactly as an unknown credential.
59
+ * What a delegated token claims, or null when it is not one this server trusts: { subject, issuer, teamId, machineId,
60
+ * target, room, caps, expiresAt, scope, kind }; it admits nothing until matched to a publication (publicationFor).
61
+ * Anything unverifiable, expired, too long-lived, for another audience or naming no exact machine target is null,
62
+ * and the caller refuses it exactly as an unknown credential.
51
63
  */
52
64
  export async function delegatedAccessOf(api, token) {
65
+ const claims = await verifiedClaims(api, token);
66
+ if (!claims) return null;
67
+ // Short-lived by construction: an issued-at, not in the future, and no more than MAX_LIFETIME_S to its expiry.
68
+ const now = Math.floor(api.clock.nowMs() / 1000);
69
+ 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;
70
+ const surface = claims.surface;
71
+ const caps = CAPS[surface?.kind]?.[claims.scope];
72
+ const config = surface?.config ?? {};
73
+ if (!caps || typeof config.team !== 'string' || typeof config.machine !== 'string' || typeof config.target !== 'string') return null;
74
+ // One exact pane or one folder below the root: never `pane:*`, `root:/`, or anything a publication could not name.
75
+ if (!publishableTarget(surface.kind, config.target)) return null;
76
+ // Every publication is into one Room, so a token must name the Room it opens the surface in.
77
+ const room = surface.room ?? claims.room ?? null;
78
+ if (typeof room !== 'string' || !room) return null;
79
+ return { caps, expiresAt: claims.exp * 1000, issuer: claims.iss, kind: surface.kind, machineId: config.machine, room, scope: claims.scope, subject: claims.sub, target: config.target, teamId: config.team };
80
+ }
81
+
82
+ /** Longest a session-view token may live, whatever it says: the workplace mints them for minutes. */
83
+ const SESSION_TOKEN_MAX_S = 15 * 60;
84
+
85
+ /**
86
+ * What a session-view token admits (RH2 ADR 0008), or null: { subject, issuer, teamId, sessionRef, room, conversation,
87
+ * scope, expiresAt }. It names one session (by its id or the harness's own) of one team, and the Room and Conversation
88
+ * viewing it; the server admits it only for a session its owner bound to that Room and Conversation.
89
+ */
90
+ export async function delegatedSessionOf(api, token) {
91
+ const claims = await verifiedClaims(api, token);
92
+ if (!claims) return null;
93
+ const session = claims.session;
94
+ if (!session || typeof session.team !== 'string' || typeof session.id !== 'string' || !session.id) return null;
95
+ if (typeof claims.room !== 'string' || !claims.room || typeof claims.conversation !== 'string' || !claims.conversation) return null;
96
+ if (!['view', 'control'].includes(claims.scope)) return null;
97
+ const now = Math.floor(api.clock.nowMs() / 1000);
98
+ if (claims.exp - now > SESSION_TOKEN_MAX_S || (typeof claims.iat === 'number' && claims.exp - claims.iat > SESSION_TOKEN_MAX_S)) return null;
99
+ return { conversation: claims.conversation, expiresAt: claims.exp * 1000, issuer: claims.iss, room: claims.room, scope: claims.scope, sessionRef: session.id, subject: claims.sub, teamId: session.team };
100
+ }
101
+
102
+ /** A token's claims when it is one this server trusts: signed by the issuer's key, for this server, in its lifetime. */
103
+ async function verifiedClaims(api, token) {
53
104
  if (!api.delegatedIssuer || !api.publicOrigin || !looksDelegated(token)) return null;
54
105
  try {
55
106
  const [head, body, signature] = token.split('.');
@@ -64,13 +115,7 @@ export async function delegatedAccessOf(api, token) {
64
115
  if (claims.iss !== api.delegatedIssuer || claims.aud !== api.publicOrigin) return null;
65
116
  if (typeof claims.exp !== 'number' || claims.exp <= now - SKEW_S || (typeof claims.nbf === 'number' && claims.nbf > now + SKEW_S)) return null;
66
117
  if (typeof claims.sub !== 'string' || !claims.sub) return null;
67
- const surface = claims.surface;
68
- const caps = CAPS[surface?.kind]?.[claims.scope];
69
- const config = surface?.config ?? {};
70
- if (!caps || typeof config.team !== 'string' || typeof config.machine !== 'string' || typeof config.target !== 'string') return null;
71
- const target = surface.kind === 'pane' ? (config.target.startsWith('pane:') ? config.target : null) : (config.target.startsWith('root:/') ? config.target : null);
72
- if (!target) return null;
73
- return { caps, expiresAt: claims.exp * 1000, issuer: claims.iss, kind: surface.kind, machineId: config.machine, scope: claims.scope, subject: claims.sub, target, teamId: config.team };
118
+ return claims;
74
119
  } catch {
75
120
  return null;
76
121
  }
package/server/http.mjs CHANGED
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
2
2
  import { bearer, hashToken, canManage, isAdmin, makeToken } from './auth.mjs';
3
3
  import { ContractError, canonicalJson, validationFailure, cursorHash, cursorToken, sha256 } from './wire.mjs';
4
4
  import { machineGrantRoute, machineGrantRows, closeMachineChannels, closeChannelsFor } from './machine-link.mjs';
5
+ import { publicationRoute, publisherRevocation } from './publications.mjs';
5
6
  import { exact, parseJson as parseRequestJson } from './request-json.mjs';
6
7
  import { identityOf } from './identity.mjs';
7
8
  import { serveViewer } from './viewer.mjs';
@@ -69,6 +70,7 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
69
70
  if(req.method==='DELETE'&&(m=/^\/machines\/([^/]+)$/.exec(sub)))return await revokeMachine(api,req,res,auth,m[1],requestId);
70
71
  if(req.method==='POST'&&(m=/^\/machines\/([^/]+)\/heartbeat$/.exec(sub)))return await heartbeat(api,req,res,auth,m[1],requestId);
71
72
  if(req.method==='GET'&&(m=/^\/machines\/([^/]+)\/configuration$/.exec(sub)))return await machineConfiguration(api,res,auth,m[1],requestId);
73
+ if((m=/^\/machines\/([^/]+)\/publications(?:\/([^/]+))?$/.exec(sub)))return await publicationRoute({api,req,res,auth,machineId:m[1],publicationId:m[2],url,requestId,parseJson,exact,fail,send,result,guardedMutation});
72
74
  if((m=/^\/machines\/([^/]+)\/access(?:\/([^/]+))?$/.exec(sub)))return await machineGrantRoute({api,req,res,auth,machineId:m[1],grantId:m[2],url,requestId,parseJson,exact,fail,send,result,guardedMutation,requireRevision});
73
75
  if(req.method==='POST'&&sub==='/sources')return await createSource(api,req,res,auth,requestId);
74
76
  if(req.method==='GET'&&sub==='/sources')return await listSources(api,res,auth,url,requestId);
@@ -135,7 +137,7 @@ async function createInvitation(api,req,res,auth,requestId){if(!isAdmin(auth))fa
135
137
  async function acceptInvite(api,res,body,requestId){exact(body,['enrollment_code','access_token']);
136
138
  // Accepted with an identity service's token, the new member is linked to it from the start.
137
139
  const person=body.access_token===undefined?null:await identityOf(api,body.access_token);if(body.access_token!==undefined&&!person)fail(401,'authentication_required','the identity token did not verify');const digest=hashToken(body.enrollment_code??'');const inv=await api.store.one('SELECT i.*,p.display_name,t.name team_name,t.revision team_revision FROM invitations i JOIN principals p ON p.id=i.principal_id JOIN teams t ON t.id=i.team_id WHERE i.secret_hash=?',[digest]),now=api.clock.nowMs();if(!inv||inv.consumed_at!=null||inv.revoked_at!=null||inv.expires_at<=now)fail(401,'authentication_required','invalid invitation');const cid=api.ids.next('credential'),token=makeToken(cid);await api.store.guardedTransaction([{sql:'SELECT 1 ok FROM invitations WHERE id=? AND consumed_at IS NULL AND revoked_at IS NULL AND expires_at>?',params:[inv.id,now],code:'authentication_required',status:401,message:'invalid invitation'}],[{sql:'UPDATE invitations SET consumed_at=? WHERE id=?',params:[now,inv.id]},{sql:'INSERT INTO memberships(team_id,principal_id,role) VALUES (?,?,?)',params:[inv.team_id,inv.principal_id,inv.role]},...(person?[{sql:'INSERT INTO principal_identities(issuer,subject,principal_id,linked_at) VALUES (?,?,?,?)',params:[person.issuer,person.subject,inv.principal_id,now]}]:[]),{sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?)',params:[cid,hashToken(token),inv.principal_id,'user',null,now+2592000000,now]},{sql:'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',params:[inv.team_id]}]);send(res,201,{principal:{id:inv.principal_id,kind:'user',display_name:inv.display_name},credential:{token,kind:'user',expires_at:now+2592000000},team:{id:inv.team_id,name:inv.team_name,revision:inv.team_revision}},requestId);}
138
- async function removeMember(api,req,res,auth,pid,requestId){if(!isAdmin(auth))fail(403,'forbidden','forbidden');const target=await api.store.one('SELECT * FROM memberships WHERE team_id=? AND principal_id=? AND active=1',[auth.team_id,pid]);if(!target)fail(404,'not_found','not found');requireRevision(req,target.revision,'membership');if(target.role==='owner'&&auth.role!=='owner')fail(403,'forbidden','only owners may remove owners');const now=api.clock.nowMs(),guards=[...authGuards(auth,now),{sql:"SELECT 1 ok FROM memberships WHERE team_id=? AND principal_id=? AND active=1 AND revision=? AND (role!='owner' OR (SELECT count(*) FROM memberships WHERE team_id=? AND role='owner' AND active=1)>1)",params:[auth.team_id,pid,target.revision,auth.team_id],code:'conflict',status:409,message:'membership changed or last owner would be removed'}],statements=[{sql:'UPDATE memberships SET active=0,revision=revision+1 WHERE team_id=? AND principal_id=?',params:[auth.team_id,pid]},{sql:'UPDATE credentials SET revoked_at=? WHERE team_id=? AND principal_id=?',params:[now,auth.team_id,pid]},{sql:"UPDATE sources SET state='paused',revision=revision+1 WHERE team_id=? AND custodian_id=? AND state='active'",params:[auth.team_id,pid]},{sql:'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',params:[auth.team_id]}];await guardedMutation(api,res,auth,guards,statements,{status:204,body:null,targetId:pid});api.events.publish(auth.team_id,{type:'authorization_changed',principal_id:pid});closeChannelsFor(api,auth.team_id,{principalId:pid,reason:'membership_removed'});send(res,204,null,requestId);}
140
+ async function removeMember(api,req,res,auth,pid,requestId){if(!isAdmin(auth))fail(403,'forbidden','forbidden');const target=await api.store.one('SELECT * FROM memberships WHERE team_id=? AND principal_id=? AND active=1',[auth.team_id,pid]);if(!target)fail(404,'not_found','not found');requireRevision(req,target.revision,'membership');if(target.role==='owner'&&auth.role!=='owner')fail(403,'forbidden','only owners may remove owners');const published=publisherRevocation(api,auth.team_id,pid),now=api.clock.nowMs(),guards=[...authGuards(auth,now),{sql:"SELECT 1 ok FROM memberships WHERE team_id=? AND principal_id=? AND active=1 AND revision=? AND (role!='owner' OR (SELECT count(*) FROM memberships WHERE team_id=? AND role='owner' AND active=1)>1)",params:[auth.team_id,pid,target.revision,auth.team_id],code:'conflict',status:409,message:'membership changed or last owner would be removed'}],statements=[{sql:'UPDATE memberships SET active=0,revision=revision+1 WHERE team_id=? AND principal_id=?',params:[auth.team_id,pid]},{sql:'UPDATE credentials SET revoked_at=? WHERE team_id=? AND principal_id=?',params:[now,auth.team_id,pid]},{sql:"UPDATE sources SET state='paused',revision=revision+1 WHERE team_id=? AND custodian_id=? AND state='active'",params:[auth.team_id,pid]},{sql:'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',params:[auth.team_id]},published.statement];await guardedMutation(api,res,auth,guards,statements,{status:204,body:null,targetId:pid});api.events.publish(auth.team_id,{type:'authorization_changed',principal_id:pid});closeChannelsFor(api,auth.team_id,{principalId:pid,reason:'membership_removed'});await published.closeChannels();send(res,204,null,requestId);}
139
141
  /** Federation (P02): a verified identity token is a member's sign-in, answered with the same 30-day credential an invitation gives. */
140
142
  async function identityLogin(api,res,body,requestId){
141
143
  exact(body,['access_token','team_id']);if(body.team_id!==undefined&&typeof body.team_id!=='string')fail(400,'invalid_request','team_id must be a string');
@@ -11,11 +11,13 @@
11
11
  // of the grants and checks each door against it. A caller with no grant on a
12
12
  // machine is refused exactly as a machine that does not exist is.
13
13
  //
14
- // A delegated caller (./delegated.mjs) holds no Teams credential: its verified token becomes one machine grant on
15
- // exactly the target and caps it names, expiring with the token, and its channel closes then.
14
+ // A delegated caller (./delegated.mjs) holds no Teams credential: its verified token, when it names exactly a surface
15
+ // the machine's custodian published to that workplace (./publications.mjs), becomes one machine grant on that target
16
+ // and the caps the token names, expiring with the token; its channel closes then, or when the surface is unpublished.
16
17
  import { Channel } from '../channel.mjs';
17
18
  import { bearer } from './auth.mjs';
18
19
  import { delegatedAccessOf, delegatedPrincipal, looksDelegated } from './delegated.mjs';
20
+ import { delegatedGrantIssuer, publicationFor } from './publications.mjs';
19
21
 
20
22
  const LINK_PROTOCOL = 'supercode-machine-link.v1';
21
23
  const CHANNEL_PROTOCOL = 'supercode-machine-channel.v1';
@@ -172,20 +174,46 @@ export async function authorizeMachineUpgrade(api, { authenticate, url, headers
172
174
  return { door, teamId, machineId, auth };
173
175
  }
174
176
 
175
- /** A delegated token's channel: its grant, minted now on exactly what the token names, or the same refusal as no machine. */
177
+ /**
178
+ * A delegated token's channel: its grant, minted now on exactly the published surface the token names, or the same
179
+ * refusal as no machine. A token for a surface nobody published here (or one since unpublished) opens nothing.
180
+ */
176
181
  async function delegatedChannel(api, teamId, machineId, token) {
177
182
  const access = await delegatedAccessOf(api, token);
178
183
  if (!access) return { status: 401, code: 'authentication_required', message: 'authentication required' };
179
184
  if (access.teamId !== teamId || access.machineId !== machineId) return { status: 404, code: 'not_found', message: 'machine not found' };
180
185
  const machine = await api.store.one('SELECT 1 ok FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', [teamId, machineId]);
181
186
  if (!machine) return { status: 404, code: 'not_found', message: 'machine not found' };
182
- const now = api.clock.nowMs(), id = api.ids.next('grant'), principal = delegatedPrincipal(access);
183
- await api.store.transaction([
184
- // Delegated grants live as long as their tokens; the expired ones go as new ones come.
185
- { sql: "DELETE FROM machine_grants WHERE team_id=? AND issuer_id LIKE 'delegated:%' AND expires_at<=?", params: [teamId, now] },
186
- { 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, machineId, principal, JSON.stringify(access.caps), JSON.stringify([access.target]), `delegated:${new URL(access.issuer).host}`, now, access.expiresAt] },
187
- ]);
188
- return { door: 'channel', teamId, machineId, auth: { kind: 'delegated', team_id: teamId, principal_id: principal, group_ids: [], grant_id: id, expires_at: access.expiresAt } };
187
+ const publication = await publicationFor(api, access);
188
+ if (!publication) return { status: 404, code: 'not_found', message: 'machine not found' };
189
+ const now = api.clock.nowMs(), id = api.ids.next('grant'), principal = delegatedPrincipal(access), issuer = delegatedGrantIssuer(access.issuer, publication.id);
190
+ try {
191
+ // The publication, and its publisher's standing, must still hold as the grant is written: an unpublish or a
192
+ // member removal racing this open wins.
193
+ await api.store.guardedTransaction([
194
+ { sql: 'SELECT 1 ok FROM machine_publications p JOIN machines m ON m.team_id=p.team_id AND m.id=p.machine_id AND m.custodian_id=p.publisher_id JOIN memberships mem ON mem.team_id=p.team_id AND mem.principal_id=p.publisher_id AND mem.active=1 WHERE p.team_id=? AND p.id=?', params: [teamId, publication.id], code: 'not_found', status: 404, message: 'machine not found' },
195
+ { sql: 'SELECT 1 ok FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', params: [teamId, machineId], code: 'not_found', status: 404, message: 'machine not found' },
196
+ ], [
197
+ // Delegated grants live as long as their tokens; the expired ones go as new ones come.
198
+ { sql: "DELETE FROM machine_grants WHERE team_id=? AND issuer_id LIKE 'delegated:%' AND expires_at<=?", params: [teamId, now] },
199
+ { 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, machineId, principal, JSON.stringify(access.caps), JSON.stringify([publication.target]), issuer, now, access.expiresAt] },
200
+ ]);
201
+ } catch (error) {
202
+ if (error.code === 'not_found') return { status: 404, code: 'not_found', message: 'machine not found' };
203
+ throw error;
204
+ }
205
+ return { door: 'channel', teamId, machineId, auth: { kind: 'delegated', team_id: teamId, principal_id: principal, group_ids: [], grant_id: id, grant_issuer: issuer, expires_at: access.expiresAt } };
206
+ }
207
+
208
+ /** Close every delegated channel whose grant came from one publication (issuer from delegatedGrantIssuer): it was unpublished. */
209
+ export function closeDelegatedChannels(api, teamId, machineId, grantIssuer, reason = 'unpublished') {
210
+ let closed = 0;
211
+ for (const entry of channels(api)) {
212
+ if (entry.teamId !== teamId || entry.machineId !== machineId || entry.grantIssuer !== grantIssuer) continue;
213
+ entry.close(reason);
214
+ closed += 1;
215
+ }
216
+ return closed;
189
217
  }
190
218
 
191
219
  /**
@@ -235,7 +263,7 @@ async function acceptChannel(api, auth, machineId, duplex, closeSocket) {
235
263
  } catch { closeSocket('refused'); return; }
236
264
  const open = new Set();
237
265
  const entry = {
238
- teamId: auth.team_id, machineId, principal: auth.principal_id, claim: null,
266
+ teamId: auth.team_id, machineId, principal: auth.principal_id, claim: null, grantIssuer: auth.grant_issuer ?? null,
239
267
  close: (reason) => closeSocket(reason),
240
268
  dropStreams: (reason) => { for (const pair of open) pair.close(reason); },
241
269
  };
@@ -0,0 +1,11 @@
1
+ // Schema migration 005 (SQLite DDL), applied in order by store-core migrate().
2
+ export default `-- Schema 5: a session's owner lets one workplace Room's Conversation view it (a session
3
+ -- view, RH2 ADR 0008). A token that workplace signs for that Room and Conversation reads
4
+ -- the session; nothing else reaches it through the token.
5
+ CREATE TABLE session_view_bindings (
6
+ team_id TEXT NOT NULL, session_id TEXT NOT NULL, issuer TEXT NOT NULL, room TEXT NOT NULL,
7
+ conversation TEXT NOT NULL, bound_by TEXT NOT NULL, created_at INTEGER NOT NULL,
8
+ PRIMARY KEY(team_id, session_id, issuer, room, conversation),
9
+ FOREIGN KEY(team_id, session_id) REFERENCES sessions(team_id, id)
10
+ ) STRICT;
11
+ `;
@@ -0,0 +1,17 @@
1
+ // Schema migration 006 (SQLite DDL), applied in order by store-core migrate().
2
+ export default `-- Schema 6: published surfaces (delegated access, server/publications.mjs). A machine's
3
+ -- custodian publishes one pane or one folder of it into one workplace Room; a delegated token
4
+ -- that workplace signs opens exactly a surface published here, and nothing else. Unpublishing
5
+ -- deletes the row, the delegated grants minted on it, and closes their channels.
6
+ CREATE TABLE machine_publications (
7
+ team_id TEXT NOT NULL, id TEXT NOT NULL, machine_id TEXT NOT NULL,
8
+ kind TEXT NOT NULL CHECK(kind IN ('pane','folder')), target TEXT NOT NULL,
9
+ workplace TEXT NOT NULL, room TEXT NOT NULL, surface_key TEXT,
10
+ publisher_id TEXT NOT NULL, created_at INTEGER NOT NULL,
11
+ PRIMARY KEY(team_id,id), UNIQUE(team_id,machine_id,target,workplace,room)
12
+ ) STRICT;
13
+ CREATE INDEX machine_publications_target ON machine_publications(team_id,machine_id,target,workplace);
14
+ -- Delegated grants minted before publications (issuer delegated:<host>, any target, any lifetime) go: each
15
+ -- grant now names its publication (delegated:<host>/<publication id>).
16
+ DELETE FROM machine_grants WHERE issuer_id LIKE 'delegated:%' AND instr(issuer_id,'/')=0;
17
+ `;
@@ -3,4 +3,6 @@ import m001 from './001.mjs';
3
3
  import m002 from './002.mjs';
4
4
  import m003 from './003.mjs';
5
5
  import m004 from './004.mjs';
6
- export const MIGRATIONS = [m001, m002, m003, m004];
6
+ import m005 from './005.mjs';
7
+ import m006 from './006.mjs';
8
+ export const MIGRATIONS = [m001, m002, m003, m004, m005, m006];
@@ -0,0 +1,107 @@
1
+ // Published surfaces: what a delegated token (./delegated.mjs) may open.
2
+ //
3
+ // /api/v1/teams/{team}/machines/{id}/publications[/{publication}] list, publish, unpublish
4
+ //
5
+ // A workplace signs delegated tokens, but it is not the authority over a machine: the machine's custodian is. So a
6
+ // token opens a pane or a folder only when the custodian published exactly that surface here, to that workplace
7
+ // (the token's issuer) and, when the token names one, that Room. `teams publish` records the surface here before it
8
+ // puts it into the workplace; `teams unpublish` deletes it here first, which also deletes every delegated grant minted
9
+ // on it and closes their channels, so a token the workplace already signed stops working at once.
10
+ import { closeDelegatedChannels } from './machine-link.mjs';
11
+
12
+ /** The one target a surface may name: an exact pane, or an exact, normalized, non-root absolute folder. Never a wildcard. */
13
+ export function publishableTarget(kind, target) {
14
+ if (typeof target !== 'string' || target.length > 4101 || /[\x00-\x1f*]/.test(target)) return false;
15
+ if (kind === 'pane') return /^pane:\S{1,1024}$/.test(target);
16
+ if (kind === 'folder') {
17
+ if (!target.startsWith('root:/') || target === 'root:/' || target.endsWith('/')) return false;
18
+ return target.slice(6).split('/').every((segment) => segment && segment !== '.' && segment !== '..');
19
+ }
20
+ return false;
21
+ }
22
+
23
+ const originOf = (text) => { try { const url = new URL(text); return (url.protocol === 'https:' || url.protocol === 'http:') && url.origin === text ? text : null; } catch { return null; } };
24
+ const json = (row) => ({ id: row.id, machine_id: row.machine_id, kind: row.kind, target: row.target, workplace: row.workplace, room: row.room, surface_key: row.surface_key, publisher_id: row.publisher_id, created_at: row.created_at });
25
+ /** The grant issuer a delegated grant minted on one publication carries, so unpublishing finds exactly those grants. */
26
+ export const delegatedGrantIssuer = (issuer, publicationId) => `delegated:${new URL(issuer).host}/${publicationId}`;
27
+
28
+ /**
29
+ * What removing a member withdraws from the surfaces they published (the rows stay, inert: publicationFor admits none
30
+ * while the publisher is not an active member): a statement, for the removal's own transaction, deleting the delegated
31
+ * grants minted on any of their publications, one published a moment ago included; and closeChannels(), to call once
32
+ * the removal is committed, closing those grants' live channels.
33
+ */
34
+ export const publisherRevocation = (api, teamId, principalId) => ({
35
+ statement: { sql: "DELETE FROM machine_grants WHERE team_id=? AND issuer_id LIKE 'delegated:%/%' AND substr(issuer_id, instr(issuer_id,'/')+1) IN (SELECT id FROM machine_publications WHERE team_id=? AND publisher_id=?)", params: [teamId, teamId, principalId] },
36
+ closeChannels: async () => {
37
+ const rows = await api.store.query('SELECT id, machine_id, workplace FROM machine_publications WHERE team_id=? AND publisher_id=?', [teamId, principalId]);
38
+ for (const row of rows) closeDelegatedChannels(api, teamId, row.machine_id, delegatedGrantIssuer(row.workplace, row.id), 'membership_removed');
39
+ },
40
+ });
41
+
42
+ /**
43
+ * The publication a verified delegated access (delegatedAccessOf) opens, or null: same team, machine, surface kind and
44
+ * exact target, published to the token's issuer and to the token's Room, by the machine's current custodian while
45
+ * still an active member of the team (a publication does not outlive its publisher's standing).
46
+ */
47
+ export async function publicationFor(api, access) {
48
+ if (typeof access.room !== 'string' || !access.room) return null;
49
+ return await api.store.one(
50
+ 'SELECT p.* FROM machine_publications p JOIN machines m ON m.team_id=p.team_id AND m.id=p.machine_id AND m.revoked_at IS NULL AND m.custodian_id=p.publisher_id JOIN memberships mem ON mem.team_id=p.team_id AND mem.principal_id=p.publisher_id AND mem.active=1 WHERE p.team_id=? AND p.machine_id=? AND p.kind=? AND p.target=? AND p.workplace=? AND p.room=? ORDER BY p.id LIMIT 1',
51
+ [access.teamId, access.machineId, access.kind, access.target, access.issuer, access.room],
52
+ ) ?? null;
53
+ }
54
+
55
+ export async function publicationRoute({ api, req, res, auth, machineId, publicationId, url, requestId, parseJson, exact, fail, send, result, guardedMutation }) {
56
+ if (!['user', 'service'].includes(auth.kind)) fail(403, 'forbidden', 'user or service credential required');
57
+ const machine = await api.store.one('SELECT * FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', [auth.team_id, machineId]);
58
+ const custodian = machine?.custodian_id === auth.principal_id;
59
+ const admin = auth.active && (auth.role === 'owner' || auth.role === 'admin');
60
+ // Publishing opens the machine to people outside the team: only its custodian does it. An admin may see and withdraw.
61
+ if (!machine || (!custodian && !admin)) fail(404, 'not_found', 'machine not found');
62
+ const context = { server_id: api.serverId, team_id: auth.team_id, principal_id: auth.principal_id };
63
+ if (req.method === 'GET') {
64
+ if (publicationId) {
65
+ const row = await api.store.one('SELECT * FROM machine_publications WHERE team_id=? AND machine_id=? AND id=?', [auth.team_id, machineId, publicationId]);
66
+ if (!row) fail(404, 'not_found', 'publication not found');
67
+ return send(res, 200, result(api, auth, json(row)), requestId);
68
+ }
69
+ if ([...url.searchParams].length) fail(400, 'invalid_request', 'publications take no query parameters');
70
+ const rows = await api.store.query('SELECT * FROM machine_publications WHERE team_id=? AND machine_id=? ORDER BY id', [auth.team_id, machineId]);
71
+ return send(res, 200, { version: 1, context, items: rows.map(json), next_cursor: null }, requestId);
72
+ }
73
+ if (req.method === 'POST' && !publicationId) {
74
+ if (!custodian) fail(403, 'forbidden', "only the machine's custodian publishes its panes and folders");
75
+ const body = await parseJson(req); exact(body, ['kind', 'target', 'workplace', 'room', 'surface_key']);
76
+ if (body.kind !== 'pane' && body.kind !== 'folder') fail(400, 'invalid_request', 'kind must be pane or folder');
77
+ if (!publishableTarget(body.kind, body.target)) fail(400, 'invalid_request', body.kind === 'pane' ? 'target must be one pane, pane:<key> (no wildcard)' : 'target must be one absolute folder, root:/path (not root:/, no wildcard, normalized)');
78
+ const workplace = typeof body.workplace === 'string' ? originOf(body.workplace) : null;
79
+ if (!workplace) fail(400, 'invalid_request', 'workplace must be an origin (https://host)');
80
+ if (workplace !== api.delegatedIssuer) fail(400, 'invalid_request', `this server does not trust ${workplace} as a delegated issuer`);
81
+ if (typeof body.room !== 'string' || !body.room || body.room.length > 256) fail(400, 'invalid_request', 'room must name the workplace Room');
82
+ if (body.surface_key !== undefined && (typeof body.surface_key !== 'string' || !body.surface_key || body.surface_key.length > 256)) fail(400, 'invalid_request', 'surface_key must be a non-empty string');
83
+ const existing = await api.store.one('SELECT * FROM machine_publications WHERE team_id=? AND machine_id=? AND target=? AND workplace=? AND room=?', [auth.team_id, machineId, body.target, workplace, body.room]);
84
+ // Publishing the same surface again answers the record already there.
85
+ if (existing && existing.kind === body.kind) return send(res, 200, result(api, auth, json(existing)), requestId);
86
+ if (existing) fail(409, 'conflict', 'that target is already published as another kind');
87
+ const now = api.clock.nowMs(), id = api.ids.next('publication');
88
+ const row = { id, machine_id: machineId, kind: body.kind, target: body.target, workplace, room: body.room, surface_key: body.surface_key ?? null, publisher_id: auth.principal_id, created_at: now };
89
+ const response = result(api, auth, json(row));
90
+ await guardedMutation(api, res, auth, [{ sql: 'SELECT 1 ok FROM machines WHERE team_id=? AND id=? AND custodian_id=? AND revoked_at IS NULL', params: [auth.team_id, machineId, auth.principal_id], code: 'not_found', status: 404, message: 'machine not found' }], [
91
+ { sql: 'INSERT INTO machine_publications(team_id,id,machine_id,kind,target,workplace,room,surface_key,publisher_id,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)', params: [auth.team_id, id, machineId, row.kind, row.target, row.workplace, row.room, row.surface_key, row.publisher_id, now] },
92
+ ], { status: 201, body: response, targetId: id });
93
+ return send(res, 201, response, requestId);
94
+ }
95
+ if (req.method === 'DELETE' && publicationId) {
96
+ const row = await api.store.one('SELECT * FROM machine_publications WHERE team_id=? AND machine_id=? AND id=?', [auth.team_id, machineId, publicationId]);
97
+ if (!row) fail(404, 'not_found', 'publication not found');
98
+ const issuer = delegatedGrantIssuer(row.workplace, row.id);
99
+ await guardedMutation(api, res, auth, [], [
100
+ { sql: 'DELETE FROM machine_publications WHERE team_id=? AND id=?', params: [auth.team_id, row.id] },
101
+ { sql: 'DELETE FROM machine_grants WHERE team_id=? AND machine_id=? AND issuer_id=?', params: [auth.team_id, machineId, issuer] },
102
+ ], { status: 204, body: null, targetId: row.id });
103
+ closeDelegatedChannels(api, auth.team_id, machineId, issuer);
104
+ return send(res, 204, null, requestId);
105
+ }
106
+ fail(405, 'not_found', 'method not allowed');
107
+ }