@volter/teams 0.2.57 → 0.2.59

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/home.mjs CHANGED
@@ -231,6 +231,27 @@ export class Launches {
231
231
  }
232
232
  return [...last.values()].sort((a, b) => b.t - a.t);
233
233
  }
234
+ /** No launch rests at `issued` once its handler is gone (its daemon ended between that write and the next; `issuing`
235
+ * holds the keys a live handler still has): with its pane live in `panes` it is ready, and `ready(launch, pane)` is
236
+ * told; with its pane kept dead it ended as `exit(pane)` ({ failed, how }) says; with no pane it exited when its
237
+ * input had been confirmed, and failed otherwise. Answers the records it wrote. */
238
+ settleIssued(panes, { issuing = new Set(), onlyKey = null, exit, ready = () => {} }) {
239
+ const restarted = 'the daemon restarted before this launch became ready';
240
+ const settled = [];
241
+ for (const launch of this.all()) {
242
+ if (launch.status !== 'issued' || (onlyKey && launch.key !== onlyKey) || issuing.has(launch.key)) continue;
243
+ const { t: _previousAt, ...facts } = launch;
244
+ const pane = panes.find((row) => row.contextKey === launch.pane) ?? null;
245
+ const ended = pane?.dead ? exit(pane) : null;
246
+ const entry = pane && !pane.dead ? { ...facts, status: 'ready' }
247
+ : ended?.failed === false ? { ...facts, status: 'finished', completion: ended }
248
+ : ended ? { ...facts, status: 'exited', error: `${restarted}; its pane ended with ${ended.how}` }
249
+ : { ...facts, status: launch.inputDelivered === true ? 'exited' : 'failed', error: `${restarted}; its pane is gone` };
250
+ if (entry.status === 'ready') ready(launch, pane);
251
+ this.record(entry); settled.push(entry);
252
+ }
253
+ return settled;
254
+ }
234
255
  }
235
256
 
236
257
  /** `host:port` or `[v6]:port` → { host, port }. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/teams",
3
- "version": "0.2.57",
3
+ "version": "0.2.59",
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
@@ -84,6 +84,23 @@ export async function maintain(api,{budgetMs=MAINTAIN_BUDGET_MS}={}){const now=a
84
84
  if(await one(`SELECT 1 AS more FROM ${table} WHERE expires_at<=? LIMIT 1`,[now]))more=true;
85
85
  }
86
86
  });
87
+ // A publication's replay record written with the whole event in it gives that copy up (a repeat is answered from
88
+ // the event, http.mjs): the records are walked once by rowid, a bounded window a run, the key, digest and status
89
+ // kept. The route and the answer's length are read from the row's own page; only an emptied row's overflow pages are
90
+ // touched, as they are freed.
91
+ await step('publication replays',async()=>{
92
+ const walk=await one("SELECT after_rowid,done FROM maintain_backfill WHERE name='publication_replays'");
93
+ if(walk?.done)return;
94
+ const after=walk?.after_rowid??0,held=`route GLOB 'POST /api/v1/teams/*/event-sources/*/events' AND octet_length(response_json)>4`;
95
+ const window=await q(`SELECT rowid AS id,(${held}) AS held FROM idempotency WHERE rowid>? ORDER BY rowid LIMIT ${REPLAY_BATCH}`,[after]);
96
+ const through=window.at(-1)?.id??after,finished=window.length<REPLAY_BATCH;
97
+ // the rows are emptied before the walk's place is written: on a full database the write takes a page they freed
98
+ await tx([
99
+ {sql:`UPDATE idempotency SET response_json='null' WHERE rowid>? AND rowid<=? AND ${held}`,params:[after,through]},
100
+ {sql:"INSERT INTO maintain_backfill(name,after_rowid,done) VALUES ('publication_replays',?,?) ON CONFLICT(name) DO UPDATE SET after_rowid=excluded.after_rowid,done=excluded.done",params:[through,finished?1:0]},
101
+ ]);
102
+ count('publication replay copies',window.filter(row=>row.held).length);if(!finished)more=true;
103
+ });
87
104
  // A capture or an upload never committed within a day expires, recorded as expired (they are few: only those in flight).
88
105
  await step('in flight',async()=>{
89
106
  for(const [kept,table] of [['staging_captures','captures'],['pending_uploads','uploads']]){
@@ -319,8 +336,8 @@ const VERSION_SESSION_BATCH=10,VERSION_PRUNE_BATCH=20,CAPTURE_WALK=500;
319
336
  const MESSAGE_BUDGET=2000,MESSAGE_CHUNK=500;
320
337
  /** Committed captures whose parts a run removes, and parts per capture per run. */
