@volter/teams 0.2.39 → 0.2.41
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 +1 -1
- package/server/http.mjs +63 -7
- package/server/migrations/027.mjs +13 -0
- package/server/migrations/index.mjs +2 -1
- package/server/published-events.mjs +51 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/teams",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.41",
|
|
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/http.mjs
CHANGED
|
@@ -23,7 +23,21 @@ const parseJson=req=>parseRequestJson(req,fail);
|
|
|
23
23
|
const result=(api,auth,data)=>({version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},data});
|
|
24
24
|
function sideEffectStatements(api,res,status,body,now,targetId=null,journal=false){const statements=[];if(status<400&&res._idempotencyRecord){const record=res._idempotencyRecord,safe=redactSecretReplay(record.path,body);statements.push({sql:'INSERT INTO idempotency(team_id,principal_id,route,key,request_digest,response_status,response_json,expires_at) VALUES (?,?,?,?,?,?,?,?)',params:[record.teamId,record.principalId,record.route,record.key,record.requestDigest,status,journal?{$journalJsonValue:safe}:JSON.stringify(safe),now+86400000]});}if(res._auditRecord){const record=res._auditRecord;statements.push({sql:'INSERT INTO audit(team_id,id,actor_id,operation,target_id,outcome,revision,created_at,actor_json) VALUES (?,?,?,?,?,?,?,?,?)',params:[record.teamId,api.ids.next('audit'),record.actorId,record.operation,targetId,status<400?'allowed':'denied',journal?{$journalRevision:true}:null,now,JSON.stringify(record.act??[])]});}return statements;}
|
|
25
25
|
async function persistSideEffects(api,res,status,body){const statements=sideEffectStatements(api,res,status,body,api.clock.nowMs());if(statements.length)await api.store.transaction(statements);}
|
|
26
|
-
function send(res,status,body,requestId,headers={}){
|
|
26
|
+
function send(res,status,body,requestId,headers={}){
|
|
27
|
+
const deliver=(actualStatus=status,actualBody=body,actualHeaders=headers)=>{
|
|
28
|
+
// The host observes the response actually issued, including a refusal
|
|
29
|
+
// whose audit write failed. It decides which diagnostic fields are safe.
|
|
30
|
+
res._workResponse?.(actualStatus,actualBody);
|
|
31
|
+
res.writeHead(actualStatus,{'Content-Type':'application/json; charset=utf-8','Cache-Control':'no-store','X-Request-ID':requestId,...actualHeaders});
|
|
32
|
+
res.end(actualBody===null?'':JSON.stringify(actualBody));
|
|
33
|
+
};
|
|
34
|
+
if(!res._sideEffectsCommitted&&res._api&&(res._idempotencyRecord||res._auditRecord))return persistSideEffects(res._api,res,status,body).then(()=>deliver(),error=>{
|
|
35
|
+
if(/UNIQUE constraint failed: idempotency\./.test(error?.message??''))return deliver();
|
|
36
|
+
if(res.headersSent)return;
|
|
37
|
+
deliver(500,{error:{code:'internal',message:'The request was not recorded.'}},{});
|
|
38
|
+
});
|
|
39
|
+
deliver();
|
|
40
|
+
}
|
|
27
41
|
async function guardedMutation(api,res,auth,guards,statements,{status,body,targetId=null,journal=false}){const now=api.clock.nowMs(),all=[...statements,...sideEffectStatements(api,res,status,body,now,targetId,journal)],result=journal?await api.store.journalTransaction(auth.team_id,[...guards,...appAuthGuards(auth,now)],all):await api.store.guardedTransaction([...guards,...appAuthGuards(auth,now)],all);res._sideEffectsCommitted=true;api.events.publish(auth.team_id,{type:'events_available'});return result;}
|
|
28
42
|
const requireRevision=(req,current,label='resource')=>{const expected=Number(String(req.headers['if-match']??'').replaceAll('"',''));if(!Number.isSafeInteger(expected))fail(400,'invalid_request','If-Match is required');if(expected!==current)fail(409,'revision_mismatch',`${label} revision changed`);return expected;};
|
|
29
43
|
const CORS={'Access-Control-Allow-Origin':'*','Access-Control-Expose-Headers':'ETag, X-Request-ID, Content-Range'};
|
|
@@ -398,12 +412,54 @@ async function revokeMachine(api,req,res,auth,machineId,requestId){
|
|
|
398
412
|
}
|
|
399
413
|
/** This machine's sessions someone is watching live (an open follow stream), as {harness, native_id}. */
|
|
400
414
|
async function watchedFor(api,teamId,machineId){const ids=[...(api.watchers?.keys()??[])].filter(key=>key.startsWith(`${teamId}:`)).map(key=>key.slice(teamId.length+1)).slice(0,90);if(!ids.length)return [];return (await api.store.query(`SELECT o.harness,o.native_id FROM origins o JOIN sources s ON s.team_id=o.team_id AND s.id=o.source_id WHERE o.team_id=? AND s.machine_id=? AND o.session_id IN (${ids.map(()=>'?').join(',')})`,[teamId,machineId,...ids])).map(({harness,native_id})=>({harness,native_id}));}
|
|
401
|
-
async function heartbeat(api,req,res,auth,machineId,requestId){
|
|
402
|
-
if(
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const
|
|
406
|
-
|
|
415
|
+
async function heartbeat(api,req,res,auth,machineId,requestId){
|
|
416
|
+
if(auth.kind!=='machine'||auth.machine_id!==machineId)fail(403,'forbidden','exact machine credential required');
|
|
417
|
+
const machine=await api.store.one('SELECT m.*,(SELECT linked FROM machine_presence p WHERE p.team_id=m.team_id AND p.machine_id=m.id) presence_linked FROM machines m WHERE m.team_id=? AND m.id=?',[auth.team_id,machineId]);
|
|
418
|
+
if(!machine||machine.revoked_at!=null)fail(403,'forbidden','machine enrollment is not active');
|
|
419
|
+
const b=await parseJson(req);
|
|
420
|
+
exact(b,['incarnation_id','sequence','bindings','roots','host_key_id','activity']);
|
|
421
|
+
if(b.activity!==undefined&&(!Array.isArray(b.activity)||b.activity.length>4096))fail(400,'invalid_request','invalid activity');
|
|
422
|
+
if(b.host_key_id!==undefined&&(typeof b.host_key_id!=='string'||!/^[A-Za-z0-9_-]{16,128}$/.test(b.host_key_id)))fail(400,'invalid_request','invalid host_key_id');
|
|
423
|
+
if(typeof b.incarnation_id!=='string'||!Number.isSafeInteger(b.sequence)||b.sequence<1||!Array.isArray(b.bindings)||!Array.isArray(b.roots))fail(400,'invalid_request','invalid heartbeat');
|
|
424
|
+
if(machine.incarnation_id===b.incarnation_id&&b.sequence<=machine.heartbeat_sequence)return send(res,200,result(api,auth,{...machineJson(machine,api.clock.nowMs(),api),watched:await watchedFor(api,auth.team_id,machineId)}),requestId);
|
|
425
|
+
const rootIds=new Set();
|
|
426
|
+
for(const root of b.roots){
|
|
427
|
+
exact(root,['id','machine_id','name','harnesses'],'root');
|
|
428
|
+
if(root.machine_id!==machineId)fail(400,'invalid_request','root machine mismatch');
|
|
429
|
+
if(rootIds.has(root.id))fail(400,'invalid_request','duplicate root');
|
|
430
|
+
rootIds.add(root.id);
|
|
431
|
+
}
|
|
432
|
+
for(const binding of b.bindings){
|
|
433
|
+
if(!binding||typeof binding!=='object'||typeof binding.id!=='string'||!binding.id||typeof binding.harness!=='string'||typeof binding.native_session_id!=='string')fail(400,'invalid_request','invalid binding');
|
|
434
|
+
if(binding.machine_id!==machineId||binding.incarnation_id!==b.incarnation_id)fail(400,'invalid_request','binding machine or incarnation mismatch');
|
|
435
|
+
}
|
|
436
|
+
let activity=null;
|
|
437
|
+
if(Array.isArray(b.activity)){
|
|
438
|
+
const byNative=new Map();
|
|
439
|
+
for(const item of b.activity){
|
|
440
|
+
exact(item,['harness','native_id','turn','since'],'activity');
|
|
441
|
+
if(typeof item.harness!=='string'||!item.harness||item.harness.length>64||typeof item.native_id!=='string'||!item.native_id||item.native_id.length>256||typeof item.turn!=='string'||!item.turn||item.turn.length>32||(item.since!=null&&!Number.isSafeInteger(item.since)))fail(400,'invalid_request','invalid activity');
|
|
442
|
+
byNative.set(JSON.stringify([item.harness,item.native_id]),{harness:item.harness,native_id:item.native_id,turn:item.turn,since:item.since??null});
|
|
443
|
+
}
|
|
444
|
+
const entries=[...byNative.entries()].sort(([a],[b])=>a<b?-1:a>b?1:0);
|
|
445
|
+
const json=canonicalJson(entries.map(([,item])=>item));
|
|
446
|
+
activity={json,digest:sha256(json)};
|
|
447
|
+
}
|
|
448
|
+
const now=api.clock.nowMs(),newIncarnation=machine.incarnation_id!==null&&machine.incarnation_id!==b.incarnation_id;
|
|
449
|
+
const statements=[{sql:'UPDATE machines SET incarnation_id=?,heartbeat_sequence=?,last_seen_at=?,host_key_id=COALESCE(?,host_key_id),revision=revision+1 WHERE team_id=? AND id=?',params:[b.incarnation_id,b.sequence,now,b.host_key_id??null,auth.team_id,machineId]}];
|
|
450
|
+
if(newIncarnation)statements.push(
|
|
451
|
+
{sql:"UPDATE attachments SET state='closed',closed_at=? WHERE team_id=? AND machine_id=? AND state IN ('pending','active')",params:[now,auth.team_id,machineId]},
|
|
452
|
+
{sql:"UPDATE operations SET state='unknown' WHERE team_id=? AND id IN (SELECT operation_id FROM native_effects WHERE team_id=? AND machine_id=? AND state IN ('pending','running'))",params:[auth.team_id,auth.team_id,machineId]},
|
|
453
|
+
{sql:"UPDATE native_effects SET state='unknown',result_json=? WHERE team_id=? AND machine_id=? AND state IN ('pending','running')",params:[JSON.stringify({state:'unknown',error:null}),auth.team_id,machineId]}
|
|
454
|
+
);
|
|
455
|
+
const snapshotIndex=statements.length;
|
|
456
|
+
statements.push({kind:'heartbeat_snapshot',snapshot:{teamId:auth.team_id,machineId,sourceId:auth.source_id,incarnationId:b.incarnation_id,now,rootsJson:canonicalJson(b.roots),bindingsJson:canonicalJson(b.bindings),activity}});
|
|
457
|
+
const updated={...machine,incarnation_id:b.incarnation_id,heartbeat_sequence:b.sequence,last_seen_at:now,revision:machine.revision+1},response=result(api,auth,machineJson(updated,now,api));
|
|
458
|
+
const guards=[{sql:"SELECT 1 ok FROM credentials c JOIN machines m ON m.team_id=c.team_id AND m.id=c.machine_id JOIN sources s ON s.team_id=c.team_id AND s.id=c.source_id JOIN memberships mem ON mem.team_id=m.team_id AND mem.principal_id=m.custodian_id WHERE c.id=? AND c.kind='machine' AND c.machine_id=? AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>?) AND m.revoked_at IS NULL AND mem.active=1 AND s.state!='revoked' AND (m.incarnation_id IS NULL OR m.incarnation_id!=? OR m.heartbeat_sequence<?)",params:[auth.credential_id,machineId,now,b.incarnation_id,b.sequence],code:'authentication_required',status:401,message:'machine authorization or heartbeat sequence changed'}];
|
|
459
|
+
const results=await guardedMutation(api,res,auth,guards,statements,{status:200,body:response,targetId:machineId});
|
|
460
|
+
const {unbound}=results[snapshotIndex];
|
|
461
|
+
send(res,200,{...response,data:{...response.data,watched:await watchedFor(api,auth.team_id,machineId),...(unbound.length?{unbound_bindings:unbound}:{})}},requestId);
|
|
462
|
+
}
|
|
407
463
|
async function machineConfiguration(api,res,auth,machineId,requestId){
|
|
408
464
|
if(auth.kind!=='machine'||auth.machine_id!==machineId)fail(auth.kind==='machine'?404:403,auth.kind==='machine'?'not_found':'forbidden','not found');
|
|
409
465
|
const [machine,rules]=await api.store.snapshot([
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// A full heartbeat refreshes one report clock, not every activity/binding row.
|
|
2
|
+
export default `
|
|
3
|
+
CREATE TABLE machine_heartbeat_state (
|
|
4
|
+
team_id TEXT NOT NULL, machine_id TEXT NOT NULL, activity_source_id TEXT NOT NULL,
|
|
5
|
+
activity_digest TEXT NOT NULL, activity_observed_at INTEGER NOT NULL,
|
|
6
|
+
PRIMARY KEY(team_id,machine_id),
|
|
7
|
+
FOREIGN KEY(team_id,machine_id) REFERENCES machines(team_id,id)
|
|
8
|
+
) STRICT;
|
|
9
|
+
CREATE INDEX bindings_machine ON bindings(team_id,machine_id,id);
|
|
10
|
+
CREATE INDEX machine_roots_machine ON machine_roots(team_id,machine_id,id);
|
|
11
|
+
CREATE INDEX native_effects_binding ON native_effects(team_id,machine_id,binding_id);
|
|
12
|
+
CREATE INDEX sources_machine_kind ON sources(team_id,machine_id,kind);
|
|
13
|
+
`;
|
|
@@ -25,7 +25,8 @@ import m023 from './023.mjs';
|
|
|
25
25
|
import m024 from './024.mjs';
|
|
26
26
|
import m025 from './025.mjs';
|
|
27
27
|
import m026 from './026.mjs';
|
|
28
|
-
|
|
28
|
+
import m027 from './027.mjs';
|
|
29
|
+
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, m026, m027];
|
|
29
30
|
/** A message row's `until_revision` while its session's current version still holds it (schema 9). */
|
|
30
31
|
export const OPEN_REVISION = Number.MAX_SAFE_INTEGER;
|
|
31
32
|
/** SQL: message row `m` belongs to the version at revision ?1 (bind the revision twice). */
|
|
@@ -11,6 +11,7 @@ const stmt = (sql, ...params) => ({sql, params});
|
|
|
11
11
|
// A Durable Object binds at most 100 parameters to a statement, so id lists go in batches.
|
|
12
12
|
const batches = (list, size = 90) => Array.from({length: Math.ceil(list.length / size)}, (_, at) => list.slice(at * size, (at + 1) * size));
|
|
13
13
|
const holes = list => list.map(() => '?').join(',');
|
|
14
|
+
const EVENT_REPLAY_BATCH = 32;
|
|
14
15
|
const fail = (status,code,message) => {throw Object.assign(new Error(message),{status,code});};
|
|
15
16
|
const text = (value,label) => {if(typeof value!=='string'||!value||value.length>256||/[\x00-\x1f]/.test(value))fail(400,'invalid_request',`${label} must be a nonempty string of at most 256 characters`);return value;};
|
|
16
17
|
const eventOf = row => ({id:row.id,org:row.team_id,at:row.created_at,actor:JSON.parse(row.actor_json),kind:row.kind,resource:{kind:row.resource_kind,id:row.resource_id},data:JSON.parse(row.data_json)});
|
|
@@ -179,8 +180,17 @@ async function eventStream({api,req,res,auth,url,requestId}){
|
|
|
179
180
|
let closed=false,running=null,dirty=false;
|
|
180
181
|
const close=()=>{if(closed)return;closed=true;clearInterval(timer);unsubscribe();req.off('close',close);api.events.off('shutdown',close);release();res.end();};
|
|
181
182
|
const terminal=(type,reason)=>{if(!closed)writeSse(res,{type,reason,terminal:true});close();};
|
|
182
|
-
const
|
|
183
|
+
const detail=values=>api.workDetail?.(values);
|
|
184
|
+
// SQLite on the object resolves as microtasks. An await per event does not
|
|
185
|
+
// let another request or the response transport run. The hosted meter yields
|
|
186
|
+
// at the end of each page; the Node host needs the same timer boundary.
|
|
187
|
+
const page=async(name,work,values={})=>{
|
|
188
|
+
if(api.work)return api.work(`publishedEvents.${name}`,work,values);
|
|
189
|
+
try{return await work();}finally{await new Promise(resolve=>setTimeout(resolve,0));}
|
|
190
|
+
};
|
|
191
|
+
const write=async event=>{if(closed)return false;detail({stage:'write',queued_bytes:res.writableLength??0});const room=await sseRoom(res,Buffer.byteLength(JSON.stringify(event)));if(closed||room==='closed')return false;if(room==='slow'){terminal('resync_required','slow_reader');return false;}writeSse(res,event);return true;};
|
|
183
192
|
const readable=async event=>{
|
|
193
|
+
detail({stage:'authority'});
|
|
184
194
|
const current=await liveAuth(api,auth);
|
|
185
195
|
if(current.epoch!==auth.epoch)fail(409,'resync_required','authorization changed during event delivery');
|
|
186
196
|
auth=current;return mayReadEvent(api,auth,event);
|
|
@@ -196,25 +206,52 @@ async function eventStream({api,req,res,auth,url,requestId}){
|
|
|
196
206
|
if(cursor!==null&&(cursor<bounds.floor||cursor>Math.max(bounds.last,bounds.floor))){terminal('resync_required','retention');return;}
|
|
197
207
|
if(cursor===null){
|
|
198
208
|
// Subscribe before reading the atomic snapshot. Replay after this watermark repairs all concurrent writes.
|
|
199
|
-
const [head,resources]=await api.store.snapshot([
|
|
209
|
+
const [head,resources]=await page('snapshot',()=>api.store.snapshot([
|
|
200
210
|
{kind:'one',sql:'SELECT COALESCE((SELECT max(sequence) FROM org_events WHERE team_id=?),(SELECT pruned_through FROM event_retention WHERE team_id=?),0) last',params:[auth.team_id,auth.team_id]},
|
|
201
211
|
{kind:'query',sql:'SELECT r.sequence FROM event_resources r JOIN org_events e ON e.sequence=r.sequence WHERE r.team_id=? AND (? IS NULL OR r.source_id=?) AND (? IS NULL OR r.resource_kind IN (SELECT value FROM json_each(?)) OR e.kind IN (SELECT value FROM json_each(?))) ORDER BY r.source_id,r.resource_kind,r.resource_id',params:[auth.team_id,query.source,query.source,filterResources,filterResources,filterKinds]},
|
|
202
|
-
]);
|
|
212
|
+
]),{stage:'query'});
|
|
203
213
|
cursor=head.last;if(!await write({type:'snapshot_begin'}))return;
|
|
204
|
-
for(let at=0;at<resources.length;at+=
|
|
205
|
-
const sequences=resources.slice(at,at+
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
214
|
+
for(let at=0;at<resources.length&&!closed;at+=EVENT_REPLAY_BATCH){
|
|
215
|
+
const sequences=resources.slice(at,at+EVENT_REPLAY_BATCH).map(row=>row.sequence);
|
|
216
|
+
const complete=await page('snapshotPage',async()=>{
|
|
217
|
+
const rows=await api.store.query('SELECT * FROM org_events WHERE team_id=? AND sequence IN (SELECT value FROM json_each(?)) ORDER BY sequence',[auth.team_id,JSON.stringify(sequences)]);
|
|
218
|
+
if(rows.length!==sequences.length){terminal('resync_required','snapshot_retention');return false;}
|
|
219
|
+
let considered=0,delivered=0;
|
|
220
|
+
for(const row of rows){
|
|
221
|
+
if(closed)return false;
|
|
222
|
+
const event=eventOf(row);considered++;detail({events_considered:considered});
|
|
223
|
+
if(await readable(event)){if(!await write({type:'snapshot_item',sequence:row.sequence,data:event}))return false;delivered++;detail({events_delivered:delivered});}
|
|
224
|
+
}
|
|
225
|
+
detail({stage:'complete'});return true;
|
|
226
|
+
},{stage:'query',events_considered:0,events_delivered:0,snapshot_resources:resources.length,page_offset:at});
|
|
227
|
+
if(!complete)return;
|
|
209
228
|
}
|
|
210
229
|
if(!await write({type:'snapshot_end',cursor:codec.encode(cursor)}))return;
|
|
211
230
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
231
|
+
// Finish one observed watermark rather than chasing concurrent publishers
|
|
232
|
+
// forever. The subscribed dirty flag schedules the next watermark.
|
|
233
|
+
const through=Math.max(bounds.last,cursor);
|
|
234
|
+
while(!closed){
|
|
235
|
+
const state=await page('replayPage',async()=>{
|
|
236
|
+
// Retention can run during the preceding page's timer yield. Read its
|
|
237
|
+
// floor and this page in one snapshot before advancing the cursor,
|
|
238
|
+
// including when filters leave an empty page.
|
|
239
|
+
const [retention,rows]=await api.store.snapshot([
|
|
240
|
+
{kind:'one',sql:'SELECT COALESCE((SELECT pruned_through FROM event_retention WHERE team_id=?),0) floor',params:[auth.team_id]},
|
|
241
|
+
{kind:'query',sql:`SELECT * FROM org_events WHERE team_id=? AND sequence>? AND sequence<=? AND (? IS NULL OR source_id=?) AND (? IS NULL OR kind IN (SELECT value FROM json_each(?))) ORDER BY sequence LIMIT ${EVENT_REPLAY_BATCH}`,params:[auth.team_id,cursor,through,query.source,query.source,filterKinds,filterKinds]},
|
|
242
|
+
]);
|
|
243
|
+
if(cursor<retention.floor){terminal('resync_required','retention');return 'closed';}
|
|
244
|
+
if(!rows.length){cursor=through;await write({type:'checkpoint',cursor:codec.encode(cursor)});detail({stage:'complete'});return 'done';}
|
|
245
|
+
let considered=0,delivered=0;
|
|
246
|
+
for(const row of rows){
|
|
247
|
+
if(closed)return 'closed';cursor=row.sequence;const event=eventOf(row);considered++;detail({events_considered:considered});
|
|
248
|
+
if(await readable(event)){if(!await write({type:'event',sequence:row.sequence,cursor:codec.encode(cursor),data:event}))return 'closed';delivered++;detail({events_delivered:delivered});}
|
|
249
|
+
}
|
|
250
|
+
// Advance even when the reader was not authorized for a resource; never leak its data.
|
|
251
|
+
if(!await write({type:'checkpoint',cursor:codec.encode(cursor)}))return 'closed';
|
|
252
|
+
detail({stage:'complete'});return rows.length<EVENT_REPLAY_BATCH?'done':'more';
|
|
253
|
+
},{stage:'query',events_considered:0,events_delivered:0});
|
|
254
|
+
if(state!=='more')break;
|
|
218
255
|
}
|
|
219
256
|
};
|
|
220
257
|
const enqueue=()=>{dirty=true;if(running)return running;running=(async()=>{while(dirty&&!closed){dirty=false;await drain();}})().catch(error=>terminal([401,403].includes(error.status)?'access_revoked':'resync_required',error.code??'unavailable')).finally(()=>{running=null;});return running;};
|