@volter/teams 0.1.1 → 0.1.3

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/admission.mjs CHANGED
@@ -4,7 +4,8 @@
4
4
  // { id, caps, targets } rows from the caller's machine grants (design §11).
5
5
  //
6
6
  // Targets: `<kind>:<name>` for the kinds a product serves on this machine
7
- // (the harness: `pane:<contextKey>`, `runtime:<id>`, `root:<path>`, `port:<pane>/<n>`),
7
+ // (the harness: `pane:<contextKey>`, `runtime:<id>`, `root:<path>`, `port:<pane>/<n>`,
8
+ // `mail:<address>` for one session's mailbox),
8
9
  // `<kind>:*` for every target of one kind, or `*` (operate and view only).
9
10
  // `root:<path>` covers the path and everything under it.
10
11
 
package/channel.mjs CHANGED
@@ -182,10 +182,17 @@ export class Channel extends EventEmitter {
182
182
  this._streams = new Map();
183
183
  this._nextStream = initiator ? 1 : 2;
184
184
  this.closed = false;
185
+ // A broken channel ends every open stream with the reason, so each caller's
186
+ // request fails with it; 'error' is emitted only to someone listening for it,
187
+ // never as an uncaught exception that takes the process down.
188
+ const failed = (error) => {
189
+ this.error ??= error;
190
+ if (this.listenerCount('error') > 0) this.emit('error', error);
191
+ };
185
192
  frameReader(
186
193
  socket,
187
194
  (frame) => this._onEnvelope(JSON.parse(frame.toString('utf8'))),
188
- (error) => this.emit('error', error),
195
+ failed,
189
196
  );
190
197
  // The hello left the socket paused with any early frames unshifted; our reader is on now.
191
198
  socket.resume();
@@ -196,12 +203,12 @@ export class Channel extends EventEmitter {
196
203
  this.closed = true;
197
204
  for (const stream of this._streams.values()) {
198
205
  stream.closed = true;
199
- stream._deliver('close', 'connection closed');
206
+ stream._deliver('close', this.error ? `connection closed: ${this.error.message}` : 'connection closed');
200
207
  }
201
208
  this._streams.clear();
202
209
  this.emit('close');
203
210
  });
204
- socket.on('error', (error) => this.emit('error', error));
211
+ socket.on('error', failed);
205
212
  }
206
213
 
207
214
  _onEnvelope(envelope) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/teams",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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",
@@ -14,6 +14,7 @@
14
14
  "./machine-core": "./machine-core.mjs",
15
15
  "./home": "./home.mjs",
16
16
  "./server/core": "./server/core.mjs",
17
+ "./server/identity-orgs": "./server/identity-orgs.mjs",
17
18
  "./server/http": "./server/http.mjs",
18
19
  "./server/auth": "./server/auth.mjs",
19
20
  "./server/machine-link": "./server/machine-link.mjs",
@@ -37,7 +38,7 @@
37
38
  "node": ">=22"
38
39
  },
39
40
  "dependencies": {
40
- "@volter/identity": "^0.1.0",
41
+ "@volter/identity": "^0.3.0",
41
42
  "ws": "^8.21.0"
42
43
  }
43
44
  }
