@volter/teams 0.2.13 → 0.2.14
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/http.mjs +5 -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.14",
|
|
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/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,8 @@ 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
|
+
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.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
71
|
const sourceDoor=sub.startsWith(`/sources/${auth.source_id}/captures`)||sub.startsWith(`/sources/${auth.source_id}/uploads`);
|
|
71
72
|
if(!machineDoor&&!sourceDoor)fail(403,'forbidden','credential kind cannot use this operation');
|
|
72
73
|
}
|
|
@@ -145,8 +146,8 @@ async function authorizeReplay(api,auth,method,sub,body){
|
|
|
145
146
|
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
147
|
return;
|
|
147
148
|
}
|
|
148
|
-
if(sub==='/event-sources'){if(
|
|
149
|
-
if((match=/^\/event-sources\/([^/]+)\/events$/.exec(sub))){if(!await api.store.one(
|
|
149
|
+
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;}
|
|
150
|
+
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
151
|
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
152
|
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
153
|
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});
|