@volter/teams 0.2.57 → 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 +1 -1
- package/server/core.mjs +19 -2
- package/server/http.mjs +6 -4
- package/server/published-events.mjs +6 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/teams",
|
|
3
|
-
"version": "0.2.
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|
|
16
|
-
//
|
|
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 =
|
|
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');
|
|
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'}];
|