@volter/teams 0.2.56 → 0.2.58

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/teams",
3
- "version": "0.2.56",
3
+ "version": "0.2.58",
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
@@ -1,5 +1,6 @@
1
1
  import { deliverAppWebhooks } from './apps.mjs';
2
2
  import { reconcileMachinePresence, LINK_GRACE_MS } from './machine-events.mjs';
3
+ import { releaseRevokedSources } from './session-release.mjs';
3
4
  // The Teams server, independent of its host. `createTeamsApi` builds the API
4
5
  // object every route and door reads; a host attaches a store (`api.attach`),
5
6
  // bootstraps the server identity once, serves requests through http.mjs's
@@ -83,6 +84,23 @@ export async function maintain(api,{budgetMs=MAINTAIN_BUDGET_MS}={}){const now=a
83
84
  if(await one(`SELECT 1 AS more FROM ${table} WHERE expires_at<=? LIMIT 1`,[now]))more=true;
84
85
  }
85
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
+ });
86
104
  // A capture or an upload never committed within a day expires, recorded as expired (they are few: only those in flight).
87
105
  await step('in flight',async()=>{
88
106
  for(const [kept,table] of [['staging_captures','captures'],['pending_uploads','uploads']]){
@@ -233,6 +251,12 @@ export async function maintain(api,{budgetMs=MAINTAIN_BUDGET_MS}={}){const now=a
233
251
  await step('webhooks',async()=>{if(await deliverAppWebhooks(api))more=true;});
234
252
  // Last, with what the budget leaves: the walks of rows written before migration 023 into its tables.
235
253
  await step('backfill',async()=>{if(!await backfill(api,{now,left}))more=true;});
254
+ // A team that says so has what its revoked sources left released (session-release.mjs); no team does by default.
255
+ // Last: it takes what the run's budget has left, after everything the server itself needs.
256
+ await step('revoked sources',async()=>{
257
+ if(!await walked(api,['version_sources'])){more=true;return;}
258
+ if(await releaseRevokedSources(api,{now,left,count}))more=true;
259
+ });
236
260
  const result={counts,failed,backlog:more};
237
261
  if(Object.keys(counts).length)console.log(`maintain: removed ${Object.entries(counts).map(([name,n])=>`${n} ${name}`).join(', ')}${more?' (backlog)':''}`);
238
262
  return result;}
@@ -312,8 +336,8 @@ const VERSION_SESSION_BATCH=10,VERSION_PRUNE_BATCH=20,CAPTURE_WALK=500;
312
336
  const MESSAGE_BUDGET=2000,MESSAGE_CHUNK=500;
313
337
  /** Committed captures whose parts a run removes, and parts per capture per run. */
314
338
  const PARTS_CAPTURE_BATCH=20,PART_PRUNE_BATCH=50;
315
- /** Rows of one expiry a run removes; captures and uploads in flight a run expires. */
316
- 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;
317
341
  /** Queued uploads and blobs a run decides. */
318
342
  const UPLOAD_PRUNE_BATCH=20,BLOB_PRUNE_BATCH=50;
319
343
  /** Events per team retention walks per run; rows per backfill batch. */
package/server/http.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { appRoute, appPreAuth, appAuthentication, enforceAppScope, appAuthGuards, installedIssuer, identityIssuers } from './apps.mjs';
2
2
  import { machineEventStream } from './machine-events.mjs';
3
- import { publishedEventRoute, OWN_SOURCE, sourceMachine } from './published-events.mjs';
3
+ import { releasePreview, releaseSetting, RELEASE_GRACE_DAYS } from './session-release.mjs';
4
+ import { publishedEventRoute } from './published-events.mjs';
4
5
  import { identityRecordRoute, identityAuthority } from './identity-record.mjs';
5
6
  import { AGENT_ACTIVE } from './agent-authority.mjs';
6
7
  import { APP_LINEAGE_REVOKED } from './auth.mjs';
@@ -21,7 +22,7 @@ class HttpError extends Error { constructor(status,code,message,details=null){su
21
22
  const fail=(status,code,message,details)=>{throw new HttpError(status,code,message,details)};
22
23
  const parseJson=req=>parseRequestJson(req,fail);
23
24
  const result=(api,auth,data)=>({version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},data});
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
+ 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;}
25
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);}
26
27
  function send(res,status,body,requestId,headers={}){
27
28
  const deliver=(actualStatus=status,actualBody=body,actualHeaders=headers)=>{
@@ -90,7 +91,10 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
90
91
  }
91
92
  let m;if(api.harnessRoutes&&await api.harnessRoutes.beforeIdempotency({api,req,res,auth,url,path,sub,requestId}))return;
92
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)));
93
- 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};}}
94
98
  if(await appRoute({api,req,res,auth,sub,requestId,parseJson,send,result,guardedMutation}))return;