package/server/core.mjs CHANGED
@@ -13,7 +13,7 @@ const productionIds={next(kind){return `${prefix[kind]??kind}_${randomBytes(16).
13
13
  const productionClock={nowMs:()=>Date.now()};
14
14
  export { productionIds, productionClock };
15
15
 
16
- export function createTeamsApi({clock=productionClock,ids=productionIds,publicOrigin=null,identityIssuer=null,delegatedIssuer=null,uploadLimitBytes=256*1024*1024}={}) {
16
+ export function createTeamsApi({clock=productionClock,ids=productionIds,publicOrigin=null,identityIssuer=null,identityOrganizations=null,delegatedIssuer=null,uploadLimitBytes=256*1024*1024}={}) {
17
17
  // An identity service people may sign in with (protocol P02 federation); its tokens name this server's public origin.
18
18
  identityIssuer=normalizePublicOrigin(identityIssuer);
19
19
  if(identityIssuer&&!publicOrigin)throw new TypeError('an identity issuer needs the public origin its tokens name');
@@ -24,7 +24,9 @@ export function createTeamsApi({clock=productionClock,ids=productionIds,publicOr
24
24
  if(!Number.isSafeInteger(uploadLimitBytes)||uploadLimitBytes<1||uploadLimitBytes>256*1024*1024)throw new TypeError('uploadLimitBytes must be between 1 and 268435456');
25
25
  let store=null,serverId=null,localOrigin=null,harnessRoutes=null;
26
26
  const events=new TeamEvents();
27
- return {clock,ids,events,publicOrigin,identityIssuer,delegatedIssuer,uploadLimitBytes,rateState:new Map(),openStreams:new Map(),securityEvents:[],
27
+ // Teams linked to organizations the identity service keeps for this server (identity-orgs.mjs).
28
+ if(identityOrganizations&&!identityIssuer)throw new TypeError('organizations need the identity issuer that keeps them');
29
+ return {clock,ids,events,publicOrigin,identityIssuer,identityOrganizations,delegatedIssuer,uploadLimitBytes,rateState:new Map(),openStreams:new Map(),securityEvents:[],
28
30
  get store(){return store;},get serverId(){return serverId;},get localOrigin(){return localOrigin;},get harnessRoutes(){return harnessRoutes;},
29
31
  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;},
30
32
  };
package/server/http.mjs CHANGED
@@ -5,6 +5,7 @@ import { machineGrantRoute, machineGrantRows, closeMachineChannels, closeChannel
5
5
  import { exact, parseJson as parseRequestJson } from './request-json.mjs';
6
6
  import { identityOf } from './identity.mjs';
7
7
  import { serveViewer } from './viewer.mjs';
8
+ import { inOrganization, linkedOrganization } from './identity-orgs.mjs';
8
9
 
9
10
  class HttpError extends Error { constructor(status,code,message,details=null){super(message);this.status=status;this.code=code;this.details=details;} }
10
11
  const fail=(status,code,message,details)=>{throw new HttpError(status,code,message,details)};
@@ -136,14 +137,51 @@ async function acceptInvite(api,res,body,requestId){exact(body,['enrollment_code
136
137
  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);}
137
138
  /** Federation (P02): a verified identity token is a member's sign-in, answered with the same 30-day credential an invitation gives. */
138
139
  async function identityLogin(api,res,body,requestId){
139
- exact(body,['access_token','team_id']);if(typeof body.team_id!=='string')fail(400,'invalid_request','team_id is required');
140
+ 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');
140
141
  const person=await identityOf(api,body.access_token);if(!person)fail(401,'authentication_required','the identity token did not verify');
141
- const row=await api.store.one('SELECT p.id principal_id,p.display_name,t.id team_id,t.name team_name,t.revision team_revision FROM principal_identities i JOIN principals p ON p.id=i.principal_id JOIN memberships m ON m.principal_id=p.id AND m.active=1 JOIN teams t ON t.id=m.team_id WHERE i.issuer=? AND i.subject=? AND (t.id=? OR t.name=?)',[person.issuer,person.subject,body.team_id,body.team_id]);
142
+ // Without a team named, the person's own: the team they are a member of, or else the one whose organization lists
143
+ // them. Two or more are named back to choose from.
144
+ if(body.team_id===undefined)body.team_id=await teamOfPerson(api,person);
145
+ const memberRow=()=>api.store.one('SELECT p.id principal_id,p.display_name,t.id team_id,t.name team_name,t.revision team_revision FROM principal_identities i JOIN principals p ON p.id=i.principal_id JOIN memberships m ON m.principal_id=p.id AND m.active=1 JOIN teams t ON t.id=m.team_id WHERE i.issuer=? AND i.subject=? AND (t.id=? OR t.name=?)',[person.issuer,person.subject,body.team_id,body.team_id]);
146
+ let row=await memberRow();
147
+ // A team linked to an organization the identity service keeps for this server admits that organization's members
148
+ // with no invitation (identity-orgs.mjs): they join as members, exactly as an accepted invitation would add them.
149
+ if(!row)row=await joinThroughOrganization(api,person,body.team_id)&&await memberRow();
142
150
  if(!row)fail(401,'authentication_required','no member of this team is linked to that identity; accept an invitation with it, or link it while signed in');
143
151
  const now=api.clock.nowMs(),cid=api.ids.next('credential'),token=makeToken(cid);
144
152
  await api.store.transaction([{sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?)',params:[cid,hashToken(token),row.principal_id,'user',null,now+2592000000,now]}]);
145
153
  send(res,201,{principal:{id:row.principal_id,kind:'user',display_name:row.display_name},credential:{token,kind:'user',expires_at:now+2592000000},team:{id:row.team_id,name:row.team_name,revision:row.team_revision}},requestId);
146
154
  }
155
+ /** The one team a signed-in person belongs to, as a member or through a linked organization. */
156
+ async function teamOfPerson(api,person){
157
+ const rows=await api.store.query('SELECT t.id,t.name FROM principal_identities i JOIN memberships m ON m.principal_id=i.principal_id AND m.active=1 JOIN teams t ON t.id=m.team_id WHERE i.issuer=? AND i.subject=? ORDER BY t.name',[person.issuer,person.subject]);
158
+ let teams=rows;
159
+ if(!teams.length){
160
+ const linked=[];
161
+ for(const team of await api.store.query('SELECT id,name FROM teams ORDER BY name',[])){const slug=linkedOrganization(api,team);if(slug&&await inOrganization(api,person.subject,slug))linked.push(team);}
162
+ teams=linked;
163
+ }
164
+ if(teams.length===1)return teams[0].id;
165
+ if(!teams.length)fail(401,'authentication_required','you are not a member of a team on this server; ask a teammate for an invitation');
166
+ fail(409,'conflict',`you belong to several teams (${teams.map(t=>t.name).join(', ')}); name one with --team`);
167
+ }
168
+ /** Make `person` a member of team `team` when the team is linked to an organization they belong to; true if joined. */
169
+ async function joinThroughOrganization(api,person,team){
170
+ const found=await api.store.one('SELECT id,name FROM teams WHERE id=? OR name=?',[team,team]);if(!found)return false;
171
+ const slug=linkedOrganization(api,found);if(!slug)return false;
172
+ if(!await inOrganization(api,person.subject,slug))return false;
173
+ const now=api.clock.nowMs(),linked=await api.store.one('SELECT principal_id FROM principal_identities WHERE issuer=? AND subject=?',[person.issuer,person.subject]);
174
+ const pid=linked?.principal_id??api.ids.next('user');
175
+ const statements=[];
176
+ if(!linked)statements.push({sql:'INSERT INTO principals(id,kind,display_name) VALUES (?,?,?)',params:[pid,'user',person.name??person.email??person.subject]},{sql:'INSERT INTO principal_identities(issuer,subject,principal_id,linked_at) VALUES (?,?,?,?)',params:[person.issuer,person.subject,pid,now]});
177
+ const member=await api.store.one('SELECT active FROM memberships WHERE team_id=? AND principal_id=?',[found.id,pid]);
178
+ if(member?.active)return true;
179
+ // A member removed from the team stays removed: the organization does not bring them back.
180
+ if(member)return false;
181
+ statements.push({sql:'INSERT INTO memberships(team_id,principal_id,role) VALUES (?,?,?)',params:[found.id,pid,'member']});
182
+ await api.store.transaction(statements);
183
+ return true;
184
+ }
147
185
  /** A signed-in member links an identity service's subject to themselves, once per issuer. */
148
186
  async function linkIdentity(api,req,res,auth,requestId){
149
187
  if(auth.kind!=='user')fail(403,'forbidden','only a user links an identity');
@@ -181,7 +219,11 @@ async function groupRoute(api,req,res,auth,groupId,principalId,url,requestId){
181
219
  else{const group=await api.store.one('SELECT revision FROM groups_table WHERE team_id=? AND id=?',[auth.team_id,groupId]);if(!group)fail(404,'not_found','group not found');requireRevision(req,group.revision,'group');guards.push({sql:'SELECT 1 ok FROM groups_table WHERE team_id=? AND id=? AND revision=?',params:[auth.team_id,groupId,group.revision],code:'revision_mismatch',status:409,message:'group revision changed'});statements.push({sql:'DELETE FROM session_grants WHERE team_id=? AND subject_kind=\'group\' AND subject_id=?',params:[auth.team_id,groupId]},{sql:'DELETE FROM group_members WHERE team_id=? AND group_id=?',params:[auth.team_id,groupId]},{sql:'DELETE FROM groups_table WHERE team_id=? AND id=?',params:[auth.team_id,groupId]});}
182
220
  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);
183
221
  }
184
- 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');let 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.enrollment_key=?',[auth.team_id,auth.principal_id,b.enrollment_key]);if(machine){return send(res,200,result(api,auth,{machine:machineJson(machine,api.clock.nowMs()),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);}
222
+ 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();
223
+ // The same person enrolling a machine of the same name again (a reinstall, a lost home) takes that machine back; the
224
+ // caller then rotates its credential, as for a machine it already knew.
225
+ 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();}}
226
+ if(machine){return send(res,200,result(api,auth,{machine:machineJson(machine,api.clock.nowMs()),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);}
185
227
  const machineJson=(m,now)=>({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?'online':'offline',last_seen_at:m.last_seen_at,incarnation_id:m.incarnation_id,host_key_id:m.host_key_id??null,revision:m.revision});
186
228
  async function machineAdminRoute(api,req,res,auth,machineId,roots,requestId){
187
229
  // Custodians and admins see their machines; a subject holding a live machine grant sees that machine (roots stay custodial).
@@ -0,0 +1,54 @@
1
+ // Joining a team through an identity service's organization (Volter ID ADR-0002): a team may be linked to an
2
+ // organization the identity service keeps for this server, and a person signed in with that service who is a member of
3
+ // it joins the team with no invitation. The identity service's tokens name only the person, so membership is asked
4
+ // through its organizations door with this server's own product credential (a `client_credentials` token for
5
+ // `<issuer>/organizations`, scope `organizations`), which reads only the organizations this product created.
6
+ //
7
+ // Configured by `identityOrganizations: { clientId, clientSecret, teams: { <team id or name>: <organization slug> } }`,
8
+ // from the environment as VOLTER_ORG_CLIENT_ID, VOLTER_ORG_CLIENT_SECRET and VOLTER_TEAM_ORGS (`team=slug,…`).
9
+
10
+ /** The configuration from the environment, or null when a piece is missing. */
11
+ export function identityOrganizationsFromEnv(env) {
12
+ const clientId = env.VOLTER_ORG_CLIENT_ID, clientSecret = env.VOLTER_ORG_CLIENT_SECRET, pairs = env.VOLTER_TEAM_ORGS;
13
+ if (!clientId || !clientSecret || !pairs) return null;
14
+ const teams = {};
15
+ for (const pair of String(pairs).split(',')) {
16
+ const [team, slug] = pair.split('=').map((part) => part?.trim());
17
+ if (team && slug) teams[team] = slug;
18
+ }
19
+ return Object.keys(teams).length ? { clientId, clientSecret, teams } : null;
20
+ }
21
+
22
+ /** The organization slug a team is linked to, by its id or its name. */
23
+ export function linkedOrganization(api, team) {
24
+ const teams = api.identityOrganizations?.teams ?? {};
25
+ return teams[team.id] ?? teams[team.name] ?? null;
26
+ }
27
+
28
+ async function productToken(api) {
29
+ const held = api.identityOrganizationToken;
30
+ if (held && held.expiresAt > api.clock.nowMs() + 60_000) return held.token;
31
+ const discovery = await (await fetch(`${api.identityIssuer}/.well-known/openid-configuration`)).json();
32
+ const { clientId, clientSecret } = api.identityOrganizations;
33
+ const response = await fetch(discovery.token_endpoint, {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: `Basic ${btoa(`${clientId}:${clientSecret}`)}` },
36
+ body: new URLSearchParams({ grant_type: 'client_credentials', scope: 'organizations', resource: `${api.identityIssuer}/organizations` }),
37
+ });
38
+ if (!response.ok) throw new Error(`the identity service refused this server's product credential (${response.status})`);
39
+ const value = await response.json();
40
+ api.identityOrganizationToken = { token: value.access_token, expiresAt: api.clock.nowMs() + (Number(value.expires_in) || 300) * 1000 };
41
+ return value.access_token;
42
+ }
43
+
44
+ /** Whether the identity service's subject is a member of the organization `slug` this product keeps there. */
45
+ export async function inOrganization(api, subject, slug) {
46
+ if (!api.identityOrganizations || !api.identityIssuer) return false;
47
+ const token = await productToken(api);
48
+ const response = await fetch(`${api.identityIssuer}/api/people/${encodeURIComponent(subject)}/organizations`, { headers: { Authorization: `Bearer ${token}` } });
49
+ if (response.status === 404) return false;
50
+ if (!response.ok) throw new Error(`the identity service did not answer for that person's organizations (${response.status})`);
51
+ const value = await response.json();
52
+ const rows = Array.isArray(value) ? value : Array.isArray(value?.organizations) ? value.organizations : [];
53
+ return rows.some((row) => row?.slug === slug);
54
+ }
@@ -19,8 +19,8 @@ import { delegatedAccessOf, delegatedPrincipal, looksDelegated } from './delegat
19
19
 
20
20
  const LINK_PROTOCOL = 'supercode-machine-link.v1';
21
21
  const CHANNEL_PROTOCOL = 'supercode-machine-channel.v1';
22
- /** The machine daemon's door vocabulary: operate (operator: harness.v1 and launch) and the participant caps its doors check. */
23
- const CAPS = new Set(['operate', 'view', 'observe', 'interact', 'control', 'files', 'ports']);
22
+ /** The machine daemon's door vocabulary: operate (operator: harness.v1 and launch) and the participant caps its doors check. `mail` writes one message into a session there (`mail:*`, or `mail:<address>` for one session). */
23
+ const CAPS = new Set(['operate', 'view', 'observe', 'interact', 'control', 'files', 'ports', 'mail']);
24
24
  // A target is `*` or `<kind>:<name>`. The server checks shape only: which kinds a machine
25
25
  // serves is the daemon's business, and a grant on a kind it does not serve covers nothing.
26
26
  const TARGET = /^(\*|root:(\*|\/[^\x00-\x1f]{0,4095})|(?!root:)[a-z][a-z0-9-]{0,31}:[^\x00-\x1f]{1,4096})$/;