@volter/teams 0.1.59 → 0.1.61

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.59",
3
+ "version": "0.1.61",
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/core.mjs CHANGED
@@ -14,20 +14,22 @@ const productionIds={next(kind){return `${prefix[kind]??kind}_${randomBytes(16).
14
14
  const productionClock={nowMs:()=>Date.now()};
15
15
  export { productionIds, productionClock };
16
16
 
17
- export function createTeamsApi({clock=productionClock,ids=productionIds,publicOrigin=null,identityIssuer=null,identityOrganizations=null,delegatedIssuer=null,uploadLimitBytes=256*1024*1024}={}) {
17
+ export function createTeamsApi({clock=productionClock,ids=productionIds,publicOrigin=null,identityIssuer=null,identityOrganizations=null,delegatedIssuer=null,delegatedIssuers=[],uploadLimitBytes=256*1024*1024}={}) {
18
18
  // An identity service people may sign in with (protocol P02 federation); its tokens name this server's public origin.
19
19
  identityIssuer=normalizePublicOrigin(identityIssuer);
20
20
  if(identityIssuer&&!publicOrigin)throw new TypeError('an identity issuer needs the public origin its tokens name');
21
21
  // A workplace whose delegated tokens admit a caller to one pane or folder (server/delegated.mjs); they name this origin too.
22
22
  delegatedIssuer=normalizePublicOrigin(delegatedIssuer);
23
- if(delegatedIssuer&&!publicOrigin)throw new TypeError('a delegated issuer needs the public origin its tokens name');
23
+ if(!Array.isArray(delegatedIssuers)||delegatedIssuers.some(origin=>typeof origin!=='string'||!origin.trim()))throw new TypeError('delegatedIssuers must be an array of origins');
24
+ delegatedIssuers=Object.freeze([...new Set([delegatedIssuer,...delegatedIssuers.map(normalizePublicOrigin)].filter(Boolean))]);
25
+ if(delegatedIssuers.length&&!publicOrigin)throw new TypeError('a delegated issuer needs the public origin its tokens name');
24
26
  if(typeof clock?.nowMs!=='function'||typeof ids?.next!=='function')throw new TypeError('clock.nowMs and ids.next are required');
25
27
  if(!Number.isSafeInteger(uploadLimitBytes)||uploadLimitBytes<1||uploadLimitBytes>256*1024*1024)throw new TypeError('uploadLimitBytes must be between 1 and 268435456');
26
28
  let store=null,serverId=null,localOrigin=null,harnessRoutes=null;
27
29
  const events=new TeamEvents();
28
30
  // Teams linked to organizations the identity service keeps for this server (identity-orgs.mjs).
29
31
  if(identityOrganizations&&!identityIssuer)throw new TypeError('organizations need the identity issuer that keeps them');
30
- return {clock,ids,events,publicOrigin,identityIssuer,identityOrganizations,delegatedIssuer,uploadLimitBytes,rateState:new Map(),openStreams:new Map(),securityEvents:[],
32
+ return {clock,ids,events,publicOrigin,identityIssuer,identityOrganizations,delegatedIssuer,delegatedIssuers,uploadLimitBytes,rateState:new Map(),openStreams:new Map(),securityEvents:[],
31
33
  get store(){return store;},get serverId(){return serverId;},get localOrigin(){return localOrigin;},get harnessRoutes(){return harnessRoutes;},
32
34
  attach(next){store=next.store??store;if(next.serverId!==undefined)serverId=next.serverId;if(next.localOrigin!==undefined)localOrigin=next.localOrigin;if(next.harnessRoutes!==undefined)harnessRoutes=next.harnessRoutes;},
33
35
  };
@@ -1,6 +1,6 @@
1
1
  // Delegated access (RH2 RFC 0013, panes and folders): a workplace that decides who may see or drive a pane or a
2
2
  // folder signs a short-lived token saying so, and this server admits exactly that, as it admits a machine grant.
3
- // The workplace is an issuer this server is configured to trust (`delegatedIssuer`, its origin); its keys are at
3
+ // The workplace is an issuer this server is configured to trust (`delegatedIssuers`, exact origins); its keys are at
4
4
  // `<issuer>/.well-known/jwks.json` (Ed25519, EdDSA). A token names the principal, the Surface (its kind, and in its
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.
@@ -38,13 +38,15 @@ export function looksDelegated(token) {
38
38
  return typeof token === 'string' && /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token);
39
39
  }
40
40
 
41
- async function issuerKey(api, kid) {
42
- const cache = api.delegatedKeys ??= { at: -Infinity, keys: new Map() };
41
+ async function issuerKey(api, issuer, kid) {
42
+ const caches = api.delegatedKeys ??= new Map();
43
+ let cache = caches.get(issuer);
44
+ if (!cache) caches.set(issuer, cache = { at: -Infinity, keys: new Map() });
43
45
  const age = api.clock.nowMs() - cache.at;
44
46
  // The set is re-read when it is old, or when it lacks the kid and was last read over a minute ago (a key rotated
45
47
  // in); so a forged kid drives at most one fetch a minute.
46
48
  if (age >= KEYS_TTL_MS || (!cache.keys.has(kid) && age > 60 * 1000)) {
47
- const response = await fetch(`${api.delegatedIssuer}/.well-known/jwks.json`, { headers: { accept: 'application/json' } });
49
+ const response = await fetch(`${issuer}/.well-known/jwks.json`, { headers: { accept: 'application/json' } });
48
50
  if (!response.ok) throw new Error(`the issuer's keys did not load (${response.status})`);
49
51
  const { keys } = await response.json();
50
52
  const loaded = new Map();
@@ -124,18 +126,20 @@ export async function delegatedRoomNoticeOf(api, token) {
124
126
 
125
127
  /** A token's claims when it is one this server trusts: signed by the issuer's key, for this server, in its lifetime. */
126
128
  async function verifiedClaims(api, token) {
127
- if (!api.delegatedIssuer || !api.publicOrigin || !looksDelegated(token)) return null;
129
+ if (!api.delegatedIssuers.length || !api.publicOrigin || !looksDelegated(token)) return null;
128
130
  try {
129
131
  const [head, body, signature] = token.split('.');
130
132
  const header = json(head);
131
133
  if (header.alg !== 'EdDSA' || typeof header.kid !== 'string') return null;
132
- const key = await issuerKey(api, header.kid);
134
+ // Only configured origins may select keys; an untrusted claim never triggers a fetch.
135
+ // Cache by issuer as well as kid so staging cannot reuse a production key identifier.
136
+ const claims = json(body);
137
+ if (!api.delegatedIssuers.includes(claims.iss) || claims.aud !== api.publicOrigin) return null;
138
+ const key = await issuerKey(api, claims.iss, header.kid);
133
139
  if (!key) return null;
134
140
  const signed = new TextEncoder().encode(`${head}.${body}`);
135
141
  if (!await crypto.subtle.verify({ name: 'Ed25519' }, key, fromBase64Url(signature), signed)) return null;
136
- const claims = json(body);
137
142
  const now = Math.floor(api.clock.nowMs() / 1000);
138
- if (claims.iss !== api.delegatedIssuer || claims.aud !== api.publicOrigin) return null;
139
143
  if (typeof claims.exp !== 'number' || claims.exp <= now - SKEW_S || (typeof claims.nbf === 'number' && claims.nbf > now + SKEW_S)) return null;
140
144
  if (typeof claims.sub !== 'string' || !claims.sub) return null;
141
145
  return claims;
package/server/http.mjs CHANGED
@@ -243,10 +243,31 @@ async function groupRoute(api,req,res,auth,groupId,principalId,url,requestId){
243
243
  statements.push({sql:'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',params:[auth.team_id]});await guardedMutation(api,res,auth,guards,statements,{status,body:response,targetId:target});api.events.publish(auth.team_id,{type:'authorization_changed',group_id:groupId});if(req.method==='DELETE')closeChannelsFor(api,auth.team_id,{groupId,principalId:principalId??null,reason:'group_changed'});send(res,status,response,requestId);
244
244
  }
245
245
  async function enroll(api,req,res,auth,requestId){if(auth.role==='viewer')fail(403,'forbidden','viewers cannot enroll machines');const b=await parseJson(req);exact(b,['name','enrollment_key']);if(!b.name||!b.enrollment_key)fail(400,'invalid_request','name and enrollment_key required');const byKey=()=>api.store.one('SELECT m.*,s.id source_id,s.name source_name,s.kind source_kind,s.state source_state,s.visibility,s.revision source_revision,s.last_received_at,s.decoder,s.counters_json,s.last_error FROM machines m JOIN sources s ON s.team_id=m.team_id AND s.machine_id=m.id WHERE m.team_id=? AND m.custodian_id=? AND m.enrollment_key=?',[auth.team_id,auth.principal_id,b.enrollment_key]);let machine=await byKey();
246
- // The same person enrolling a machine of the same name again (a reinstall, a lost home) takes that machine back; the
247
- // caller then rotates its credential, as for a machine it already knew.
248
- if(!machine){const own=await api.store.one('SELECT id FROM machines WHERE team_id=? AND custodian_id=? AND name=? AND revoked_at IS NULL',[auth.team_id,auth.principal_id,b.name]);if(own){await api.store.transaction([{sql:'UPDATE machines SET enrollment_key=? WHERE team_id=? AND id=?',params:[b.enrollment_key,auth.team_id,own.id]}]);machine=await byKey();}}
249
- if(machine){return send(res,200,result(api,auth,{machine:machineJson(machine,api.clock.nowMs(),api),source:sourceJson({id:machine.source_id,name:machine.source_name,kind:machine.source_kind,state:machine.source_state,machine_id:machine.id,custodian_id:machine.custodian_id,visibility:machine.visibility,revision:machine.source_revision,last_received_at:machine.last_received_at,decoder:machine.decoder,counters_json:machine.counters_json,last_error:machine.last_error}),credential:null}),requestId);}const mid=api.ids.next('machine'),sid=api.ids.next('source'),cid=api.ids.next('credential'),token=makeToken(cid),now=api.clock.nowMs(),data={machine:{id:mid,name:b.name,custodian_id:auth.principal_id,enrollment:'active',connection:'offline',last_seen_at:null,incarnation_id:null,revision:1},source:{id:sid,name:`${b.name} connector`,kind:'connector',state:'active',machine_id:mid,custodian_id:auth.principal_id,default_visibility:'restricted',revision:1,last_received_at:null,decoder:null},credential:{token,kind:'machine',expires_at:now+2592000000}},response=result(api,auth,data);await guardedMutation(api,res,auth,[...authGuards(auth,now),{sql:'SELECT 1 ok WHERE NOT EXISTS (SELECT 1 FROM sources WHERE team_id=? AND name=?)',params:[auth.team_id,`${b.name} connector`],code:'conflict',status:409,message:`a machine named ${b.name} is already enrolled in this team; enroll this one under another --name`}],[{sql:'INSERT INTO machines(team_id,id,name,custodian_id,enrollment_key) VALUES (?,?,?,?,?)',params:[auth.team_id,mid,b.name,auth.principal_id,b.enrollment_key]},{sql:"INSERT INTO sources(team_id,id,name,kind,machine_id,custodian_id,visibility,state) VALUES (?,?,?,?,?,?,?,'active')",params:[auth.team_id,sid,`${b.name} connector`,'connector',mid,auth.principal_id,'restricted']},{sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,source_id,machine_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?,?,?)',params:[cid,hashToken(token),auth.principal_id,'machine',auth.team_id,sid,mid,now+2592000000,now]}],{status:201,body:response,targetId:mid});send(res,201,response,requestId);}
246
+ // Enrollment is a fresh, authenticated custodial act. A disconnect revoked the
247
+ // old credentials, but must not permanently reserve this custodian's name.
248
+ if(!machine) machine=await api.store.one('SELECT m.*,s.id source_id,s.name source_name,s.kind source_kind,s.state source_state,s.visibility,s.revision source_revision,s.last_received_at,s.decoder,s.counters_json,s.last_error FROM machines m JOIN sources s ON s.team_id=m.team_id AND s.machine_id=m.id WHERE m.team_id=? AND m.custodian_id=? AND m.name=?',[auth.team_id,auth.principal_id,b.name]);
249
+ if(machine){
250
+ const now=api.clock.nowMs(),reviving=machine.revoked_at!=null;
251
+ const changed=reviving||machine.enrollment_key!==b.enrollment_key;
252
+ const cid=reviving?api.ids.next('credential'):null,token=reviving?makeToken(cid):null;
253
+ const updated=changed?{...machine,enrollment_key:b.enrollment_key,revision:machine.revision+1,...(reviving?{revoked_at:null,last_seen_at:null,incarnation_id:null,heartbeat_sequence:0}: {})}:machine;
254
+ const source=sourceJson({id:machine.source_id,name:machine.source_name,kind:machine.source_kind,state:reviving?'active':machine.source_state,machine_id:machine.id,custodian_id:machine.custodian_id,visibility:machine.visibility,revision:machine.source_revision+(reviving?1:0),last_received_at:machine.last_received_at,decoder:machine.decoder,counters_json:machine.counters_json,last_error:machine.last_error});
255
+ const response=result(api,auth,{machine:machineJson(updated,now,api),source,credential:reviving?{token,kind:'machine',expires_at:now+2592000000}:null});
256
+ if(changed){
257
+ const guards=[...authGuards(auth,now),{sql:'SELECT 1 ok FROM machines m JOIN sources s ON s.team_id=m.team_id AND s.machine_id=m.id WHERE m.team_id=? AND m.id=? AND m.custodian_id=? AND m.revision=? AND s.id=? AND s.revision=?',params:[auth.team_id,machine.id,auth.principal_id,machine.revision,machine.source_id,machine.source_revision],code:'revision_mismatch',status:409,message:'machine enrollment changed; retry connect'}];
258
+ const statements=[{sql:'UPDATE machines SET enrollment_key=?,revision=revision+1 WHERE team_id=? AND id=?',params:[b.enrollment_key,auth.team_id,machine.id]}];
259
+ if(reviving)statements.push(
260
+ {sql:'UPDATE machines SET revoked_at=NULL,last_seen_at=NULL,incarnation_id=NULL,heartbeat_sequence=0 WHERE team_id=? AND id=?',params:[auth.team_id,machine.id]},
261
+ {sql:"UPDATE sources SET state='active',revision=revision+1 WHERE team_id=? AND id=?",params:[auth.team_id,machine.source_id]},
262
+ {sql:'UPDATE credentials SET revoked_at=? WHERE team_id=? AND machine_id=? AND revoked_at IS NULL',params:[now,auth.team_id,machine.id]},
263
+ {sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,source_id,machine_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?,?,?)',params:[cid,hashToken(token),auth.principal_id,'machine',auth.team_id,machine.source_id,machine.id,now+2592000000,now]},
264
+ {sql:'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',params:[auth.team_id]});
265
+ await guardedMutation(api,res,auth,guards,statements,{status:200,body:response,targetId:machine.id});
266
+ if(reviving)api.events.publish(auth.team_id,{type:'authorization_changed',machine_id:machine.id});
267
+ }
268
+ return send(res,200,response,requestId);
269
+ }
270
+ const mid=api.ids.next('machine'),sid=api.ids.next('source'),cid=api.ids.next('credential'),token=makeToken(cid),now=api.clock.nowMs(),data={machine:{id:mid,name:b.name,custodian_id:auth.principal_id,enrollment:'active',connection:'offline',last_seen_at:null,incarnation_id:null,revision:1},source:{id:sid,name:`${b.name} connector`,kind:'connector',state:'active',machine_id:mid,custodian_id:auth.principal_id,default_visibility:'restricted',revision:1,last_received_at:null,decoder:null},credential:{token,kind:'machine',expires_at:now+2592000000}},response=result(api,auth,data);await guardedMutation(api,res,auth,[...authGuards(auth,now),{sql:'SELECT 1 ok WHERE NOT EXISTS (SELECT 1 FROM sources WHERE team_id=? AND name=?)',params:[auth.team_id,`${b.name} connector`],code:'conflict',status:409,message:`a machine named ${b.name} is already enrolled in this team; enroll this one under another --name`}],[{sql:'INSERT INTO machines(team_id,id,name,custodian_id,enrollment_key) VALUES (?,?,?,?,?)',params:[auth.team_id,mid,b.name,auth.principal_id,b.enrollment_key]},{sql:"INSERT INTO sources(team_id,id,name,kind,machine_id,custodian_id,visibility,state) VALUES (?,?,?,?,?,?,?,'active')",params:[auth.team_id,sid,`${b.name} connector`,'connector',mid,auth.principal_id,'restricted']},{sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,source_id,machine_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?,?,?)',params:[cid,hashToken(token),auth.principal_id,'machine',auth.team_id,sid,mid,now+2592000000,now]}],{status:201,body:response,targetId:mid});send(res,201,response,requestId);}
250
271
  // `online` is a machine that heartbeats and holds its link, so callers can reach it; `unlinked` heartbeats without one.
251
272
  const machineJson=(m,now,api)=>({id:m.id,name:m.name,custodian_id:m.custodian_id,enrollment:m.revoked_at==null?'active':'revoked',connection:m.last_seen_at!=null&&m.last_seen_at+45000>now?(!api||api.machineLinks?.has(`${m.team_id}\0${m.id}`)?'online':'unlinked'):'offline',last_seen_at:m.last_seen_at,incarnation_id:m.incarnation_id,host_key_id:m.host_key_id??null,revision:m.revision});
252
273
  async function machineAdminRoute(api,req,res,auth,machineId,roots,requestId){
@@ -157,11 +157,11 @@ export async function authorizeMachineUpgrade(api, { authenticate, url, headers
157
157
  const presented = bearerFrom(headers);
158
158
  // A Room member typing into a session bound to their Room: the session names the machine, the server finds it.
159
159
  const session = /^\/api\/v1\/teams\/([^/]+)\/sessions\/([^/]+)\/channel$/.exec(pathname);
160
- if (session) return api.delegatedIssuer && looksDelegated(presented) ? await delegatedSessionChannel(api, session[1], decodeURIComponent(session[2]), presented) : { status: 404, code: 'not_found', message: 'not found' };
160
+ if (session) return api.delegatedIssuers.length && looksDelegated(presented) ? await delegatedSessionChannel(api, session[1], decodeURIComponent(session[2]), presented) : { status: 404, code: 'not_found', message: 'not found' };
161
161
  const match = /^\/api\/v1\/teams\/([^/]+)\/machines\/([^/]+)\/(link|channel)$/.exec(pathname);
162
162
  if (!match) return { status: 404, code: 'not_found', message: 'not found' };
163
163
  const [, teamId, machineId, door] = match;
164
- if (door === 'channel' && api.delegatedIssuer && looksDelegated(presented)) return await delegatedChannel(api, teamId, machineId, presented);
164
+ if (door === 'channel' && api.delegatedIssuers.length && looksDelegated(presented)) return await delegatedChannel(api, teamId, machineId, presented);
165
165
  const auth = await authenticate(api, presented);
166
166
  if (!auth) return { status: 401, code: 'authentication_required', message: 'authentication required' };
167
167
  if (door === 'link') {
@@ -80,7 +80,7 @@ export async function publicationRoute({ api, req, res, auth, machineId, publica
80
80
  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)');
81
81
  const workplace = typeof body.workplace === 'string' ? originOf(body.workplace) : null;
82
82
  if (!workplace) fail(400, 'invalid_request', 'workplace must be an origin (https://host)');
83
- if (workplace !== api.delegatedIssuer) fail(400, 'invalid_request', `this server does not trust ${workplace} as a delegated issuer`);
83
+ if (!api.delegatedIssuers.includes(workplace)) fail(400, 'invalid_request', `this server does not trust ${workplace} as a delegated issuer`);
84
84
  if (typeof body.room !== 'string' || !body.room || body.room.length > 256) fail(400, 'invalid_request', 'room must name the workplace Room');
85
85
  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');
86
86
  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]);