@volter/teams 0.2.9 → 0.2.10
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/http-client.d.ts +2 -0
- package/http-client.mjs +2 -0
- package/package.json +1 -1
- package/server/published-events.mjs +65 -0
package/http-client.d.ts
CHANGED
|
@@ -83,8 +83,10 @@ export interface TeamClient {
|
|
|
83
83
|
catalogue(options?: RequestOptions): Promise<Result<{kinds: string[]}>>;
|
|
84
84
|
createSource(body: {name: string}, options?: RequestOptions): Promise<Result<{id: string; name: string}>>;
|
|
85
85
|
publish(source: string, body: unknown, options?: RequestOptions): Promise<Result<OntologyEvent>>;
|
|
86
|
+
purge(ids: string[], options?: RequestOptions): Promise<Result<{purged: string[]; unknown: string[]}>>;
|
|
86
87
|
follow(query?: Query, options?: StreamOptions): AsyncIterable<PublishedEventFrame>;
|
|
87
88
|
};
|
|
89
|
+
purgeSessionText(sessions: string[], values: string[], options?: RequestOptions): Promise<Result<{sessions: Record<string, {messages: number; natives: number; artifacts_removed: number; artifacts_left: number; chunks_removed: number; shared_chunks_kept: number; shared_chunks_holding_value: number}>}>>;
|
|
88
90
|
sessions: {
|
|
89
91
|
list(query?: Query, options?: RequestOptions): Promise<Page>;
|
|
90
92
|
search(query: Query, options?: RequestOptions): Promise<unknown>;
|
package/http-client.mjs
CHANGED
|
@@ -84,7 +84,9 @@ class TeamClient {
|
|
|
84
84
|
follow: (q = {},o = {}) => events(root,base+'/events'+query(q),o),
|
|
85
85
|
createSource: (body,o) => post('/event-sources',body,o),
|
|
86
86
|
publish: (source,body,o) => post(resource('/event-sources',source)+'/events',body,o),
|
|
87
|
+
purge: (ids,o) => post('/events/purge',{events:ids},o),
|
|
87
88
|
};
|
|
89
|
+
this.purgeSessionText = (sessions, values, o) => post('/sessions/purge', { sessions, values }, o);
|
|
88
90
|
this.apps = {
|
|
89
91
|
catalogue: o => get('/apps/catalogue', o),
|
|
90
92
|
list: o => get('/apps', o), get: (id, o) => get(resource('/apps', id), o),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/teams",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
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",
|
|
@@ -8,6 +8,9 @@ import { APP_EVENTS, PUBLISHABLE_EVENTS, eventReadAction } from './event-catalog
|
|
|
8
8
|
import { EVENT_DATA_LIMIT } from '../event-limits.mjs';
|
|
9
9
|
|
|
10
10
|
const stmt = (sql, ...params) => ({sql, params});
|
|
11
|
+
// A Durable Object binds at most 100 parameters to a statement, so id lists go in batches.
|
|
12
|
+
const batches = (list, size = 90) => Array.from({length: Math.ceil(list.length / size)}, (_, at) => list.slice(at * size, (at + 1) * size));
|
|
13
|
+
const holes = list => list.map(() => '?').join(',');
|
|
11
14
|
const fail = (status,code,message) => {throw Object.assign(new Error(message),{status,code});};
|
|
12
15
|
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;};
|
|
13
16
|
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)});
|
|
@@ -84,6 +87,68 @@ export async function publishedEventRoute(ctx) {
|
|
|
84
87
|
await guardedMutation(api,res,auth,guards,[stmt('INSERT INTO org_events(id,team_id,source_id,source_revision,kind,resource_kind,resource_id,actor_json,data_json,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)',body.id,auth.team_id,publication[1],body.revision,body.kind,body.resource.kind,body.resource.id,JSON.stringify(actor),JSON.stringify(body.data),now)],{status:201,body:response,targetId:body.resource.id});
|
|
85
88
|
send(res,201,response,requestId);return true;
|
|
86
89
|
}
|
|
90
|
+
// An admin purges published events by id: each one's data becomes a tombstone (and so does the resource snapshot it
|
|
91
|
+
// left), its row and sequence kept so followers' cursors hold. The request names ids only, never the text purged, and
|
|
92
|
+
// the log line names the ids. A secret pasted into a card reaches here with the card; this is how it leaves.
|
|
93
|
+
if(sub==='/events/purge'&&req.method==='POST'){
|
|
94
|
+
if(!isAdmin(auth))fail(403,'forbidden','an admin purges events');
|
|
95
|
+
const body=await parseJson(req);exact(body,['events']);
|
|
96
|
+
if(!Array.isArray(body.events)||!body.events.length||body.events.length>500||body.events.some(id=>typeof id!=='string'||!/^event_[0-9a-f]{64}$/.test(id)))fail(400,'invalid_request','events must name 1 to 500 event ids');
|
|
97
|
+
const ids=[...new Set(body.events)],found=[];
|
|
98
|
+
for(const part of batches(ids))found.push(...(await api.store.query(`SELECT id FROM org_events WHERE team_id=? AND id IN (${holes(part)})`,[auth.team_id,...part])).map(row=>row.id));
|
|
99
|
+
const tombstone='{"purged":true}',response=result(api,auth,{purged:found,unknown:ids.filter(id=>!found.includes(id))});
|
|
100
|
+
await guardedMutation(api,res,auth,authorityGuards(auth,api.clock.nowMs()),batches(found).flatMap(part=>[
|
|
101
|
+
stmt(`UPDATE org_events SET data_json=? WHERE team_id=? AND id IN (${holes(part)})`,tombstone,auth.team_id,...part),
|
|
102
|
+
stmt(`UPDATE event_resources SET event_json=json_set(event_json,'$.data',json(?)) WHERE team_id=? AND json_extract(event_json,'$.id') IN (${holes(part)})`,tombstone,auth.team_id,...part),
|
|
103
|
+
// Each event was also copied whole into the webhook body of every subscribed installation (migration 015): pending
|
|
104
|
+
// rows would still deliver it, and finished ones keep it for a week.
|
|
105
|
+
stmt(`UPDATE app_webhook_deliveries SET body=json_set(body,'$.data',json(?)) WHERE installation_id IN (SELECT id FROM app_installations WHERE team_id=?) AND event_id IN (${holes(part)})`,tombstone,auth.team_id,...part),
|
|
106
|
+
]),{status:200,body:response,targetId:null});
|
|
107
|
+
console.log(`events purged by ${auth.principal_id}: ${found.join(',') || 'none'}`);
|
|
108
|
+
send(res,200,response,requestId);return true;
|
|
109
|
+
}
|
|
110
|
+
// An admin redacts text from named sessions' recorded messages and the captures they came from. The text is in the
|
|
111
|
+
// request body (never a URL, never logged); the answer and the log line are counts per session. Ids alone cannot find
|
|
112
|
+
// which messages hold a pasted secret, and a search would carry it in a URL.
|
|
113
|
+
if(sub==='/sessions/purge'&&req.method==='POST'){
|
|
114
|
+
if(!isAdmin(auth))fail(403,'forbidden','an admin purges session text');
|
|
115
|
+
const body=await parseJson(req);exact(body,['sessions','values']);
|
|
116
|
+
if(!Array.isArray(body.sessions)||!body.sessions.length||body.sessions.length>20||body.sessions.some(id=>typeof id!=='string'||!/^ses_[0-9a-f]{32}$/.test(id)))fail(400,'invalid_request','sessions must name 1 to 20 session ids');
|
|
117
|
+
if(!Array.isArray(body.values)||!body.values.length||body.values.length>20||body.values.some(v=>typeof v!=='string'||v.length<16||v.length>512||/["\\\u0000-\u001f]/.test(v)))fail(400,'invalid_request','values must be 1 to 20 strings of 16 to 512 characters with no quotes, backslashes or control characters');
|
|
118
|
+
const marker='[redacted]',counts={};
|
|
119
|
+
for(const session of new Set(body.sessions)){
|
|
120
|
+
const natives=(await api.store.query("SELECT DISTINCT json_extract(descriptor_json,'$.native_session_id') AS native FROM bindings WHERE team_id=? AND session_id=?",[auth.team_id,session])).map(row=>row.native).filter(Boolean);
|
|
121
|
+
const statements=[];
|
|
122
|
+
for(const value of body.values){
|
|
123
|
+
statements.push(stmt('UPDATE messages SET value_json=replace(value_json,?,?),search_text=replace(search_text,?,?) WHERE team_id=? AND session_id=? AND (instr(value_json,?)>0 OR instr(search_text,?)>0)',value,marker,value,marker,auth.team_id,session,value,value));
|
|
124
|
+
if(natives.length)statements.push(stmt(`UPDATE capture_parts SET payload_json=replace(payload_json,?,?) WHERE team_id=? AND instr(payload_json,?)>0 AND capture_id IN (SELECT id FROM captures WHERE team_id=? AND native_id IN (${natives.map(()=>'?').join(',')}))`,value,marker,auth.team_id,value,auth.team_id,...natives));
|
|
125
|
+
}
|
|
126
|
+
// The native transcript each capture uploaded (and any export made from it) holds the text byte for byte, behind
|
|
127
|
+
// a manifest's sha256: those files go whole, so a later native export or continuation answers fidelity_unavailable
|
|
128
|
+
// rather than the secret. An upload or content-addressed chunk another session still names is kept, and the answer
|
|
129
|
+
// counts how many kept chunks still hold a value.
|
|
130
|
+
const mine="SELECT upload_id FROM session_artifacts WHERE team_id=? AND session_id=?",others="SELECT 1 FROM session_artifacts o WHERE o.team_id=? AND o.session_id<>? AND o.upload_id=",both=[auth.team_id,session,auth.team_id,session];
|
|
131
|
+
const uploads=(await api.store.query(`SELECT DISTINCT upload_id FROM session_artifacts a WHERE team_id=? AND session_id=? AND NOT EXISTS (${others}a.upload_id)`,both)).map(row=>row.upload_id);
|
|
132
|
+
const blobs=(await api.store.query(`SELECT DISTINCT sha256 FROM artifact_chunk_refs r WHERE team_id=? AND upload_id IN (${mine}) AND NOT EXISTS (${others}r.upload_id)`,[auth.team_id,...both])).map(row=>row.sha256);
|
|
133
|
+
statements.push(
|
|
134
|
+
stmt(`DELETE FROM artifact_chunks WHERE team_id=? AND upload_id IN (${mine}) AND NOT EXISTS (${others}artifact_chunks.upload_id)`,auth.team_id,...both),
|
|
135
|
+
stmt(`DELETE FROM artifact_chunk_refs WHERE team_id=? AND upload_id IN (${mine}) AND NOT EXISTS (${others}artifact_chunk_refs.upload_id)`,auth.team_id,...both),
|
|
136
|
+
stmt('DELETE FROM session_artifacts WHERE team_id=? AND session_id=?',auth.team_id,session),
|
|
137
|
+
...batches(uploads).map(part=>stmt(`DELETE FROM uploads WHERE team_id=? AND id IN (${holes(part)}) AND NOT EXISTS (SELECT 1 FROM session_artifacts a WHERE a.team_id=uploads.team_id AND a.upload_id=uploads.id)`,auth.team_id,...part)),
|
|
138
|
+
...batches(blobs).map(part=>stmt(`DELETE FROM artifact_blobs WHERE team_id=? AND sha256 IN (${holes(part)}) AND NOT EXISTS (SELECT 1 FROM artifact_chunk_refs r WHERE r.team_id=artifact_blobs.team_id AND r.sha256=artifact_blobs.sha256)`,auth.team_id,...part)));
|
|
139
|
+
const before=await api.store.one(`SELECT (SELECT count(*) FROM messages WHERE team_id=? AND session_id=? AND (${body.values.map(()=>'instr(value_json,?)>0').join(' OR ')})) messages`,[auth.team_id,session,...body.values]);
|
|
140
|
+
await api.store.guardedTransaction(authorityGuards(auth,api.clock.nowMs()),statements);
|
|
141
|
+
let kept=0,holding=0;
|
|
142
|
+
for(const part of batches(blobs,70)){
|
|
143
|
+
const row=await api.store.one(`SELECT count(*) kept,coalesce(sum(${body.values.map(()=>'instr(CAST(bytes AS TEXT),?)>0').join(' OR ')}),0) holding FROM artifact_blobs WHERE team_id=? AND sha256 IN (${holes(part)})`,[...body.values,auth.team_id,...part]);
|
|
144
|
+
kept+=row?.kept??0;holding+=row?.holding??0;
|
|
145
|
+
}
|
|
146
|
+
const left=await api.store.one('SELECT count(*) n FROM session_artifacts WHERE team_id=? AND session_id=?',[auth.team_id,session]);
|
|
147
|
+
counts[session]={messages:before?.messages??0,natives:natives.length,artifacts_removed:uploads.length,artifacts_left:left?.n??0,chunks_removed:blobs.length-kept,shared_chunks_kept:kept,shared_chunks_holding_value:holding};
|
|
148
|
+
}
|
|
149
|
+
console.log(`session text purged by ${auth.principal_id}: ${JSON.stringify(counts)}`);
|
|
150
|
+
send(res,200,result(api,auth,{sessions:counts}),requestId);return true;
|
|
151
|
+
}
|
|
87
152
|
if(sub==='/events/catalogue'&&req.method==='GET'){send(res,200,result(api,auth,{kinds:APP_EVENTS}),requestId);return true;}
|
|
88
153
|
if(sub==='/events'&&req.method==='GET'){await eventStream(ctx);return true;}
|
|
89
154
|
return false;
|