@volter/teams 0.2.12 → 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-client.mjs CHANGED
@@ -6,7 +6,7 @@ import { dirname } from 'node:path';
6
6
  import { teamsHome, paths } from './home.mjs';
7
7
  import { fromContext, WorkspaceContexts } from './context.mjs';
8
8
  import { openLocalMachine } from './client.mjs';
9
- export { retryAfterMs } from './http-client.mjs';
9
+ export { retryAfterMs, spentClause } from './http-client.mjs';
10
10
 
11
11
  export function eventClient({supercodeHome=dirname(teamsHome()),context: name,fetch}={}) {
12
12
  const {client,context}=fromContext({supercodeHome,name,fetch});
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.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Neutral HTTP transport. Product extensions keep their payloads in their own contracts. */
2
- export interface RequestOptions { signal?: AbortSignal; idempotencyKey?: string; ifMatch?: number }
2
+ export interface RequestOptions { signal?: AbortSignal; idempotencyKey?: string; ifMatch?: number; /** this call site's name, appended to the process's X-Supercode-Caller */ caller?: string }
3
3
  export interface StreamOptions extends RequestOptions { after?: string }
4
4
  export type Query = Record<string, unknown>;
5
5
  export interface Result<T = unknown> { version: number; data: T; [key: string]: unknown }
@@ -153,3 +153,4 @@ export declare class TeamsClient {
153
153
  }
154
154
  /** Milliseconds a stream refused for load waits before asking again (the server's retry_after, at least 5 s), else null. */
155
155
  export declare function retryAfterMs(error: unknown, response?: Response | null): number | null;
156
+ export declare function spentClause(error: unknown): string;
package/http-client.mjs CHANGED
@@ -13,6 +13,9 @@ 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 events publish'`), so one process's doors are told
17
+ // apart in the server's tally; the process's own words are kept, the site's words always fit.
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}`};};
16
19
  // Every write to a Teams server is redacted (@volter/teams/redact), but a sign-in or credential exchange, whose body is
17
20
  // the credential it hands over.
18
21
  const SIGN_IN=/\/(?:login|apps\/token|invitations\/accept|credentials|me\/identities)(?:\/|$)/;
@@ -20,10 +23,10 @@ const SIGN_IN=/\/(?:login|apps\/token|invitations\/accept|credentials|me\/identi
20
23
  const { redactValue } = globalThis.process?.versions?.node ? await import(`./redact${'.mjs'}`) : { redactValue: (value) => value };
21
24
  export class TeamsClient {
22
25
  constructor({server,credential,fetch:fetchImpl=(...args)=>globalThis.fetch(...args),machine=null}){this.server=trimOrigin(server);this.credential=credential;this.fetch=fetchImpl;this.machine=machine;if(typeof fetchImpl!=='function')throw new TypeError('fetch is required');}
23
- async request(method,path,{body,signal,idempotencyKey,ifMatch}={}) {
26
+ async request(method,path,{body,signal,idempotencyKey,ifMatch,caller}={}) {
24
27
  const key=idempotencyKey??(method==='GET'?null:randomKey());let delay=250,last;
25
28
  for(let attempt=0;attempt<5;attempt++) { try {
26
- const response=await this.fetch(this.server+path,{method,signal,redirect:'manual',headers:{Accept:'application/json',Authorization:`Bearer ${this.credential}`,...(CALLER?{'X-Supercode-Caller':CALLER}:{}),...(body!==undefined?{'Content-Type':'application/json'}:{}),...(key?{'Idempotency-Key':key}:{}),...(ifMatch!==undefined?{'If-Match':String(ifMatch)}:{})},body:body===undefined?undefined:JSON.stringify(SIGN_IN.test(path)?body:redactValue(body))});
29
+ const response=await this.fetch(this.server+path,{method,signal,redirect:'manual',headers:{Accept:'application/json',Authorization:`Bearer ${this.credential}`,...callerHeader(caller),...(body!==undefined?{'Content-Type':'application/json'}:{}),...(key?{'Idempotency-Key':key}:{}),...(ifMatch!==undefined?{'If-Match':String(ifMatch)}:{})},body:body===undefined?undefined:JSON.stringify(SIGN_IN.test(path)?body:redactValue(body))});
27
30
  if(response.type==='opaqueredirect'||(response.status>=300&&response.status<400))throw Object.assign(new Error('cross-origin redirects are refused'),{code:'invalid_request',status:400,retryable:false});
28
31
  const value=response.status===204?null:await response.json();if(!response.ok)throw Object.assign(new Error(value?.error?.message??`HTTP ${response.status}`),value?.error,{status:response.status});return value;
29
32
  } catch(error) { last=error;if(method!=='GET'||signal?.aborted||!(!error.status||error.retryable)||attempt===4)throw error;await retryWait(delay,signal);delay=Math.min(delay*2,5000); } }
@@ -236,12 +239,19 @@ export function retryAfterMs(error,response=null){
236
239
  const named=error?.details?.retry_after_seconds??error?.retry_after_seconds??response?.headers?.get?.('retry-after'),seconds=named==null||named===''?NaN:Number(named);
237
240
  return Math.max(5000,Number.isFinite(seconds)?seconds*1000:30000);
238
241
  }
239
- async function* events(root,path,{signal,after}={}){
242
+ // Who spent the credential's last minute, as a refusal for the rate limit carries it (the server's tally by caller and
243
+ // route), for a log line: `; spent 118/min: caller a=60, b=40; route POST /x=70`. Empty for any other error.
244
+ export function spentClause(error){
245
+ const spent=error?.code==='rate_limited'?error.details?.spent_last_minute:null;if(!spent)return '';
246
+ const top=list=>(Array.isArray(list)?list:[]).slice(0,3).map(entry=>`${entry.name}=${entry.count}`).join(', ')||'none';
247
+ return `; spent ${spent.requests}/min: caller ${top(spent.by_caller)}; route ${top(spent.by_route)}`;
248
+ }
249
+ async function* events(root,path,{signal,after,caller}={}){
240
250
  let cursor=after??null,lastYielded=null,lastError=null;
241
251
  for(let attempt=0;attempt<5&&!signal?.aborted;attempt++){
242
252
  const suffix=cursor?path+(path.includes('?')?'&':'?')+'after='+encodeURIComponent(cursor):path;
243
253
  try{
244
- const response=await root.fetch(root.server+suffix,{signal,redirect:'manual',headers:{Accept:'text/event-stream',Authorization:`Bearer ${root.credential}`,...(CALLER?{'X-Supercode-Caller':CALLER}:{}),...(root.machine?{'X-Supercode-Machine':root.machine}:{}),...(cursor?{'Last-Event-ID':cursor}:{})}});
254
+ const response=await root.fetch(root.server+suffix,{signal,redirect:'manual',headers:{Accept:'text/event-stream',Authorization:`Bearer ${root.credential}`,...callerHeader(caller),...(root.machine?{'X-Supercode-Machine':root.machine}:{}),...(cursor?{'Last-Event-ID':cursor}:{})}});
245
255
  if(response.type==='opaqueredirect'||response.status>=300&&response.status<400)throw Object.assign(new Error('cross-origin redirects are refused'),{code:'invalid_request',status:400,retryable:false});
246
256
  if(!response.ok)throw await sseError(response);
247
257
  let buffer='';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/teams",
3
- "version": "0.2.12",
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
- const machineDoor=auth.kind==='machine'&&(sub==='/machines/events'||/^\/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));
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(!isAdmin(auth)&&!(auth.installation_id&&auth.app_actions.includes('events.publish')))fail(403,'forbidden','event source authorization changed');return;}
149
- if((match=/^\/event-sources\/([^/]+)\/events$/.exec(sub))){if(!await api.store.one('SELECT 1 FROM event_sources WHERE team_id=? AND id=? AND principal_id=?',[auth.team_id,match[1],auth.principal_id]))fail(404,'not_found','event source not found');return;}
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
- export const MIGRATIONS = [m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014, m015, m016, m017, m018, m019, m020, m021];
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
- if(!isAdmin(auth)&&!(auth.installation_id&&auth.app_actions.includes('events.publish')))fail(403,'forbidden','an admin or a consented publisher installation must authorize an event source');
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
- await guardedMutation(api,res,auth,authorityGuards(auth,api.clock.nowMs()),old?[]:[stmt('INSERT INTO event_sources VALUES (?,?,?,?)',auth.team_id,id,auth.principal_id,body.name)],{status:201,body:response,targetId:id});
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('SELECT 1 FROM event_sources WHERE team_id=? AND id=? AND principal_id=?',[auth.team_id,publication[1],auth.principal_id]);
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:'SELECT 1 FROM event_sources WHERE team_id=? AND id=? AND principal_id=?',params:[auth.team_id,publication[1],auth.principal_id],status:403,code:'forbidden',message:'event source authority changed'},
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});