95
99
  if(await publishedEventRoute({api,req,res,auth,sub,url,requestId,parseJson,send,result,guardedMutation}))return;
96
100
  if(await agentSessionRoute({api,req,res,auth,sub,url,requestId,parseJson,send,result,guardedMutation}))return;
@@ -102,7 +106,7 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
102
106
  if(req.method==='POST'&&sub==='/invitations')return await createInvitation(api,req,res,auth,requestId);
103
107
  if(req.method==='POST'&&sub==='/credentials')return await createCredential(api,req,res,auth,requestId);
104
108
  if(req.method==='DELETE'&&(m=/^\/credentials\/([^/]+)$/.exec(sub)))return await revokeCredential(api,req,res,auth,m[1],requestId);
105
- if((req.method==='GET'||req.method==='PATCH')&&sub==='/settings')return await teamSettings(api,req,res,auth,requestId);
109
+ if((req.method==='GET'||req.method==='PATCH')&&sub==='/settings')return await teamSettings(api,req,res,auth,url,requestId);
106
110
  if(req.method==='GET'&&sub==='/members')return await listMembers(api,res,auth,url,requestId);
107
111
  if(req.method==='PATCH'&&(m=/^\/members\/([^/]+)$/.exec(sub)))return await updateMember(api,req,res,auth,m[1],requestId);
108
112
  if(req.method==='DELETE'&&(m=/^\/members\/([^/]+)$/.exec(sub)))return await removeMember(api,req,res,auth,m[1],requestId);
@@ -165,7 +169,6 @@ async function authorizeReplay(api,auth,method,sub,body){
165
169
  return;
166
170
  }
167
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;}
168
- 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;}
169
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;}
170
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;}
171
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;}
@@ -335,13 +338,24 @@ async function collectionPage(api,auth,url,name,items){
335
338
  }
336
339
  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);}
337
340
  // The team's own settings. native_capture: whether machines upload each session's native transcript (see migration 025).
338
- async function teamSettings(api,req,res,auth,requestId){
339
- 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};};
340
- if(req.method==='GET')return send(res,200,result(api,auth,await read()),requestId);
341
+ // release_revoked_sources: whether retention releases what revoked sources left (migration 028), with the moment it
342
+ // was turned on (its cutoff) and the grace. An administrator's `?release_revoked_sources=preview` says what turning it
343
+ // on now would release, or what the standing cutoff releases, source by source.
344
+ async function teamSettings(api,req,res,auth,url,requestId){
345
+ const read=async()=>{const t=await api.store.one('SELECT native_capture,release_revoked_before FROM teams WHERE id=?',[auth.team_id]);return{native_capture:t.native_capture===1,release_revoked_sources:t.release_revoked_before!=null,release_revoked_sources_since:t.release_revoked_before,release_revoked_sources_grace_days:RELEASE_GRACE_DAYS};};
346
+ if(req.method==='GET'){
347
+ const settings=await read(),preview=url.searchParams.get('release_revoked_sources');
348
+ if(preview===null)return send(res,200,result(api,auth,settings),requestId);
349
+ if(preview!=='preview')fail(400,'invalid_request','release_revoked_sources reads only as preview');
350
+ if(!isAdmin(auth))fail(403,'forbidden','forbidden');
351
+ return send(res,200,result(api,auth,{...settings,release_preview:await releasePreview(api,auth.team_id,settings.release_revoked_sources_since??api.clock.nowMs())}),requestId);
352
+ }
341
353
  if(!isAdmin(auth))fail(403,'forbidden','forbidden');
342
- 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');
343
- const now=api.clock.nowMs(),response=result(api,auth,{native_capture:body.native_capture});
344
- 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});
354
+ const body=await parseJson(req);exact(body,['native_capture','release_revoked_sources']);const set=['native_capture','release_revoked_sources'].filter(name=>body[name]!==undefined);if(!set.length)fail(400,'invalid_request','name a setting');for(const name of set)if(typeof body[name]!=='boolean')fail(400,'invalid_request',`${name} must be a boolean`);
355
+ const now=api.clock.nowMs(),response=result(api,auth,{...Object.fromEntries(set.map(name=>[name,body[name]])),...(body.release_revoked_sources!==undefined?{release_revoked_sources_since:body.release_revoked_sources?now:null}:{})});
356
+ // the record of the change says which setting and to what
357
+ res._auditRecord.operation+=` ${set.map(name=>`${name}=${body[name]}`).join(' ')}`;
358
+ await guardedMutation(api,res,auth,authGuards(auth,now),set.flatMap(name=>name==='release_revoked_sources'?releaseSetting(auth.team_id,body[name],now):[{sql:'UPDATE teams SET native_capture=? WHERE id=?',params:[body[name]?1:0,auth.team_id]}]),{status:200,body:response,targetId:auth.team_id});
345
359
  send(res,200,response,requestId);
