@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 +1 -1
- package/package.json +1 -1
- package/server/core.mjs +8 -0
- package/server/http.mjs +13 -2
- package/server/machine-link.mjs +5 -2
- package/server/migrations/025.mjs +6 -0
- package/server/migrations/index.mjs +2 -1
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.
|
|
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);}
|
package/server/machine-link.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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). */
|