@volter/teams 0.2.27 → 0.2.29

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 CHANGED
@@ -247,7 +247,7 @@ export class MachineCore extends EventEmitter {
247
247
  }
248
248
  const index = this.nextConnection++;
249
249
  // A delegated caller's claim also carries the name its workplace signed for it (who typed a turn).
250
- 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 }) };
250
+ const entry = { channel, admitted, principal: claim.principal, principalName: typeof claim.principal_name === 'string' ? claim.principal_name : null, 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 }) };
251
251
  this.connections.set(index, entry);
252
252
  stream.on('close', () => { this.connections.delete(index); this.connectionClosed(index); });
253
253
  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.2.27",
3
+ "version": "0.2.29",
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
@@ -106,6 +106,14 @@ export async function maintain(api,{budgetMs=MAINTAIN_BUDGET_MS}={}){const now=a
106
106
  }
107
107
  });
108
108
  // A committed upload nothing names (no artifact, continuation or in-flight capture: upload_refs) releases its bundle.
109
+ // A team with native capture off holds no native transcripts: its artifact rows go a batch at a time, and the
110
+ // uploads and blobs they named follow through the steps below once nothing else names them.
111
+ await step('native off',async()=>{
112
+ const rows=await q(`SELECT a.rowid id FROM session_artifacts a JOIN teams t ON t.id=a.team_id WHERE t.native_capture=0 LIMIT 200`);
113
+ if(!rows.length)return;
114
+ await tx(rows.map(row=>({sql:'DELETE FROM session_artifacts WHERE rowid=?',params:[row.id]})));
115
+ count('native artifacts',rows.length);if(rows.length>=200)more=true;
116
+ });
109
117
  await step('uploads',async()=>{
110
118
  if(!await walked(api,['artifact_refs','continuation_refs','captures','uploads'])){more=true;return;}
111
119
  const queued=await q(`SELECT team_id,upload_id,generation FROM upload_prune_queue LIMIT ${UPLOAD_PRUNE_BATCH}`);
package/server/http.mjs CHANGED
@@ -86,6 +86,7 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
86
86
  if(req.method==='POST'&&sub==='/invitations')return await createInvitation(api,req,res,auth,requestId);
87
87
  if(req.method==='POST'&&sub==='/credentials')return await createCredential(api,req,res,auth,requestId);
88
88
  if(req.method==='DELETE'&&(m=/^\/credentials\/([^/]+)$/.exec(sub)))return await revokeCredential(api,req,res,auth,m[1],requestId);
89
+ if((req.method==='GET'||req.method==='PATCH')&&sub==='/settings')return await teamSettings(api,req,res,auth,requestId);
89
90
  if(req.method==='GET'&&sub==='/members')return await listMembers(api,res,auth,url,requestId);
90
91
  if(req.method==='PATCH'&&(m=/^\/members\/([^/]+)$/.exec(sub)))return await updateMember(api,req,res,auth,m[1],requestId);
91
92
  if(req.method==='DELETE'&&(m=/^\/members\/([^/]+)$/.exec(sub)))return await removeMember(api,req,res,auth,m[1],requestId);
@@ -317,6 +318,16 @@ async function collectionPage(api,auth,url,name,items){
317
318
  const page=remaining.slice(0,limit),rest=remaining.slice(limit);let next=null;if(rest.length){next=cursorToken();const team=await api.store.one('SELECT auth_epoch,journal_revision FROM teams WHERE id=?',[auth.team_id]);await api.store.exec('INSERT INTO cursors(id,team_id,principal_id,kind,query_json,auth_epoch,revision,snapshot_json,expires_at) VALUES (?,?,?,?,?,?,?,?,?)',[cursorHash(next),auth.team_id,auth.principal_id,'list',JSON.stringify(normalized),team.auth_epoch,team.journal_revision,JSON.stringify(rest),api.clock.nowMs()+900000]);}return{version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},items:page,next_cursor:next};
318
319
  }
319
320
  async function listMembers(api,res,auth,url,requestId){const rows=auth.role==='viewer'?await api.store.query('SELECT m.*,p.kind,p.display_name FROM memberships m JOIN principals p ON p.id=m.principal_id WHERE m.team_id=? AND m.principal_id=? AND m.active=1',[auth.team_id,auth.principal_id]):await api.store.query('SELECT m.*,p.kind,p.display_name FROM memberships m JOIN principals p ON p.id=m.principal_id WHERE m.team_id=? AND m.active=1 ORDER BY p.display_name,m.principal_id',[auth.team_id]);send(res,200,await collectionPage(api,auth,url,'members',rows.map(membershipJson)),requestId);}
321
+ // The team's own settings. native_capture: whether machines upload each session's native transcript (see migration 025).
322
+ async function teamSettings(api,req,res,auth,requestId){
323
+ const read=async()=>{const t=await api.store.one('SELECT native_capture FROM teams WHERE id=?',[auth.team_id]);return{native_capture:t.native_capture===1};};
324
+ if(req.method==='GET')return send(res,200,result(api,auth,await read()),requestId);
325
+ if(!isAdmin(auth))fail(403,'forbidden','forbidden');
326
+ const body=await parseJson(req);exact(body,['native_capture']);if(typeof body.native_capture!=='boolean')fail(400,'invalid_request','native_capture must be a boolean');
327
+ const now=api.clock.nowMs(),response=result(api,auth,{native_capture:body.native_capture});
328
+ await guardedMutation(api,res,auth,authGuards(auth,now),[{sql:'UPDATE teams SET native_capture=? WHERE id=?',params:[body.native_capture?1:0,auth.team_id]}],{status:200,body:response,targetId:auth.team_id});
329
+ send(res,200,response,requestId);
330
+ }
320
331
  async function updateMember(api,req,res,auth,principalId,requestId){await requireUnlinked(api,auth.team_id);
321
332
  if(!isAdmin(auth))fail(403,'forbidden','forbidden');
322
333
  const body=await parseJson(req);exact(body,['role']);if(!['owner','admin','member','viewer'].includes(body.role))fail(400,'invalid_request','invalid team role');
@@ -385,12 +396,12 @@ async function heartbeat(api,req,res,auth,machineId,requestId){if(auth.kind!=='m
385
396
  async function machineConfiguration(api,res,auth,machineId,requestId){
386
397
  if(auth.kind!=='machine'||auth.machine_id!==machineId)fail(auth.kind==='machine'?404:403,auth.kind==='machine'?'not_found':'forbidden','not found');
387
398
  const [machine,rules]=await api.store.snapshot([
388
- {kind:'one',sql:'SELECT m.*,(SELECT linked FROM machine_presence p WHERE p.team_id=m.team_id AND p.machine_id=m.id) presence_linked,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,t.auth_epoch FROM machines m JOIN sources s ON s.team_id=m.team_id AND s.machine_id=m.id JOIN teams t ON t.id=m.team_id JOIN memberships mem ON mem.team_id=m.team_id AND mem.principal_id=m.custodian_id AND mem.active=1 WHERE m.team_id=? AND m.id=? AND m.revoked_at IS NULL',params:[auth.team_id,machineId]},
399
+ {kind:'one',sql:'SELECT m.*,(SELECT linked FROM machine_presence p WHERE p.team_id=m.team_id AND p.machine_id=m.id) presence_linked,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,t.auth_epoch,t.native_capture FROM machines m JOIN sources s ON s.team_id=m.team_id AND s.machine_id=m.id JOIN teams t ON t.id=m.team_id JOIN memberships mem ON mem.team_id=m.team_id AND mem.principal_id=m.custodian_id AND mem.active=1 WHERE m.team_id=? AND m.id=? AND m.revoked_at IS NULL',params:[auth.team_id,machineId]},
389
400
  {kind:'query',sql:'SELECT r.* FROM sync_rules r JOIN machines m ON m.team_id=r.team_id AND m.id=r.machine_id JOIN sources s ON s.team_id=r.team_id AND s.id=r.source_id WHERE r.team_id=? AND r.machine_id=? AND m.revoked_at IS NULL AND s.machine_id=m.id ORDER BY r.id',params:[auth.team_id,machineId]},
390
401
  ]);
391
402
  if(!machine)fail(404,'not_found','not found');
392
403
  if(machine.source_state==='revoked')fail(403,'source_revoked','source revoked');
393
- 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:machineId,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}),rules:rules.map(syncRuleJson),custodian_id:machine.custodian_id,machine_grants:await machineGrantRows(api,auth.team_id,machineId),authorization_revision:machine.auth_epoch}),requestId);
404
+ 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:machineId,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}),rules:rules.map(syncRuleJson),custodian_id:machine.custodian_id,machine_grants:await machineGrantRows(api,auth.team_id,machineId),authorization_revision:machine.auth_epoch,native_capture:machine.native_capture===1}),requestId);
394
405
  }
