@volter/teams 0.2.13 → 0.2.15
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/event-limits.mjs +3 -0
- package/http-client.mjs +1 -1
- package/package.json +1 -1
- package/server/agent-sessions.mjs +21 -2
- package/server/http.mjs +6 -4
- package/server/migrations/022.mjs +7 -0
- package/server/migrations/index.mjs +2 -1
- package/server/published-events.mjs +19 -6
package/event-limits.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
// What one published event may carry: the server refuses a source event whose `data` serializes above this many
|
|
2
2
|
// bytes, and a publisher fits its snapshot to it before sending.
|
|
3
3
|
export const EVENT_DATA_LIMIT = 262144;
|
|
4
|
+
// The most event sources one enrolled machine makes: the server refuses its next, and its connector makes one per board
|
|
5
|
+
// home registered with it, so it publishes at most this many homes.
|
|
6
|
+
export const MACHINE_SOURCES = 16;
|
package/http-client.mjs
CHANGED
|
@@ -13,7 +13,7 @@ export function callerName(env=globalThis.process?.env,argv=globalThis.process?.
|
|
|
13
13
|
return ([script,...words].join(' ')+(session?` in ${session}`:'')).replace(/[^\x20-\x7e]/g,'?').slice(0,120);
|
|
14
14
|
}
|
|
15
15
|
const CALLER=callerName();
|
|
16
|
-
// A call site may name itself after the process (`caller: 'board
|
|
16
|
+
// A call site may name itself after the process (`caller: 'board events publish'`), so one process's doors are told
|
|
17
17
|
// apart in the server's tally; the process's own words are kept, the site's words always fit.
|
|
18
18
|
const callerHeader=site=>{if(!CALLER)return {};if(!site)return {'X-Supercode-Caller':CALLER};const named=String(site).replace(/[^\x20-\x7e]/g,'?').slice(0,60);return {'X-Supercode-Caller':`${CALLER.slice(0,119-named.length)} ${named}`};};
|
|
19
19
|
// Every write to a Teams server is redacted (@volter/teams/redact), but a sign-in or credential exchange, whose body is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/teams",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.15",
|
|
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",
|
|
@@ -139,6 +139,19 @@ export async function agentSessionRoute(ctx){
|
|
|
139
139
|
const receipt=await sendAgentMail(api,auth,decodeURIComponent(match[1]),body.text,req.headers['idempotency-key']);
|
|
140
140
|
await send(res,200,result(api,auth,receipt),requestId);return true;
|
|
141
141
|
}
|
|
142
|
+
if((match=/^\/machines\/([^/]+)\/agent-sessions$/.exec(sub))&&req.method==='POST'){
|
|
143
|
+
// A session this machine captured is declared its agent's by the machine itself, from the agent its launch named
|
|
144
|
+
// (the session's environment): the machine's custodian holds the session, so it records the lineage when it manages
|
|
145
|
+
// the agent, exactly as a declaration through /agents/{id}/sessions does.
|
|
146
|
+
if(auth.kind!=='machine'||auth.machine_id!==match[1])fail(403,'forbidden','the owning machine declares its sessions\' agents');
|
|
147
|
+
const body=await parseJson(req);exact(body,['session','agent','main']);
|
|
148
|
+
if(typeof body.session!=='string'||typeof body.agent!=='string'||body.main!==undefined&&typeof body.main!=='boolean')fail(400,'invalid_request','session, agent and optional main boolean are required');
|
|
149
|
+
const agent=await api.store.one('SELECT * FROM agents WHERE team_id=? AND id=?',[auth.team_id,body.agent]);
|
|
150
|
+
if(!agent)fail(404,'not_found','agent not found');
|
|
151
|
+
if(!await manageableAgent(api,auth,agent))fail(403,'forbidden','only the agent\'s custodian, parent or sponsor declares its sessions');
|
|
152
|
+
const {session,guards:machineGuards}=await machineProfileSession(api,auth,body.session);
|
|
153
|
+
await declareOwnership(ctx,agent,session,{main:body.main},machineGuards);return true;
|
|
154
|
+
}
|
|
142
155
|
if((match=/^\/machines\/([^/]+)\/session-profiles$/.exec(sub))&&req.method==='POST'){
|
|
143
156
|
if(auth.kind!=='machine'||auth.machine_id!==match[1])fail(403,'forbidden','the owning machine reports resolved profiles');
|
|
144
157
|
const body=await parseJson(req);exact(body,['session','profile','observed_at','observation_id']);
|
|
@@ -203,6 +216,12 @@ export async function agentSessionRoute(ctx){
|
|
|
203
216
|
const body=await parseJson(req);exact(body,['session','main','starter_session']);
|
|
204
217
|
if(typeof body.session!=='string'||body.main!==undefined&&typeof body.main!=='boolean')fail(400,'invalid_request','session reference and optional main boolean are required');
|
|
205
218
|
const session=await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref:body.session,now});
|
|
219
|
+
await declareOwnership(ctx,agent,session,body,[]);return true;
|
|
220
|
+
}
|
|
221
|
+
/** One session declared as one agent's: the session's custodian, who manages the agent, records it (rule 1). */
|
|
222
|
+
async function declareOwnership(ctx,agent,session,body,extraGuards){
|
|
223
|
+
const {api,res,auth,requestId,send,result,guardedMutation}=ctx;
|
|
224
|
+
const now=api.clock.nowMs();
|
|
206
225
|
if(session.custodian_id!==auth.principal_id)fail(403,'forbidden','only your own session can be declared');
|
|
207
226
|
const old=await api.store.one('SELECT * FROM agent_sessions WHERE team_id=? AND session_id=?',[auth.team_id,session.id]);
|
|
208
227
|
if(old?.agent_id&&old.agent_id!==agent.id)fail(409,'conflict','a session already owned by an agent cannot change identity');
|
|
@@ -221,6 +240,6 @@ export async function agentSessionRoute(ctx){
|
|
|
221
240
|
// The main session is an Agent field (ontology Agent.main_session): its change is the agent's change too.
|
|
222
241
|
if(main&&agent.main_session_id!==session.id)statements.push(publication(api,auth,'agent.changed',agent.id,{...data.agent,revision:agent.revision+1},now));
|
|
223
242
|
statements.push(stmt('UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',auth.team_id),publish(api,auth,session.id,data,now));
|
|
224
|
-
await guardedMutation(api,res,auth,[...memberGuards(auth,now),ownerGuard(auth,session),...standing,...await managementGuards(api,auth,agent),revisionGuard(auth.team_id,session.id,old)],statements,{status:old?200:201,body:result(api,auth,data),targetId:session.id});
|
|
225
|
-
send(res,old?200:201,result(api,auth,data),requestId);
|
|
243
|
+
await guardedMutation(api,res,auth,[...memberGuards(auth,now),ownerGuard(auth,session),...standing,...await managementGuards(api,auth,agent),revisionGuard(auth.team_id,session.id,old),...extraGuards],statements,{status:old?200:201,body:result(api,auth,data),targetId:session.id});
|
|
244
|
+
send(res,old?200:201,result(api,auth,data),requestId);
|
|
226
245
|
}
|
package/server/http.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { appRoute, appPreAuth, appAuthentication, enforceAppScope, appAuthGuards, installedIssuer, identityIssuers } from './apps.mjs';
|
|
2
2
|
import { machineEventStream } from './machine-events.mjs';
|
|
3
|
-
import { publishedEventRoute } from './published-events.mjs';
|
|
3
|
+
import { publishedEventRoute, OWN_SOURCE, sourceMachine } from './published-events.mjs';
|
|
4
4
|
import { identityRecordRoute, identityAuthority } from './identity-record.mjs';
|
|
5
5
|
import { AGENT_ACTIVE } from './agent-authority.mjs';
|
|
6
6
|
import { APP_LINEAGE_REVOKED } from './auth.mjs';
|
|
@@ -66,7 +66,9 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
|
|
|
66
66
|
if(auth.kind==='user'&&auth.team_id===null){const membership=await api.store.one('SELECT role,active FROM memberships WHERE team_id=? AND principal_id=?',[teamId,auth.principal_id]);if(!membership?.active)fail(401,'authentication_required','authentication required');auth.role=membership.role;auth.active=membership.active;auth.team_id=teamId;auth.group_ids=(await api.store.query('SELECT group_id FROM group_members WHERE team_id=? AND principal_id=?',[teamId,auth.principal_id])).map(x=>x.group_id);}else if(auth.team_id!==teamId||!auth.active)fail(401,'authentication_required','authentication required');
|
|
67
67
|
if(req.method!=='GET')res._auditRecord={teamId:auth.team_id,actorId:auth.principal_id,act:auth.act??[],operation:`${req.method} ${path}`};
|
|
68
68
|
if(['machine','ingest'].includes(auth.kind)){
|
|
69
|
-
|
|
69
|
+
// a machine credential also publishes its own boards' events (the connector follows `workflow events`, D138 step 4)
|
|
70
|
+
// and declares the agent each of its sessions' launch named (agent-sessions, D138 step 5)
|
|
71
|
+
const machineDoor=auth.kind==='machine'&&(sub==='/machines/events'||sub==='/event-sources'||/^\/event-sources\/[^/]+\/events$/.test(sub)||/^\/machines\/[^/]+\/configuration$/.test(sub)||sub===`/machines/${auth.machine_id}/session-profiles`||sub===`/machines/${auth.machine_id}/agent-sessions`||sub.startsWith(`/machines/${auth.machine_id}/agent-closures`)||sub===`/machines/${auth.machine_id}/heartbeat`||sub.startsWith(`/machines/${auth.machine_id}/effects`)||new RegExp(`^/machines/${auth.machine_id}/attachments/[^/]+(?:/frames)?$`).test(sub));
|
|
70
72
|
const sourceDoor=sub.startsWith(`/sources/${auth.source_id}/captures`)||sub.startsWith(`/sources/${auth.source_id}/uploads`);
|
|
71
73
|
if(!machineDoor&&!sourceDoor)fail(403,'forbidden','credential kind cannot use this operation');
|
|
72
74
|
}
|
|
@@ -145,8 +147,8 @@ async function authorizeReplay(api,auth,method,sub,body){
|
|
|
145
147
|
if(method==='DELETE') { const row=await api.store.one('SELECT created_by FROM identity_links WHERE team_id=? AND id=?',[auth.team_id,sub.split('/').at(-1)]); if(!row||(!(isAdmin(auth)&&!auth.installation_id)&&row.created_by!==auth.principal_id))fail(403,'forbidden','identity link authority changed'); }
|
|
146
148
|
return;
|
|
147
149
|
}
|
|
148
|
-
if(sub==='/event-sources'){if(
|
|
149
|
-
if((match=/^\/event-sources\/([^/]+)\/events$/.exec(sub))){if(!await api.store.one(
|
|
150
|
+
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;}
|
|
151
|
+
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;}
|
|
150
152
|
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;}
|
|
151
153
|
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;}
|
|
152
154
|
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;}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// An event source an enrolled machine made is that machine's: only its own machine credential publishes into it or
|
|
2
|
+
// finds it by name, and no other credential of its custodian does (D138 step 4). A source with no machine is its
|
|
3
|
+
// principal's, and no machine credential publishes into it.
|
|
4
|
+
export default `
|
|
5
|
+
ALTER TABLE event_sources ADD COLUMN machine_id TEXT;
|
|
6
|
+
CREATE INDEX event_sources_machine ON event_sources(team_id, machine_id);
|
|
7
|
+
`;
|
|
@@ -20,7 +20,8 @@ import m018 from './018.mjs';
|
|
|
20
20
|
import m019 from './019.mjs';
|
|
21
21
|
import m020 from './020.mjs';
|
|
22
22
|
import m021 from './021.mjs';
|
|
23
|
-
|
|
23
|
+
import m022 from './022.mjs';
|
|
24
|
+
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];
|
|
24
25
|
/** A message row's `until_revision` while its session's current version still holds it (schema 9). */
|
|
25
26
|
export const OPEN_REVISION = Number.MAX_SAFE_INTEGER;
|
|
26
27
|
/** SQL: message row `m` belongs to the version at revision ?1 (bind the revision twice). */
|
|
@@ -5,7 +5,7 @@ import { exact } from './request-json.mjs';
|
|
|
5
5
|
import { canonicalJson } from './wire.mjs';
|
|
6
6
|
import { sseRoom, writeSse, holdStream } from './events.mjs';
|
|
7
7
|
import { APP_EVENTS, PUBLISHABLE_EVENTS, eventReadAction } from './event-catalogue.mjs';
|
|
8
|
-
import { EVENT_DATA_LIMIT } from '../event-limits.mjs';
|
|
8
|
+
import { EVENT_DATA_LIMIT, MACHINE_SOURCES } from '../event-limits.mjs';
|
|
9
9
|
|
|
10
10
|
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.
|
|
@@ -14,9 +14,16 @@ const holes = list => list.map(() => '?').join(',');
|
|
|
14
14
|
const fail = (status,code,message) => {throw Object.assign(new Error(message),{status,code});};
|
|
15
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;};
|
|
16
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)});
|
|
17
|
+
// A machine credential's principal is its custodian, so a source is the caller's only when its machine is the caller's
|
|
18
|
+
// machine too: a machine never publishes into its custodian's hand-made sources or another machine's, and no other
|
|
19
|
+
// credential of the custodian publishes into a machine's source as that machine.
|
|
20
|
+
export const sourceMachine=auth=>auth.kind==='machine'?auth.machine_id:null;
|
|
21
|
+
export const OWN_SOURCE='SELECT 1 FROM event_sources WHERE team_id=? AND id=? AND principal_id=? AND machine_id IS ?';
|
|
17
22
|
const authorityGuards=(auth,now)=>[
|
|
18
23
|
{sql:'SELECT 1 FROM credentials WHERE id=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)',params:[auth.credential_id,now],status:401,code:'authentication_required',message:'event credential revoked'},
|
|
19
24
|
{sql:'SELECT 1 FROM memberships WHERE team_id=? AND principal_id=? AND active=1',params:[auth.team_id,auth.principal_id],status:401,code:'authentication_required',message:'event membership revoked'},
|
|
25
|
+
// a machine credential publishes while its machine stays enrolled
|
|
26
|
+
...(auth.kind==='machine'?[{sql:'SELECT 1 FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL',params:[auth.team_id,auth.machine_id],status:401,code:'authentication_required',message:'event machine revoked'}]:[]),
|
|
20
27
|
];
|
|
21
28
|
|
|
22
29
|
/** Re-evaluate the installation and each correlated session's current ACL before every delivery or replay. */
|
|
@@ -62,11 +69,17 @@ function cursorCodec(api,auth,query) {
|
|
|
62
69
|
export async function publishedEventRoute(ctx) {
|
|
63
70
|
const {api,req,res,auth,sub,url,requestId,parseJson,send,result,guardedMutation}=ctx;
|
|
64
71
|
if(sub==='/event-sources'&&req.method==='POST'){
|
|
65
|
-
|
|
72
|
+
// An enrolled machine's connector publishes its own boards' events under its machine credential (D138 step 4).
|
|
73
|
+
if(auth.kind!=='machine'&&!isAdmin(auth)&&!(auth.installation_id&&auth.app_actions.includes('events.publish')))fail(403,'forbidden','an admin, an enrolled machine or a consented publisher installation must authorize an event source');
|
|
66
74
|
const body=await parseJson(req);exact(body,['name']);text(body.name,'name');
|
|
75
|
+
const machine=sourceMachine(auth);
|
|
76
|
+
// A machine's sources are named under its own id, so no machine finds or squats another's name.
|
|
77
|
+
if(machine&&!body.name.startsWith(`${machine}:`))fail(400,'invalid_request',`a machine's event source is named under its machine id (${machine}:…)`);
|
|
67
78
|
const old=await api.store.one('SELECT * FROM event_sources WHERE team_id=? AND principal_id=? AND name=?',[auth.team_id,auth.principal_id,body.name]);
|
|
79
|
+
if(old&&(old.machine_id??null)!==machine)fail(403,'forbidden','that event source belongs to another publisher');
|
|
68
80
|
const id=old?.id??api.ids.next('event_source'),response=result(api,auth,{id,name:body.name});
|
|
69
|
-
|
|
81
|
+
const guards=[...authorityGuards(auth,api.clock.nowMs()),...(machine&&!old?[{sql:'SELECT 1 WHERE (SELECT count(*) FROM event_sources WHERE team_id=? AND machine_id=?)<?',params:[auth.team_id,machine,MACHINE_SOURCES],status:409,code:'source_limit',message:`a machine publishes into at most ${MACHINE_SOURCES} event sources`}]:[])];
|
|
82
|
+
await guardedMutation(api,res,auth,guards,old?[]:[stmt('INSERT INTO event_sources(team_id,id,principal_id,name,machine_id) VALUES (?,?,?,?,?)',auth.team_id,id,auth.principal_id,body.name,machine)],{status:201,body:response,targetId:id});
|
|
70
83
|
send(res,201,response,requestId);return true;
|
|
71
84
|
}
|
|
72
85
|
const publication=/^\/event-sources\/([^/]+)\/events$/.exec(sub);
|
|
@@ -75,13 +88,13 @@ export async function publishedEventRoute(ctx) {
|
|
|
75
88
|
text(body.id,'id');text(body.resource?.id,'resource.id');exact(body.resource,['kind','id']);
|
|
76
89
|
if(!PUBLISHABLE_EVENTS.includes(body.kind)||body.resource.kind!==body.kind.split('.')[0]||!body.resource.id.startsWith(`${publication[1]}:`)||!Number.isSafeInteger(body.revision)||body.revision<1||!body.data||typeof body.data!=='object'||Array.isArray(body.data)||Buffer.byteLength(JSON.stringify(body.data))>EVENT_DATA_LIMIT)fail(400,'invalid_request','invalid source event or snapshot');
|
|
77
90
|
// A source can prove only its own principal. A plain session is evidence, never principal identity.
|
|
78
|
-
const actor={principal:auth.principal_id,session:null,lineage:auth.act??[]};
|
|
91
|
+
const actor={principal:auth.principal_id,session:null,lineage:auth.act??[],...(auth.kind==='machine'?{machine:auth.machine_id}:{})};
|
|
79
92
|
if(body.actor!=null){exact(body.actor,['session']);if(body.actor.session!=null){text(body.actor.session,'actor.session');if(!body.actor.session.startsWith('session:'))fail(400,'invalid_request','actor.session must name a session');actor.session=body.actor.session;}}
|
|
80
|
-
const source=await api.store.one(
|
|
93
|
+
const source=await api.store.one(OWN_SOURCE,[auth.team_id,publication[1],auth.principal_id,sourceMachine(auth)]);
|
|
81
94
|
if(!source)fail(404,'not_found','event source not found');
|
|
82
95
|
const old=await api.store.one('SELECT * FROM org_events WHERE id=?',[body.id]);
|
|
83
96
|
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;}
|
|
84
|
-
const now=api.clock.nowMs(),guards=[...authorityGuards(auth,now),{sql:
|
|
97
|
+
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'},
|
|
85
98
|
{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'}];
|
|
86
99
|
const event={id:body.id,org:auth.team_id,at:now,actor,kind:body.kind,resource:body.resource,data:body.data},response=result(api,auth,event);
|
|
87
100
|
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});
|