321
338
  const PARTS_CAPTURE_BATCH=20,PART_PRUNE_BATCH=50;
322
- /** Rows of one expiry a run removes; captures and uploads in flight a run expires. */
323
- const EXPIRY_BATCH=500,IN_FLIGHT_BATCH=50;
339
+ /** Rows of one expiry a run removes; captures and uploads in flight a run expires; replay records a run walks. */
340
+ const EXPIRY_BATCH=500,IN_FLIGHT_BATCH=50,REPLAY_BATCH=200;
324
341
  /** Queued uploads and blobs a run decides. */
325
342
  const UPLOAD_PRUNE_BATCH=20,BLOB_PRUNE_BATCH=50;
326
343
  /** Events per team retention walks per run; rows per backfill batch. */
package/server/http.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { appRoute, appPreAuth, appAuthentication, enforceAppScope, appAuthGuards, installedIssuer, identityIssuers } from './apps.mjs';
2
2
  import { machineEventStream } from './machine-events.mjs';
3
3
  import { releasePreview, releaseSetting, RELEASE_GRACE_DAYS } from './session-release.mjs';
4
- import { publishedEventRoute, OWN_SOURCE, sourceMachine } from './published-events.mjs';
4
+ import { publishedEventRoute } from './published-events.mjs';
5
5
  import { identityRecordRoute, identityAuthority } from './identity-record.mjs';
6
6
  import { AGENT_ACTIVE } from './agent-authority.mjs';
7
7
  import { APP_LINEAGE_REVOKED } from './auth.mjs';
@@ -22,7 +22,7 @@ class HttpError extends Error { constructor(status,code,message,details=null){su
22
22
  const fail=(status,code,message,details)=>{throw new HttpError(status,code,message,details)};
23
23
  const parseJson=req=>parseRequestJson(req,fail);
24
24
  const result=(api,auth,data)=>({version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},data});
25
- 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
+ function sideEffectStatements(api,res,status,body,now,targetId=null,journal=false){const statements=[];if(status<400&&res._idempotencyRecord){const record=res._idempotencyRecord,safe=record.fromEvent?null: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;}
26
26
  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);}