395
406
  const syncRuleJson=r=>({id:r.id,machine_id:r.machine_id,source_id:r.source_id,selector:JSON.parse(r.selector_json),visibility:r.visibility,backfill:JSON.parse(r.backfill_json),state:r.state,revision:r.revision,capture_after:r.capture_after});
396
407
  async function createSource(api,req,res,auth,requestId){if(!isAdmin(auth))fail(403,'forbidden','forbidden');const b=await parseJson(req);exact(b,['name','kind','custodian_id','visibility']);if(b.kind!=='otlp'||!['team','restricted'].includes(b.visibility))fail(400,'invalid_request','only otlp sources may be created');const id=api.ids.next('source'),now=api.clock.nowMs(),response=result(api,auth,{id,name:b.name,kind:'otlp',state:'active',machine_id:null,custodian_id:b.custodian_id,default_visibility:b.visibility,revision:1,last_received_at:null,decoder:null}),guards=[...authGuards(auth,now),{sql:'SELECT 1 ok FROM memberships WHERE team_id=? AND principal_id=? AND active=1',params:[auth.team_id,b.custodian_id],code:'invalid_request',status:400,message:'source custodian must be active'}];await guardedMutation(api,res,auth,guards,[{sql:"INSERT INTO sources(team_id,id,name,kind,custodian_id,visibility,state) VALUES (?,?,?,?,?,?,'active')",params:[auth.team_id,id,b.name,'otlp',b.custodian_id,b.visibility]}],{status:201,body:response,targetId:id});send(res,201,response,requestId);}