346
360
  }
347
361
  async function updateMember(api,req,res,auth,principalId,requestId){await requireUnlinked(api,auth.team_id);
@@ -0,0 +1,15 @@
1
+ // A team may have retention release what its revoked sources left: the sessions only they wrote, and those sources'
2
+ // message ids and capture records (session-release.mjs). `release_revoked_before` is the moment the team turned it on,
3
+ // its cutoff; NULL, the default, is off: a retired machine's sessions are often the only copy of their transcripts, and
4
+ // nothing of them is deleted until the team says so. A source records when it was revoked, and forgets it when it is
5
+ // made active again, by the write that changes its state. Constant cost: two columns and two triggers, no row written.
6
+ export default `
7
+ ALTER TABLE teams ADD COLUMN release_revoked_before INTEGER;
8
+ ALTER TABLE sources ADD COLUMN revoked_at INTEGER;
9
+ CREATE TRIGGER source_revoked AFTER UPDATE OF state ON sources WHEN NEW.state='revoked' AND OLD.state<>'revoked' BEGIN
10
+ UPDATE sources SET revoked_at=CAST(strftime('%s','now') AS INTEGER)*1000 WHERE team_id=NEW.team_id AND id=NEW.id;
11
+ END;
12
+ CREATE TRIGGER source_unrevoked AFTER UPDATE OF state ON sources WHEN NEW.state<>'revoked' AND OLD.state='revoked' BEGIN
13
+ UPDATE sources SET revoked_at=NULL WHERE team_id=NEW.team_id AND id=NEW.id;
14
+ END;
15
+ `;
@@ -26,7 +26,8 @@ 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
+ import m028 from './028.mjs';
30
+ 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, m028];
30
31
  /** A message row's `until_revision` while its session's current version still holds it (schema 9). */
31
32
  export const OPEN_REVISION = Number.MAX_SAFE_INTEGER;
32
33
  /** SQL: message row `m` belongs to the version at revision ?1 (bind the revision twice). */