27
27
  function send(res,status,body,requestId,headers={}){
28
28
  const deliver=(actualStatus=status,actualBody=body,actualHeaders=headers)=>{
@@ -91,7 +91,10 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
91
91
  }
92
92
  let m;if(api.harnessRoutes&&await api.harnessRoutes.beforeIdempotency({api,req,res,auth,url,path,sub,requestId}))return;
93
93
  const intrinsicPart=req.method==='PUT'&&(/\/captures\/[^/]+\/parts\/\d+$/.test(sub)||/\/uploads\/[^/]+\/chunks\/\d+$/.test(sub)),intrinsicReplay=intrinsicPart||(req.method==='POST'&&(/^\/sources\/[^/]+\/captures$/.test(sub)||/\/captures\/[^/]+\/commit$/.test(sub)||/\/effects\/[^/]+\/(?:claim|result)$/.test(sub)));
94
- if(['POST','PATCH','DELETE'].includes(req.method)&&!intrinsicPart){const key=req.headers['idempotency-key'];if(typeof key!=='string'||key.length<16||key.length>128||!/^[\x20-\x7e]+$/.test(key))fail(400,'invalid_request','Idempotency-Key must be 16..128 printable ASCII characters');if(!intrinsicReplay){const body=req.method==='DELETE'?null:await parseJson(req),route=`${req.method} ${path}`,requestDigest=sha256(canonicalJson({method:req.method,path,if_match:req.headers['if-match']??null,body})),old=await api.store.one('SELECT * FROM idempotency WHERE team_id=? AND principal_id=? AND route=? AND key=? AND expires_at>?',[auth.team_id,auth.principal_id,route,key,api.clock.nowMs()]);if(old){if(old.request_digest!==requestDigest)fail(409,'idempotency_conflict','idempotency key was reused with a different request');await authorizeReplay(api,auth,req.method,sub,body);return send(res,old.response_status,JSON.parse(old.response_json),requestId);}res._idempotencyRecord={teamId:auth.team_id,principalId:auth.principal_id,route,key,requestDigest,path};}}
94
+ // A publication's replay is answered from its event (published-events.mjs): its record keeps the key, the request
95
+ // digest and the status, and no second copy of the event.
96
+ const fromEvent=req.method==='POST'&&/^\/event-sources\/[^/]+\/events$/.test(sub);
97
+ if(['POST','PATCH','DELETE'].includes(req.method)&&!intrinsicPart){const key=req.headers['idempotency-key'];if(typeof key!=='string'||key.length<16||key.length>128||!/^[\x20-\x7e]+$/.test(key))fail(400,'invalid_request','Idempotency-Key must be 16..128 printable ASCII characters');if(!intrinsicReplay){const body=req.method==='DELETE'?null:await parseJson(req),route=`${req.method} ${path}`,requestDigest=sha256(canonicalJson({method:req.method,path,if_match:req.headers['if-match']??null,body})),old=await api.store.one('SELECT * FROM idempotency WHERE team_id=? AND principal_id=? AND route=? AND key=? AND expires_at>?',[auth.team_id,auth.principal_id,route,key,api.clock.nowMs()]);if(old){if(old.request_digest!==requestDigest)fail(409,'idempotency_conflict','idempotency key was reused with a different request');if(fromEvent)res._replayStatus=old.response_status;else{await authorizeReplay(api,auth,req.method,sub,body);return send(res,old.response_status,JSON.parse(old.response_json),requestId);}}else res._idempotencyRecord={teamId:auth.team_id,principalId:auth.principal_id,route,key,requestDigest,path,fromEvent};}}
95
98
  if(await appRoute({api,req,res,auth,sub,requestId,parseJson,send,result,guardedMutation}))return;
96
99
  if(await publishedEventRoute({api,req,res,auth,sub,url,requestId,parseJson,send,result,guardedMutation}))return;
97
100
  if(await agentSessionRoute({api,req,res,auth,sub,url,requestId,parseJson,send,result,guardedMutation}))return;
@@ -166,7 +169,6 @@ async function authorizeReplay(api,auth,method,sub,body){
166
169
  return;
167
170
  }
168
171
  if(sub==='/event-sources'){if(auth.kind!=='machine'&&!isAdmin(auth)&&!(auth.installation_id&&auth.app_actions.includes('events.publish')))fail(403,'forbidden','event source authorization changed');return;}
169
- if((match=/^\/event-sources\/([^/]+)\/events$/.exec(sub))){if(!await api.store.one(OWN_SOURCE,[auth.team_id,match[1],auth.principal_id,sourceMachine(auth)]))fail(404,'not_found','event source not found');return;}
170
172
  if(sub.startsWith('/apps')||sub.startsWith('/app-installations')){if(auth.installation_id&&sub===`/app-installations/${auth.installation_id}/delegations`&&method==='POST')return;if(!isAdmin(auth)||auth.installation_id)fail(403,'forbidden','application admin authorization changed');return;}
171
173
  if((match=/^\/sources\/([^/]+)\/captures$/.exec(sub))){const source=await api.store.one('SELECT state,machine_id,visibility FROM sources WHERE team_id=? AND id=?',[auth.team_id,match[1]]);if(!source||source.state!=='active'||auth.source_id!==match[1])fail(403,'forbidden','source authorization changed');if(auth.kind==='machine'){const rule=await api.store.one("SELECT 1 ok FROM machines m JOIN sync_rules r ON r.team_id=m.team_id AND r.machine_id=m.id WHERE m.team_id=? AND m.id=? AND m.revoked_at IS NULL AND r.id=? AND r.source_id=? AND r.state='active' AND r.revision=? AND r.visibility=?",[auth.team_id,auth.machine_id,body?.rule_id,match[1],body?.rule_revision,body?.visibility]);if(!rule)fail(403,'forbidden','sync rule authorization changed');}else if(auth.kind!=='ingest'||body?.visibility!==source.visibility)fail(403,'forbidden','source policy changed');return;}
172
174
  if((match=/^\/machines\/([^/]+)\/heartbeat$/.exec(sub))&&auth.kind==='machine'){const machine=await api.store.one('SELECT 1 ok FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL',[auth.team_id,match[1]]);if(!machine||auth.machine_id!==match[1])fail(403,'forbidden','machine authorization changed');return;}
@@ -12,10 +12,10 @@ const stmt = (sql, ...params) => ({sql, params});
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
14
  const EVENT_REPLAY_BATCH = 32;
15
- // A publication is written about four times: the event, its resource's snapshot, its idempotent answer, and a
16
- // webhook delivery. Past the host's maximum a write does not fail, it resets the server under every machine's link:
15
+ // A publication is written about three times: the event, its resource's snapshot, and a webhook delivery (its replay
16
+ // record holds no copy: a repeat is answered from the event). Past the host's maximum a write does not fail, it resets the server under every machine's link:
17
17
  // an event that would not fit under the limit beside a mebibyte kept for retention is refused, and writes nothing.
18
- const EVENT_COPIES = 4, EVENT_RESERVE = 1024 * 1024;
18
+ const EVENT_COPIES = 3, EVENT_RESERVE = 1024 * 1024;
19
19
  async function eventHeadroomSpent(api, res, bytes) {
20
20
  const usage = await api.storageUsage?.();
21
21
  if (!usage || usage.used + bytes * EVENT_COPIES + EVENT_RESERVE < usage.limit) return false;
@@ -105,7 +105,9 @@ export async function publishedEventRoute(ctx) {
105
105
  if(!source)fail(404,'not_found','event source not found');
106
106
  const spent=await eventHeadroomSpent(api,res,Buffer.byteLength(JSON.stringify(body.data)));
107
107
  const old=await api.store.one('SELECT * FROM org_events WHERE id=?',[body.id]);
108
- if(old){if(old.team_id!==auth.team_id||old.source_id!==publication[1]||old.kind!==body.kind||old.resource_id!==body.resource.id||old.source_revision!==body.revision||canonicalJson(JSON.parse(old.data_json))!==canonicalJson(body.data)||canonicalJson(JSON.parse(old.actor_json))!==canonicalJson(actor))fail(409,'idempotency_conflict','event id already names another event');send(res,200,result(api,auth,eventOf(old)),requestId);return true;}
108
+ if(old){if(old.team_id!==auth.team_id||old.source_id!==publication[1]||old.kind!==body.kind||old.resource_id!==body.resource.id||old.source_revision!==body.revision||canonicalJson(JSON.parse(old.data_json))!==canonicalJson(body.data)||canonicalJson(JSON.parse(old.actor_json))!==canonicalJson(actor))fail(409,'idempotency_conflict','event id already names another event');
109
+ // a repeat under the key that published it answers as the publication did; under another key, as found
110
+ send(res,res._replayStatus??200,result(api,auth,eventOf(old)),requestId);return true;}
109
111
  if(spent){res.setHeader('Retry-After','60');throw Object.assign(new Error('event deferred while Teams storage is full'),{status:507,code:'storage_full',retryable:true});}
110
112
  const now=api.clock.nowMs(),guards=[...authorityGuards(auth,now),{sql:OWN_SOURCE,params:[auth.team_id,publication[1],auth.principal_id,sourceMachine(auth)],status:403,code:'forbidden',message:'event source authority changed'},
111
113
  {sql:'SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM event_resources WHERE team_id=? AND source_id=? AND resource_kind=? AND resource_id=? AND source_revision>=?)',params:[auth.team_id,publication[1],body.resource.kind,body.resource.id,body.revision],status:409,code:'revision_mismatch',message:'resource publication is older than its current snapshot'}];