@@ -73,13 +73,16 @@ async function claimFor(api, auth, machineId) {
73
73
  const membership = await api.store.one('SELECT active FROM memberships WHERE team_id=? AND principal_id=?', [auth.team_id, auth.principal_id]);
74
74
  if (!membership?.active) return null;
75
75
  const groups = auth.group_ids ?? [];
76
- if (machine.custodian_id === auth.principal_id) return { principal: auth.principal_id, groups, custodian: true };
76
+ // The principal's name travels with its claim: the machine says who sent what came over this stream (a sender's
77
+ // identity is the link's, never a return address) without asking anyone.
78
+ const principal_name = (await api.store.one('SELECT display_name FROM principals WHERE id=?', [auth.principal_id]))?.display_name ?? null;
79
+ if (machine.custodian_id === auth.principal_id) return { principal: auth.principal_id, principal_name, groups, custodian: true };
77
80
  const subjects = [auth.principal_id, ...groups];
78
81
  const rows = await api.store.query(`SELECT id, revision FROM machine_grants WHERE team_id=? AND machine_id=? AND expires_at>? AND subject_id IN (${subjects.map(() => '?').join(',')}) ORDER BY id`, [auth.team_id, machineId, api.clock.nowMs(), ...subjects]);
79
82
  // Every member holds the team's standing mail grant (TEAM_MAIL_GRANT) besides their own.
80
83
  const held = [...rows, { id: TEAM_MAIL_GRANT, revision: 1 }];
81
84
  // Revisions let the machine notice a grant it holds has changed (rewritten, narrowed) and resync.
82
- return { principal: auth.principal_id, groups: [...groups, teamSubject(auth.team_id)], grants: held.map((row) => row.id), revisions: Object.fromEntries(held.map((row) => [row.id, row.revision])) };
85
+ return { principal: auth.principal_id, principal_name, groups: [...groups, teamSubject(auth.team_id)], grants: held.map((row) => row.id), revisions: Object.fromEntries(held.map((row) => [row.id, row.revision])) };
83
86
  }
84
87
 
85
88
  /** Close every channel a member leaving the team, or a group losing a member or being deleted, leaves without its claimed authority. */
@@ -0,0 +1,6 @@
1
+ // A team chooses whether its machines upload each session's native transcript (the byte-exact file a harness resumes
2
+ // from) beside its messages. Off, the server refuses those uploads and retention releases the copies it holds; native
3
+ // continuation and export of a session then answer fidelity_unavailable. Constant cost at activation: one column.
4
+ export default `
5
+ ALTER TABLE teams ADD COLUMN native_capture INTEGER NOT NULL DEFAULT 1 CHECK(native_capture IN (0,1));
6
+ `;
@@ -23,7 +23,8 @@ import m021 from './021.mjs';
23
23
  import m022 from './022.mjs';
24
24
  import m023 from './023.mjs';
25
25
  import m024 from './024.mjs';
26
- export const MIGRATIONS = [m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014, m015, m016, m017, m018, m019, m020, m021, m022, m023, m024];
26
+ import m025 from './025.mjs';
27
+ export const MIGRATIONS = [m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014, m015, m016, m017, m018, m019, m020, m021, m022, m023, m024, m025];
27
28
  /** A message row's `until_revision` while its session's current version still holds it (schema 9). */
28
29
  export const OPEN_REVISION = Number.MAX_SAFE_INTEGER;
29
30
  /** SQL: message row `m` belongs to the version at revision ?1 (bind the revision twice). */