@@ -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'}];
@@ -0,0 +1,145 @@
1
+ // Releasing what a revoked source left: a team that turns `release_revoked_sources` on (migration 028) has every
2
+ // session only such sources wrote deleted, as a person's delete of it would, and then what a delete leaves behind for
3
+ // that source removed too (its message ids and capture records). Off, nothing here runs: a retired machine's sessions
4
+ // are often the only copy of their transcripts, so only the team's own word releases them.
5
+ //
6
+ // Which sources: those revoked at least the grace before the moment the setting was turned on (its cutoff). Revoking
7
+ // is how a machine is disconnected to be linked again, and connecting it again makes the same source active: a source
8
+ // revoked after the turning-on, or within the grace before it, is not released by it (that takes turning it on
9
+ // again), and a source made active again stops being released at once. The grace is a week: a machine taken off to be
10
+ // re-linked or rebuilt is back within days, and a week is what the server keeps of everything else it ages out.
11
+
12
+ export const RELEASE_GRACE_DAYS = 7;
13
+ const GRACE_MS = RELEASE_GRACE_DAYS * 86_400_000;
14
+ /** Origin rows a run examines; rows it deletes; rows one delete removes, of messages and message ids, and of captures
15
+ * (each cascades to its parts). */
16
+ const ORIGIN_BUDGET = 1000, ORIGIN_WALK = 200, ROW_BUDGET = 4000, ROW_CHUNK = 500, CAPTURE_CHUNK = 50;
17
+ const WALK = 'release_revoked_sources';
18
+
19
+ // When source `s` was revoked: its own record, or its machine's for a source revoked before sources recorded it.
20
+ const REVOKED_AT = 'COALESCE(s.revoked_at,(SELECT m.revoked_at FROM machines m WHERE m.team_id=s.team_id AND m.id=s.machine_id))';
21
+ /** SQL and parameters: source `s` is released under `cutoff`. */
22
+ const eligible = cutoff => ({ sql: `(s.state='revoked' AND ${REVOKED_AT}<=?)`, params: [cutoff - GRACE_MS] });
23
+ /** SQL and parameters: why session `se` is left whole under `cutoff` (`merged`: it is, or is named by, a correlation;
24
+ * `shared`: a version of it came from a source that is not released), or NULL when it is released. */
25
+ const kept = (team, cutoff) => {
26
+ const source = eligible(cutoff);
27
+ return { sql: `CASE WHEN se.canonical_id IS NOT NULL OR se.id IN (SELECT left_session_id FROM correlations WHERE team_id=? UNION SELECT right_session_id FROM correlations WHERE team_id=? UNION SELECT canonical_id FROM correlations WHERE team_id=?) THEN 'merged'
28
+ WHEN EXISTS (SELECT 1 FROM version_sources v WHERE v.team_id=se.team_id AND v.session_id=se.id AND v.source_id<>'' AND NOT EXISTS (SELECT 1 FROM sources s WHERE s.team_id=v.team_id AND s.id=v.source_id AND ${source.sql})) THEN 'shared' END`,
29
+ params: [team, team, team, ...source.params] };
30
+ };
31
+
32
+ /**
33
+ * What turning the setting on at `cutoff` releases, by revoked source: whether the source is released, and of its
34
+ * sessions how many are released, how many are left whole and why, and the capture records that go with the released.
35
+ * The same tests the release applies. It reads each revoked source's sessions through the origins' key, each session's
36
+ * row and its retained versions' sources, and counts captures through their key; it reads no message.
37
+ */
38
+ export async function releasePreview(api, teamId, cutoff) {
39
+ const source = eligible(cutoff), whole = kept(teamId, cutoff);
40
+ const rows = await api.store.query(`SELECT id,name,revoked_at,eligible,COUNT(session_id) sessions,COALESCE(SUM(kept='merged'),0) merged,COALESCE(SUM(kept='shared'),0) shared,
41
+ COALESCE(SUM(CASE WHEN session_id IS NOT NULL AND eligible AND kept IS NULL THEN (SELECT COUNT(*) FROM captures c WHERE c.team_id=h.team_id AND c.source_id=h.id AND c.harness=h.harness AND c.native_id=h.native_id) END),0) captures
42
+ FROM (SELECT s.team_id,s.id,s.name,${REVOKED_AT} revoked_at,${source.sql} eligible,o.harness,o.native_id,se.id session_id,
43
+ CASE WHEN se.id IS NOT NULL AND ${source.sql} THEN ${whole.sql} END kept
44
+ FROM sources s LEFT JOIN origins o ON o.team_id=s.team_id AND o.source_id=s.id AND o.deleted_at IS NULL
45
+ LEFT JOIN sessions se ON se.team_id=o.team_id AND se.id=o.session_id AND se.deleted_at IS NULL
46
+ WHERE s.team_id=? AND s.state='revoked') h GROUP BY id ORDER BY name,id`, [...source.params, ...source.params, ...whole.params, teamId]);
47
+ const sources = rows.map(row => ({ id: row.id, name: row.name, revoked_at: row.revoked_at, released: row.eligible === 1,
48
+ ...(row.eligible === 1 ? {} : { not_released: row.revoked_at == null ? 'no revocation time is recorded' : `revoked less than ${RELEASE_GRACE_DAYS} days before the cutoff` }),
49
+ sessions: { released: row.eligible === 1 ? row.sessions - row.merged - row.shared : 0, left_whole: row.eligible === 1 ? { merged: row.merged, shared: row.shared } : { source_not_released: row.sessions } },
50
+ captures_released: row.captures }));
51
+ return { cutoff, grace_days: RELEASE_GRACE_DAYS, sources,
52
+ totals: { sources_released: sources.filter(item => item.released).length, sessions_released: sources.reduce((sum, item) => sum + item.sessions.released, 0), captures_released: sources.reduce((sum, item) => sum + item.captures_released, 0) } };
53
+ }
54
+
55
+ /** The statements that turn a team's setting on (its cutoff is now) or off; either starts the walk over. */
56
+ export const releaseSetting = (teamId, on, now) => [
57
+ { sql: 'UPDATE teams SET release_revoked_before=? WHERE id=?', params: [on ? now : null, teamId] },
58
+ { sql: 'DELETE FROM maintain_backfill WHERE name=?', params: [WALK] },
59
+ ];
60
+
61
+ /**
62
+ * One run of the release, inside `maintain`'s budget (`left`). It walks the origins once by rowid, its place kept in
63
+ * `maintain_backfill` so a restart resumes there; a change of any team's setting starts it over. An origin is passed
64
+ * when nothing of it is left to do. A session is whole or deleted, never between: the transaction that deletes it
65
+ * tests, as it writes, that its source is still released and the session still stands alone, and only a deleted
66
+ * session's rows are removed, in bounded deletes found through a key and safe to repeat. Answers whether more is left.
67
+ */
68
+ export async function releaseRevokedSources(api, { now, left, count }) {
69
+ const one = (sql, params = []) => api.store.one(sql, params);
70
+ const cutoffs = new Map((await api.store.query('SELECT id,release_revoked_before cutoff FROM teams WHERE release_revoked_before IS NOT NULL')).map(row => [row.id, row.cutoff]));
71
+ if (!cutoffs.size) return false;
72
+ const walk = await one('SELECT after_rowid,done FROM maintain_backfill WHERE name=?', [WALK]);
73
+ if (walk?.done) return false;
74
+ const run = { now, one, count, cutoffs, released: new Map(), rows: ROW_BUDGET, left: () => run.rows > 0 && left(), deleted: false };
75
+ let after = walk?.after_rowid ?? 0, origins = ORIGIN_BUDGET, finished = false, stopped = false;
76
+ while (!stopped) {
77
+ const rows = await api.store.query(`SELECT rowid id,team_id,source_id,harness,native_id,session_id,deleted_at FROM origins WHERE rowid>? ORDER BY rowid LIMIT ${ORIGIN_WALK}`, [after]);
78
+ if (!rows.length) { finished = true; break; }
79
+ for (const origin of rows) {
80
+ if (origins-- <= 0 || !run.left()) { stopped = true; break; }
81
+ if (cutoffs.has(origin.team_id) && !await releaseOrigin(api, origin, run)) { stopped = true; break; }
82
+ after = origin.id;
83
+ }
84
+ }
85
+ if (run.deleted) await api.scheduleCapturePrefixWork?.();
86
+ await api.store.transaction([{ sql: 'INSERT INTO maintain_backfill(name,after_rowid,done) VALUES (?,?,?) ON CONFLICT(name) DO UPDATE SET after_rowid=excluded.after_rowid,done=excluded.done', params: [WALK, after, finished ? 1 : 0] }]);
87
+ return !finished;
88
+ }
89
+
90
+ /** Whether all of one origin is done (released, or left as it is); false when the run's budget ended first. */
91
+ async function releaseOrigin(api, origin, run) {
92
+ const { team_id: team, source_id: source, harness, native_id: native, session_id: session } = origin, { one, now } = run;
93
+ const cutoff = run.cutoffs.get(team), released = `${team}\0${source}`;
94
+ if (!run.released.has(released)) { const test = eligible(cutoff); run.released.set(released, Boolean(await one(`SELECT 1 ok FROM sources s WHERE s.team_id=? AND s.id=? AND ${test.sql}`, [team, source, ...test.params]))); }
95
+ const sourceReleased = run.released.get(released);
96
+ // An origin still standing matters only under a released source; a deleted one may be a deletion of ours to finish.
97
+ if (origin.deleted_at == null && !sourceReleased) return true;
98
+ let deleted = (await one('SELECT deleted_at FROM sessions WHERE team_id=? AND id=?', [team, session]))?.deleted_at != null;
99
+ if (!deleted) {
100
+ if (origin.deleted_at != null) return true;
101
+ // What a person's delete of the session writes (harness-routes mutateSession), but for the team's authorization
102
+ // epoch: every read already passes over a deleted session, and the epoch would end every open stream of the team.
103
+ const test = eligible(cutoff), whole = kept(team, cutoff), revision = { $journalRevision: true };
104
+ const guard = { sql: `SELECT 1 ok FROM sessions se JOIN origins o ON o.team_id=se.team_id AND o.session_id=se.id JOIN sources s ON s.team_id=o.team_id AND s.id=o.source_id
105
+ WHERE se.team_id=? AND se.id=? AND se.deleted_at IS NULL AND o.source_id=? AND o.harness=? AND o.native_id=? AND o.deleted_at IS NULL AND ${test.sql} AND ${whole.sql} IS NULL`,
106
+ params: [team, session, source, harness, native, ...test.params, ...whole.params], code: 'left_whole' };
107
+ if (!await one(guard.sql, guard.params)) return true;
108
+ let at;
109
+ try {
110
+ ({ revision: at } = await api.store.journalTransaction(team, [guard], [
111
+ { sql: 'UPDATE sessions SET deleted_at=?,revision=? WHERE team_id=? AND id=?', params: [now, revision, team, session] },
112
+ { sql: 'UPDATE origins SET deleted_at=? WHERE team_id=? AND session_id=? AND source_id=? AND harness=? AND native_id=?', params: [now, team, session, source, harness, native] },
113
+ { sql: 'INSERT OR IGNORE INTO capture_prefix_garbage SELECT team_id,session_id,source_id,generation FROM capture_prefix_state WHERE team_id=? AND session_id=?', params: [team, session] },
114
+ { sql: 'DELETE FROM capture_prefix_state WHERE team_id=? AND session_id=?', params: [team, session] },
115
+ { sql: 'DELETE FROM capture_prefix_warm_queue WHERE team_id=? AND session_id=?', params: [team, session] },
116
+ { sql: 'UPDATE teams SET journal_revision=? WHERE id=?', params: [revision, team] },
117
+ { sql: 'INSERT INTO journal(team_id,revision,session_id,kind,created_at) VALUES (?,?,?,?,?)', params: [team, revision, session, 'delete', now] },
118
+ ]));
119
+ } catch (error) { if (error?.code === 'left_whole') return true; throw error; }
120
+ api.events.publish(team, { type: 'session_deleted', cursor: String(at), revision: at, session_id: session });
121
+ run.count('revoked-source sessions', 1); run.deleted = true; deleted = true;
122
+ }
123
+ // A deleted session's versions are the mark of a deletion of ours not yet finished: they go last.
124
+ const unfinished = Boolean(await one('SELECT 1 held FROM session_versions WHERE team_id=? AND session_id=? LIMIT 1', [team, session]));
125
+ if (!unfinished && !sourceReleased) return true;
126
+ const key = [team, source, harness, native];
127
+ const drain = async (sql, params, chunk, name) => {
128
+ for (;;) {
129
+ if (!run.left()) return false;
130
+ const { changes } = await api.store.exec(sql, params);
131
+ run.count(name, changes); run.rows -= changes;
132
+ if (changes < chunk) return true;
133
+ }
134
+ };
135
+ if (!await drain(`DELETE FROM message_ids WHERE rowid IN (SELECT rowid FROM message_ids WHERE team_id=? AND source_id=? AND harness=? AND native_id=? LIMIT ${ROW_CHUNK})`, key, ROW_CHUNK, 'revoked-source message ids')) return false;
136
+ if (!await drain(`DELETE FROM captures WHERE rowid IN (SELECT rowid FROM captures WHERE team_id=? AND source_id=? AND harness=? AND native_id=? LIMIT ${CAPTURE_CHUNK})`, key, CAPTURE_CHUNK, 'revoked-source captures')) return false;
137
+ if (!unfinished) return true;
138
+ if (!await drain(`DELETE FROM messages WHERE rowid IN (SELECT rowid FROM messages WHERE team_id=? AND session_id=? LIMIT ${ROW_CHUNK})`, [team, session], ROW_CHUNK, 'revoked-source messages')) return false;
139
+ await api.store.transaction([
140
+ { sql: 'DELETE FROM session_versions WHERE team_id=? AND session_id=?', params: [team, session] },
141
+ { sql: 'DELETE FROM version_prune_queue WHERE team_id=? AND session_id=?', params: [team, session] },
142
+ { sql: 'DELETE FROM session_activity WHERE team_id=? AND source_id=? AND harness=? AND native_id=?', params: key },
143
+ ]);
144
+ return true;
145
+ }