@volter/teams 0.1.89 → 0.2.0

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.
@@ -56,6 +56,9 @@ export async function machineGrantRows(api, teamId, machineId) {
56
56
 
57
57
  /** What the caller may claim on the machine right now, or null (indistinguishable from no machine). */
58
58
  async function claimFor(api, auth, machineId) {
59
+ // Session credentials never inherit the team's all-session mail grant or machine grants.
60
+ // Their narrowly authorized session operations use the session door instead.
61
+ if(auth.agent_credential)return null;
59
62
  if(auth.installation_id&&auth.kind!=='delegated'&&!await appAuthentication(api,auth))return null;
60
63
  if (auth.kind === 'delegated') {
61
64
  const row = await api.store.one(`SELECT g.id,g.revision FROM machine_grants g JOIN app_installations i ON i.id=g.app_installation_id JOIN apps a ON a.id=i.app_id
@@ -98,6 +101,11 @@ export function closeAppChannels(api,installationId,reason='app_uninstalled'){
98
101
  for(const entry of channels(api))if(entry.installationId===installationId)entry.close(reason);
99
102
  }
100
103
 
104
+ /** A represented agent's lifetime also fences a channel whose principal is its ancestor's app. */
105
+ export function closeActorChannels(api,teamId,actor,reason='agent_ended'){
106
+ for(const entry of channels(api))if(entry.teamId===teamId&&entry.actors?.includes(actor))entry.close(reason);
107
+ }
108
+
101
109
  /** Close the channels a revocation or machine removal leaves without authority; the next stream would be refused anyway. */
102
110
  export function closeMachineChannels(api, teamId, machineId, { grantId = null, reason = 'access_revoked' } = {}) {
103
111
  let closed = 0;
@@ -175,6 +183,7 @@ export async function authorizeMachineUpgrade(api, { authenticate, url, headers
175
183
  if (door === 'channel' && looksDelegated(presented)) return await delegatedChannel(api, teamId, machineId, presented);
176
184
  const auth = await authenticate(api, presented);
177
185
  if (!auth) return { status: 401, code: 'authentication_required', message: 'authentication required' };
186
+ if(auth.agent_credential)return {status:403,code:'forbidden',message:'agent session credential cannot open a machine channel'};
178
187
  if (door === 'link') {
179
188
  if (auth.kind !== 'machine' || auth.team_id !== teamId || auth.machine_id !== machineId) return { status: 403, code: 'forbidden', message: 'exact machine credential required' };
180
189
  const machine = await api.store.one('SELECT incarnation_id FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', [teamId, machineId]);
@@ -278,7 +287,7 @@ async function boundSessionTarget(api, teamId, access) {
278
287
  /** The same delivery checks, explainable only through a session custodian's authenticated door.
279
288
  * No token is accepted and no grant, binding or native message is created by this read.
280
289
  */
281
- export async function sessionDeliveryResolution(api, teamId, access, diagnostics = false) {
290
+ export async function sessionDeliveryResolution(api, teamId, access, diagnostics = false, auth = null) {
282
291
  const named = await api.store.query('SELECT id, canonical_id FROM sessions WHERE team_id=? AND deleted_at IS NULL AND (id=? OR id IN (SELECT session_id FROM origins WHERE team_id=? AND native_id=? AND deleted_at IS NULL))', [teamId, access.sessionRef, teamId, access.sessionRef]);
283
292
  let reason = 'session_not_found', origins = null;
284
293
  for (const found of named) {
@@ -294,6 +303,21 @@ export async function sessionDeliveryResolution(api, teamId, access, diagnostics
294
303
  ORDER BY (o.native_id=?) DESC, o.sequence DESC LIMIT 1`, [teamId, teamId, root.id, root.id, access.sessionRef]);
295
304
  if (origin) {
296
305
  const target = { sessionId: root.id, harness: origin.harness, nativeId: origin.native_id, machineId: origin.machine_id };
306
+ if(diagnostics){
307
+ if(await endedNoticeTarget(api,teamId,target.sessionId))return {state:'ended',reason:'agent_session_ended',target};
308
+ const machine=await api.store.one('SELECT name FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL',[teamId,target.machineId]);
309
+ const claim=auth?await claimFor(api,auth,target.machineId):null;
310
+ const link=links(api).get(key(teamId,target.machineId));
311
+ if(!machine||!claim||!link||link.channel.closed)return {state:'unknown',reason:!claim?'native_liveness_not_authorized':'machine_offline',target};
312
+ const name=String(machine.name).split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g,'-');
313
+ try{
314
+ const reply=await nativeMailReply(link,claim,{op:'status',to:`sc:${name}:${target.harness}:${target.nativeId}`},35000);
315
+ const native=reply.code===0?reply.native:null;
316
+ if(!native||!['running','stopped','unavailable','unknown'].includes(native.state))return {state:'unknown',reason:'native_liveness_unavailable',target};
317
+ return {state:native.state==='running'?'ready':native.state,reason:native.reason??(native.state==='stopped'?'native_session_stopped':null),target,
318
+ native:{state:native.state,resumable:native.resumable===true},checked_at:api.clock.nowMs()};
319
+ }catch{return {state:'unknown',reason:'native_liveness_unavailable',target};}
320
+ }
297
321
  return { state: 'ready', reason: null, target };
298
322
  }
299
323
  reason = 'no_live_origin';
@@ -317,6 +341,33 @@ export async function sessionDeliveryResolution(api, teamId, access, diagnostics
317
341
  return { state: 'unavailable', reason, target: null, ...(origins ? { origins } : {}) };
318
342
  }
319
343
 
344
+ /** The agent ownership ledger exists after C7. A process stop is distinct from its owner's terminal end. */
345
+ async function endedNoticeTarget(api,team,session){
346
+ if(!await api.store.one("SELECT 1 FROM sqlite_master WHERE type='table' AND name='agent_sessions'",[]))return false;
347
+ return Boolean(await api.store.one('SELECT 1 FROM agent_sessions WHERE team_id=? AND session_id=? AND ended_at IS NOT NULL',[team,session]));
348
+ }
349
+
350
+ async function nativeMailReply(link,claim,request,ms=35000){
351
+ const stream=await link.channel.open('mail',{_admitted:claim,request});
352
+ return new Promise((resolve,reject)=>{
353
+ let text='',finished=false;
354
+ const finish=(error,result)=>{if(finished)return;finished=true;clearTimeout(timer);error?reject(error):resolve(result);};
355
+ const timer=setTimeout(()=>{finish(new Error('native mail reply timed out'));stream.close('timeout');},ms);
356
+ stream.on('data',chunk=>{text+=chunk;if(text.length>65536){finish(new Error('native mail reply exceeded its bound'));stream.close('oversized');}});
357
+ stream.on('close',()=>{try{finish(null,JSON.parse(text));}catch{finish(new Error('native mail reply contained no JSON receipt'));}});
358
+ stream.on('error',error=>finish(error));
359
+ });
360
+ }
361
+
362
+ function noticeRefusal(reply){
363
+ const message=reply?.text??reply?.detail??'Room notice refused';
364
+ if(reply?.error==='session_resume_pending')return Object.assign(new Error(message),{status:503,code:'session_resume_pending'});
365
+ if(reply?.error==='session_resume_unavailable')return Object.assign(new Error(message),{status:409,code:'session_resume_unavailable'});
366
+ if(reply?.code===3)return Object.assign(new Error(message),{status:410,code:'session_not_running'});
367
+ if(reply?.code===2)return Object.assign(new Error(message),{status:403,code:'native_delivery_refused'});
368
+ return Object.assign(new Error(message),{status:503,code:'native_delivery_unconfirmed'});
369
+ }
370
+
320
371
  /** Close every delegated channel whose grant came from one publication (issuer from delegatedGrantIssuer): it was unpublished. */
321
372
  export function closeDelegatedChannels(api, teamId, machineId, grantIssuer, reason = 'unpublished') {
322
373
  let closed = 0;
@@ -371,6 +422,33 @@ async function acceptLink(api, teamId, machineId, duplex, closeSocket) {
371
422
  }
372
423
 
373
424
  const LINK_WAIT_MS = 10_000;
425
+
426
+ /** Send one already-authorized, exact native message. The receipt, rather than subprocess exit, confirms delivery. */
427
+ export async function deliverNativeMail(api, teamId, machineId, claim, request, authorize) {
428
+ const link=await linkWithin(api,key(teamId,machineId),LINK_WAIT_MS);
429
+ if(!link)throw Object.assign(new Error('machine is offline'),{status:503,code:'machine_offline'});
430
+ await authorize();
431
+ const stream=await link.channel.open('mail',{_admitted:claim,request});
432
+ return await new Promise((resolve,reject)=>{
433
+ let text='';
434
+ const timeout=setTimeout(()=>{reject(new Error('native message delivery not confirmed'));stream.close('timeout');},15000);
435
+ stream.on('data',chunk=>{
436
+ text+=chunk;
437
+ if(text.length>65536){clearTimeout(timeout);reject(new Error('native message receipt exceeds limit'));stream.close('receipt_limit');}
438
+ });
439
+ stream.on('close',()=>{
440
+ clearTimeout(timeout);
441
+ try{
442
+ if(!text.trim())throw new Error('native message returned no receipt');
443
+ const result=JSON.parse(text);
444
+ if(result.code!==0)throw new Error(result.text??result.detail??'native message refused');
445
+ if(result.receipt?.delivered!==true)throw new Error(result.receipt?.reason??'native message delivery not confirmed');
446
+ resolve({delivered:true});
447
+ }catch(error){reject(error);}
448
+ });
449
+ stream.on('error',error=>{clearTimeout(timeout);reject(error);});
450
+ });
451
+ }
374
452
  /** How often an open delegated channel asks its workplace whether the token's access still holds. */
375
453
  const DELEGATED_RECHECK_MS = 15_000;
376
454
 
@@ -407,7 +485,7 @@ async function acceptChannel(api, auth, machineId, duplex, closeSocket) {
407
485
  } catch { closeSocket('refused'); return; }
408
486
  const open = new Set();
409
487
  const entry = {
410
- teamId: auth.team_id, machineId, installationId:auth.installation_id??null, principal: auth.principal_id, claim: null, grantIssuer: auth.grant_issuer ?? null,
488
+ teamId: auth.team_id, machineId, installationId:auth.installation_id??null, principal: auth.principal_id, actors:[...(auth.act??[])], claim: null, grantIssuer: auth.grant_issuer ?? null,
411
489
  close: (reason) => closeSocket(reason),
412
490
  dropStreams: (reason) => { for (const pair of open) pair.close(reason); },
413
491
  };
@@ -466,6 +544,7 @@ async function acceptChannel(api, auth, machineId, duplex, closeSocket) {
466
544
  export async function deliverSessionRoomNotice(api, access) {
467
545
  const target = await boundSessionTarget(api, access.teamId, access);
468
546
  if (!target || target.harness !== access.harness) throw Object.assign(new Error('session not found'), { status: 404, code: 'not_found' });
547
+ if(await endedNoticeTarget(api,access.teamId,target.sessionId))throw Object.assign(new Error('the session owner has ended; nothing was sent'),{status:410,code:'agent_session_ended'});
469
548
  const machine = await api.store.one('SELECT name FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', [access.teamId, target.machineId]);
470
549
  if (!machine) throw Object.assign(new Error('machine not found'), { status: 404, code: 'not_found' });
471
550
  const machineName = String(machine.name).split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g, '-');
@@ -480,26 +559,43 @@ export async function deliverSessionRoomNotice(api, access) {
480
559
  params: [access.teamId, id, target.machineId, principal, JSON.stringify(['mail']), JSON.stringify([`mail:${to}`]), `delegated:room-notice:${digest}`, now, access.expiresAt, access.installationId] }]);
481
560
  try {
482
561
  const link = await linkWithin(api, key(access.teamId, target.machineId), LINK_WAIT_MS);
483
- if (!link) throw new Error('machine offline; Room notice remains pending');
484
- const stream = await link.channel.open('mail', { _admitted: { principal, groups: [], grants: [id], revisions: { [id]: 1 } },
562
+ if (!link) throw Object.assign(new Error('machine offline; Room notice remains pending'),{status:503,code:'machine_offline'});
563
+ const claim={principal,groups:[],grants:[id],revisions:{[id]:1}};
564
+ let state;
565
+ try{state=await nativeMailReply(link,claim,{op:'status',to},35000);}
566
+ catch(error){
567
+ // A connector that predates the status door refuses the op itself. It still delivers to a running
568
+ // session exactly as before; only the stopped-thread resume needs the updated connector.
569
+ if(!/^mail refused: mail needs a send, file, list or turn request/.test(error.message))throw Object.assign(new Error('native liveness is unavailable; nothing was sent'),{status:503,code:'native_liveness_unavailable'});
570
+ state=null;
571
+ }
572
+ if(state&&(state.code!==0||!['running','stopped','unavailable'].includes(state.native?.state)))throw Object.assign(new Error('native liveness is unavailable; nothing was sent'),{status:503,code:'native_liveness_unavailable'});
573
+ if(state?.native.state==='unavailable')throw Object.assign(new Error('native session is absent; nothing was sent'),{status:410,code:'native_session_not_found'});
574
+ if(state?.native.state==='stopped'&&!state.native.resumable)throw Object.assign(new Error(state.native.reason??'native resume authority is unrecorded; nothing was sent'),{status:409,code:'session_resume_unavailable'});
575
+ // Recheck admission and the exact binding after a potentially slow native read.
576
+ const current=await boundSessionTarget(api,access.teamId,access);
577
+ if(!current||JSON.stringify(current)!==JSON.stringify(target))throw Object.assign(new Error('session binding changed; nothing was sent'),{status:409,code:'session_binding_changed'});
578
+ if(await endedNoticeTarget(api,access.teamId,target.sessionId))throw Object.assign(new Error('the session owner has ended; nothing was sent'),{status:410,code:'agent_session_ended'});
579
+ await api.store.guardedTransaction([installedGuard(access,'delegation.notices')],[]);
580
+ const stream = await link.channel.open('mail', { _admitted: claim,
485
581
  request: { op: 'send', from: `sc:${machineName}:operator:room-${digest.slice(0, 24)}`, from_name: `Room ${access.roomName}`,
486
- to, body: access.notice.text, id: `room-notice:${digest}`, queue: false } });
582
+ to, body: access.notice.text, id: `room-notice:${digest}`, queue: false,...(state?{resume_if_closed:true}:{}) } });
487
583
  return await new Promise((resolve, reject) => {
488
584
  let text = '';
489
- const timeout = setTimeout(() => { reject(new Error('Room notice delivery not confirmed')); stream.close('timeout'); }, 15000);
585
+ const timeout = setTimeout(() => { reject(Object.assign(new Error('Room notice delivery not confirmed'),{status:503,code:'native_delivery_unconfirmed'})); stream.close('timeout'); }, 60000);
490
586
  // Channel data is already text; the machine writes remote-deliver's JSON reply as a string.
491
587
  stream.on('data', chunk => { text += chunk; });
492
588
  stream.on('close', () => {
493
589
  clearTimeout(timeout);
494
590
  try {
495
- if (!text.trim()) throw new Error('Room notice delivery returned no receipt');
591
+ if (!text.trim()) throw Object.assign(new Error('Room notice delivery returned no receipt'),{status:503,code:'native_delivery_unconfirmed'});
496
592
  const result = JSON.parse(text);
497
- if (result?.code !== 0) throw new Error(result?.text ?? result?.detail ?? 'Room notice refused');
498
- if (result.receipt?.delivered !== true) throw new Error(result.receipt?.reason ?? 'Room notice native delivery not confirmed');
593
+ if (result?.code !== 0) throw noticeRefusal(result);
594
+ if (result.receipt?.delivered !== true) throw Object.assign(new Error(result.receipt?.reason ?? 'Room notice native delivery not confirmed'),{status:503,code:'native_delivery_unconfirmed'});
499
595
  resolve({ delivered: true, noticeId: access.notice.id });
500
- } catch (error) { reject(error); }
596
+ } catch (error) { reject(error.status?error:Object.assign(new Error('Room notice delivery returned an invalid receipt'),{status:503,code:'native_delivery_unconfirmed'})); }
501
597
  });
502
- stream.on('error', error => { clearTimeout(timeout); reject(error); });
598
+ stream.on('error', () => { clearTimeout(timeout); reject(Object.assign(new Error('Room notice delivery channel failed'),{status:503,code:'native_delivery_unconfirmed'})); });
503
599
  });
504
600
  } finally { await api.store.transaction([{ sql: 'DELETE FROM machine_grants WHERE team_id=? AND id=?', params: [access.teamId, id] }]); }
505
601
  })();
@@ -0,0 +1,147 @@
1
+ // Standard org doors (mirrored ontology C6, M6; ADR 0018). Two ways a team takes members from outside:
2
+ // - an identity issuer's GROUP: an admin links the team to a group, and a person whose verified sign-in token carries
3
+ // that group in its `groups` claim joins as a member (a member removed from the team stays removed);
4
+ // - a LINKED SOURCE: an admin names one installed app as the team's membership source; that app's installation writes
5
+ // the members, each by an issuer the team installed for sign-in and a subject, and nobody else does. A subject
6
+ // already linked by a sign-in is seated at once; any other is a pending seat its owner's own sign-in claims, so a
7
+ // tenant's assertion never writes the server-wide issuer/subject link.
8
+ // A removal the team cannot apply (the last owner) is kept as an alarm for the admins.
9
+ // Joining a team gives no machine or session access.
10
+ import { isAdmin } from './auth.mjs';
11
+ import { installedIssuer } from './apps.mjs';
12
+
13
+ const text = (value, name, max = 512) => { if (typeof value !== 'string' || !value.trim() || value.length > max) throw Object.assign(new Error(`${name} is required`), { status: 400, code: 'invalid_request' }); return value.trim(); };
14
+ const forbid = (message) => { throw Object.assign(new Error(message), { status: 403, code: 'forbidden' }); };
15
+
16
+ /** The installation a linked team takes its membership from, or null. */
17
+ export async function membershipSource(api, teamId) {
18
+ return (await api.store.one('SELECT installation_id FROM membership_sources WHERE team_id=?', [teamId]))?.installation_id ?? null;
19
+ }
20
+
21
+ /** Refuse a membership change in a linked team made by anyone but its source. */
22
+ export async function requireUnlinked(api, teamId) {
23
+ if (await membershipSource(api, teamId)) throw Object.assign(new Error('this team takes its members from its linked source; change them there'), { status: 409, code: 'membership_linked' });
24
+ }
25
+
26
+ /** Whether a verified person's `groups` claim names a group this (unlinked) team admits from that issuer. */
27
+ export async function admittedByGroup(api, teamId, person) {
28
+ if (!Array.isArray(person.groups) || !person.groups.length || await membershipSource(api, teamId)) return false;
29
+ const rows = await api.store.query('SELECT group_name FROM identity_group_links WHERE team_id=? AND issuer=?', [teamId, person.issuer]);
30
+ return rows.some((row) => person.groups.includes(row.group_name));
31
+ }
32
+
33
+ /**
34
+ * A member the issuer's group admitted stays only while their token still carries a linked group: a sign-in whose
35
+ * token no longer does ends the membership and is refused. A member who came by invitation is unaffected.
36
+ */
37
+ export async function groupStillAdmits(api, teamId, principalId, person) {
38
+ const admitted = await api.store.one('SELECT issuer FROM group_admissions WHERE team_id=? AND principal_id=?', [teamId, principalId]);
39
+ if (!admitted || admitted.issuer !== person.issuer) return true;
40
+ const rows = await api.store.query('SELECT group_name FROM identity_group_links WHERE team_id=? AND issuer=?', [teamId, person.issuer]);
41
+ if (rows.some((row) => (person.groups ?? []).includes(row.group_name))) return true;
42
+ const now = api.clock.nowMs();
43
+ await api.store.transaction([
44
+ { sql: 'UPDATE memberships SET active=0,revision=revision+1 WHERE team_id=? AND principal_id=? AND active=1', params: [teamId, principalId] },
45
+ { sql: 'UPDATE credentials SET revoked_at=? WHERE team_id=? AND principal_id=? AND revoked_at IS NULL', params: [now, teamId, principalId] },
46
+ { sql: 'DELETE FROM group_admissions WHERE team_id=? AND principal_id=?', params: [teamId, principalId] },
47
+ { sql: 'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?', params: [teamId] },
48
+ ]);
49
+ api.events.publish(teamId, { type: 'authorization_changed', principal_id: principalId });
50
+ return false;
51
+ }
52
+
53
+ export async function memberMirrorRoute({ api, req, res, auth, sub, requestId, parseJson, send, result }) {
54
+ const admin = () => { if (!isAdmin(auth) || auth.installation_id) forbid('a team admin manages its org links'); };
55
+ const now = api.clock.nowMs();
56
+ if (sub === '/identity/groups') {
57
+ admin();
58
+ if (req.method === 'GET') { send(res, 200, result(api, auth, { links: await api.store.query('SELECT issuer,group_name "group",linked_by,created_at FROM identity_group_links WHERE team_id=? ORDER BY issuer,group_name', [auth.team_id]) }), requestId); return true; }
59
+ const body = await parseJson(req);
60
+ const issuer = text(body.issuer, 'issuer'), group = text(body.group, 'group', 256);
61
+ if (req.method === 'POST') await api.store.transaction([{ sql: 'INSERT OR IGNORE INTO identity_group_links(team_id,issuer,group_name,linked_by,created_at) VALUES (?,?,?,?,?)', params: [auth.team_id, issuer, group, auth.principal_id, now] }]);
62
+ else if (req.method === 'DELETE') await api.store.transaction([{ sql: 'DELETE FROM identity_group_links WHERE team_id=? AND issuer=? AND group_name=?', params: [auth.team_id, issuer, group] }]);
63
+ else return false;
64
+ send(res, 200, result(api, auth, { issuer, group, linked: req.method === 'POST' }), requestId); return true;
65
+ }
66
+ if (sub === '/membership-source') {
67
+ admin();
68
+ if (req.method === 'GET') { send(res, 200, result(api, auth, { installation_id: await membershipSource(api, auth.team_id) }), requestId); return true; }
69
+ if (req.method === 'PUT') {
70
+ const body = await parseJson(req);
71
+ const installation = await api.store.one("SELECT id FROM app_installations WHERE id=? AND team_id=? AND state='active'", [text(body.installation_id, 'installation_id'), auth.team_id]);
72
+ if (!installation) throw Object.assign(new Error('no active installation by that id in this team'), { status: 404, code: 'not_found' });
73
+ await api.store.transaction([{ sql: 'INSERT INTO membership_sources(team_id,installation_id,linked_by,linked_at) VALUES (?,?,?,?) ON CONFLICT(team_id) DO UPDATE SET installation_id=excluded.installation_id,linked_by=excluded.linked_by,linked_at=excluded.linked_at', params: [auth.team_id, installation.id, auth.principal_id, now] }]);
74
+ send(res, 200, result(api, auth, { installation_id: installation.id }), requestId); return true;
75
+ }
76
+ if (req.method === 'DELETE') { await api.store.transaction([{ sql: 'DELETE FROM membership_sources WHERE team_id=?', params: [auth.team_id] }]); send(res, 200, result(api, auth, { installation_id: null }), requestId); return true; }
77
+ return false;
78
+ }
79
+ if (sub === '/membership-alarms' && req.method === 'GET') {
80
+ admin();
81
+ send(res, 200, result(api, auth, { alarms: await api.store.query('SELECT id,principal_id,issuer,subject,reason,created_at FROM membership_alarms WHERE team_id=? AND cleared_at IS NULL ORDER BY created_at', [auth.team_id]) }), requestId); return true;
82
+ }
83
+ if (sub === '/membership-seats' && req.method === 'GET') {
84
+ admin();
85
+ send(res, 200, result(api, auth, { seats: await api.store.query('SELECT issuer,subject,role,display_name,observation,created_at FROM source_seats WHERE team_id=? ORDER BY created_at', [auth.team_id]) }), requestId); return true;
86
+ }
87
+ if (sub === '/members/mirror' && req.method === 'POST') {
88
+ const source = await membershipSource(api, auth.team_id);
89
+ if (!source || auth.installation_id !== source) forbid('only the team\'s linked membership source mirrors members');
90
+ const body = await parseJson(req);
91
+ const issuer = text(body.issuer, 'issuer'), subject = text(body.subject, 'subject'), active = body.active !== false;
92
+ const role = body.role === 'admin' ? 'admin' : 'member';
93
+ // The source's own record of this person (an RH2 principal, `rh2:<id>`): linked to whoever holds the subject.
94
+ const observed = body.observation == null ? null : text(body.observation, 'observation');
95
+ if (observed !== null && !/^[a-z][a-z0-9_-]*:\S+$/.test(observed)) throw Object.assign(new Error('observation is a namespaced identifier'), { status: 400, code: 'invalid_request' });
96
+ // Only an issuer this team installed for sign-in names its members.
97
+ if (!await installedIssuer(api, issuer, 'identity.login', auth.team_id)) forbid('this team has not installed that issuer for sign-in');
98
+ const linked = await api.store.one('SELECT principal_id FROM principal_identities WHERE issuer=? AND subject=?', [issuer, subject]);
99
+ if (!active) {
100
+ await api.store.transaction([{ sql: 'DELETE FROM source_seats WHERE team_id=? AND issuer=? AND subject=?', params: [auth.team_id, issuer, subject] }]);
101
+ if (!linked) { send(res, 200, result(api, auth, { applied: true, member: null }), requestId); return true; }
102
+ const member = await api.store.one('SELECT role FROM memberships WHERE team_id=? AND principal_id=? AND active=1', [auth.team_id, linked.principal_id]);
103
+ if (member?.role === 'owner' && (await api.store.one("SELECT count(*) n FROM memberships WHERE team_id=? AND role='owner' AND active=1", [auth.team_id])).n <= 1) {
104
+ await api.store.transaction([{ sql: 'INSERT INTO membership_alarms(team_id,id,principal_id,issuer,subject,reason,created_at) VALUES (?,?,?,?,?,?,?)', params: [auth.team_id, api.ids.next('alarm'), linked.principal_id, issuer, subject, 'the source removed the team\'s last owner', now] }]);
105
+ throw Object.assign(new Error('the team cannot remove its last owner; an alarm was raised for its admins'), { status: 409, code: 'removal_unapplied' });
106
+ }
107
+ if (member) await api.store.transaction([
108
+ { sql: 'UPDATE memberships SET active=0,revision=revision+1 WHERE team_id=? AND principal_id=?', params: [auth.team_id, linked.principal_id] },
109
+ { sql: 'UPDATE credentials SET revoked_at=? WHERE team_id=? AND principal_id=? AND revoked_at IS NULL', params: [now, auth.team_id, linked.principal_id] },
110
+ { sql: 'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?', params: [auth.team_id] },
111
+ ]);
112
+ api.events.publish(auth.team_id, { type: 'authorization_changed', principal_id: linked.principal_id });
113
+ send(res, 200, result(api, auth, { applied: true, member: null }), requestId); return true;
114
+ }
115
+ if (!linked) {
116
+ // Nobody has signed in as this subject: the team keeps a pending seat that the person's own sign-in claims.
117
+ await api.store.transaction([{ sql: 'INSERT INTO source_seats(team_id,issuer,subject,role,display_name,observation,created_at) VALUES (?,?,?,?,?,?,?) ON CONFLICT(team_id,issuer,subject) DO UPDATE SET role=excluded.role,display_name=excluded.display_name,observation=excluded.observation', params: [auth.team_id, issuer, subject, role, typeof body.name === 'string' && body.name.trim() ? body.name.trim().slice(0, 200) : null, observed, now] }]);
118
+ send(res, 200, result(api, auth, { applied: true, member: null, pending: true }), requestId); return true;
119
+ }
120
+ const pid = linked.principal_id;
121
+ const statements = observed === null ? [] : integrationLink(api, auth.team_id, observed, pid, auth.installation_id, auth.principal_id, issuer, subject, now);
122
+ const member = await api.store.one('SELECT active,role FROM memberships WHERE team_id=? AND principal_id=?', [auth.team_id, pid]);
123
+ if (!member) statements.push({ sql: 'INSERT INTO memberships(team_id,principal_id,role) VALUES (?,?,?)', params: [auth.team_id, pid, role] });
124
+ else if (!member.active || (member.role !== role && member.role !== 'owner')) statements.push({ sql: 'UPDATE memberships SET active=1,role=?,revision=revision+1 WHERE team_id=? AND principal_id=?', params: [member.role === 'owner' ? 'owner' : role, auth.team_id, pid] });
125
+ if (statements.length) await api.store.transaction(statements);
126
+ send(res, 200, result(api, auth, { applied: true, member: { principal_id: pid, role: member?.role === 'owner' ? 'owner' : role } }), requestId); return true;
127
+ }
128
+ return false;
129
+ }
130
+
131
+ /** The pending seat a linked source keeps in this team for a verified person, or null (sign-in claims it). */
132
+ export async function sourceSeat(api, teamId, person) {
133
+ return api.store.one('SELECT role,display_name,observation FROM source_seats WHERE team_id=? AND issuer=? AND subject=?', [teamId, person.issuer, person.subject]);
134
+ }
135
+
136
+ /**
137
+ * The statements that link a membership source's own record of a person (`rh2:<id>`) to the principal who signed in as
138
+ * the subject the source named: proven, kind integration, through the source's installation; written only while the
139
+ * observation has no live proven link.
140
+ */
141
+ export function integrationLink(api, teamId, observation, principalId, installationId, by, issuer, subject, now) {
142
+ return [
143
+ { sql: 'INSERT INTO identity_observations(team_id,id,first_seen,last_seen) VALUES (?,?,?,?) ON CONFLICT(team_id,id) DO NOTHING', params: [teamId, observation, now, now] },
144
+ { sql: "INSERT INTO identity_links(team_id,id,observation_id,principal_id,classification,proof_kind,evidence_json,created_by,created_at,installation_id) SELECT ?,?,?,?,'proven','integration',?,?,?,? WHERE NOT EXISTS (SELECT 1 FROM identity_links WHERE team_id=? AND observation_id=? AND classification='proven' AND revoked_at IS NULL)",
145
+ params: [teamId, api.ids.next('identity_link'), observation, principalId, JSON.stringify({ installation_id: installationId, issuer, subject }), by, now, installationId, teamId, observation] },
146
+ ];
147
+ }
@@ -0,0 +1,72 @@
1
+ // The event and its delivery obligations share the source mutation's transaction on both hosts.
2
+ const now = "CAST((julianday('now')-2440587.5)*86400000 AS INTEGER)";
3
+ const insert = (team, kind, type, id, at, data) => `
4
+ INSERT INTO org_events(id,team_id,kind,resource_kind,resource_id,created_at,actor_json,data_json)
5
+ VALUES ('evt_'||lower(hex(randomblob(16))),${team},${kind},'${type}',${id},${at},'{"principal":null,"session":null,"lineage":[]}',${data});`;
6
+ const session = (team, id) => `json_object('id',${id},'harness',(SELECT harness FROM sessions WHERE team_id=${team} AND id=${id}),
7
+ 'bindings',json(COALESCE((SELECT json_group_array(json(descriptor_json)) FROM bindings WHERE team_id=${team} AND session_id=${id}),'[]')))`;
8
+ const membership = "json_object('principal_id',NEW.principal_id,'role',NEW.role,'active',json(CASE WHEN NEW.active=1 THEN 'true' ELSE 'false' END),'revision',NEW.revision)";
9
+ const otherRunning = "EXISTS (SELECT 1 FROM bindings b WHERE b.team_id=NEW.team_id AND b.session_id=NEW.session_id AND b.id!=NEW.id AND json_extract(b.descriptor_json,'$.state')='running')";
10
+ const bindingKind = `CASE WHEN json_extract(NEW.descriptor_json,'$.state')='running' AND NOT ${otherRunning} THEN 'session.started' WHEN json_extract(NEW.descriptor_json,'$.state')='stopped' AND NOT ${otherRunning} THEN 'session.ended' ELSE 'session.changed' END`;
11
+ const bindingChanged = "OLD.incarnation_id!=NEW.incarnation_id OR json_extract(OLD.descriptor_json,'$.state') IS NOT json_extract(NEW.descriptor_json,'$.state') OR json_extract(OLD.descriptor_json,'$.native_runtime_id') IS NOT json_extract(NEW.descriptor_json,'$.native_runtime_id')";
12
+ export default `
13
+ CREATE TABLE event_sources (
14
+ team_id TEXT NOT NULL, id TEXT NOT NULL, principal_id TEXT NOT NULL, name TEXT NOT NULL,
15
+ PRIMARY KEY(team_id,id), UNIQUE(team_id,principal_id,name),
16
+ FOREIGN KEY(team_id,principal_id) REFERENCES memberships(team_id,principal_id)
17
+ ) STRICT;
18
+ CREATE TABLE org_events (
19
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, team_id TEXT NOT NULL REFERENCES teams(id),
20
+ source_id TEXT, source_revision INTEGER, kind TEXT NOT NULL, resource_kind TEXT NOT NULL, resource_id TEXT NOT NULL,
21
+ actor_json TEXT NOT NULL CHECK(json_valid(actor_json)), data_json TEXT NOT NULL CHECK(json_valid(data_json)), created_at INTEGER NOT NULL
22
+ ) STRICT;
23
+ CREATE INDEX org_events_replay ON org_events(team_id,sequence);
24
+ CREATE TABLE event_resources (
25
+ team_id TEXT NOT NULL, source_id TEXT NOT NULL DEFAULT '', resource_kind TEXT NOT NULL, resource_id TEXT NOT NULL,
26
+ sequence INTEGER NOT NULL, source_revision INTEGER, event_json TEXT NOT NULL CHECK(json_valid(event_json)),
27
+ PRIMARY KEY(team_id,source_id,resource_kind,resource_id)
28
+ ) STRICT;
29
+ CREATE TABLE event_retention (team_id TEXT PRIMARY KEY, pruned_through INTEGER NOT NULL) STRICT;
30
+ CREATE TRIGGER org_event_record AFTER INSERT ON org_events BEGIN
31
+ INSERT INTO event_resources(team_id,source_id,resource_kind,resource_id,sequence,source_revision,event_json)
32
+ VALUES (NEW.team_id,COALESCE(NEW.source_id,''),NEW.resource_kind,NEW.resource_id,NEW.sequence,NEW.source_revision,
33
+ json_object('id',NEW.id,'org',NEW.team_id,'at',NEW.created_at,'actor',json(NEW.actor_json),'kind',NEW.kind,
34
+ 'resource',json_object('kind',NEW.resource_kind,'id',NEW.resource_id),'data',json(NEW.data_json)))
35
+ ON CONFLICT(team_id,source_id,resource_kind,resource_id) DO UPDATE SET sequence=excluded.sequence,source_revision=excluded.source_revision,event_json=excluded.event_json;
36
+ INSERT INTO app_webhook_deliveries(id,installation_id,event_id,body,state,next_at,created_at)
37
+ SELECT 'delivery_'||lower(hex(randomblob(16))),i.id,NEW.id,
38
+ json_object('id',NEW.id,'org',NEW.team_id,'at',NEW.created_at,'actor',json(NEW.actor_json),'kind',NEW.kind,
39
+ 'resource',json_object('kind',NEW.resource_kind,'id',NEW.resource_id),'data',json(NEW.data_json)),
40
+ 'pending',NEW.created_at,NEW.created_at
41
+ FROM app_installations i JOIN apps a ON a.id=i.app_id
42
+ WHERE i.team_id=NEW.team_id AND i.state='active' AND a.state='active' AND a.webhook_url IS NOT NULL
43
+ AND EXISTS (SELECT 1 FROM json_each(a.events_json) WHERE value=NEW.kind);
44
+ END;
45
+ CREATE TRIGGER membership_event_added AFTER INSERT ON memberships WHEN NEW.active=1 BEGIN
46
+ ${insert('NEW.team_id', "'membership.added'", 'membership', 'NEW.principal_id', now, membership)}
47
+ END;
48
+ CREATE TRIGGER membership_event_changed AFTER UPDATE OF active,role ON memberships WHEN OLD.active!=NEW.active OR OLD.role!=NEW.role BEGIN
49
+ ${insert('NEW.team_id', "CASE WHEN NEW.active=0 THEN 'membership.removed' WHEN OLD.active=0 THEN 'membership.added' ELSE 'membership.changed' END", 'membership', 'NEW.principal_id', now, membership)}
50
+ END;
51
+ CREATE TRIGGER session_event_capture AFTER INSERT ON session_versions BEGIN
52
+ ${insert('NEW.team_id', "'session.changed'", 'session', 'NEW.session_id', 'NEW.created_at', session('NEW.team_id', 'NEW.session_id'))}
53
+ END;
54
+ CREATE TRIGGER session_event_binding_added AFTER INSERT ON bindings BEGIN
55
+ ${insert('NEW.team_id', bindingKind, 'session', 'NEW.session_id', 'NEW.observed_at', session('NEW.team_id', 'NEW.session_id'))}
56
+ END;
57
+ CREATE TRIGGER session_event_binding_changed AFTER UPDATE ON bindings WHEN ${bindingChanged} BEGIN
58
+ ${insert('NEW.team_id', bindingKind, 'session', 'NEW.session_id', 'NEW.observed_at', session('NEW.team_id', 'NEW.session_id'))}
59
+ END;
60
+ CREATE TRIGGER session_event_binding_removed AFTER DELETE ON bindings BEGIN
61
+ ${insert('OLD.team_id', "'session.changed'", 'session', 'OLD.session_id', now, session('OLD.team_id', 'OLD.session_id'))}
62
+ END;
63
+ CREATE TRIGGER machine_event_presence AFTER INSERT ON machine_presence_events BEGIN
64
+ ${insert('NEW.team_id', "'resource.changed'", 'machine', 'NEW.machine_id', 'NEW.created_at', "json_object('id',NEW.machine_id,'name',NEW.name,'linked',json(CASE WHEN NEW.linked=1 THEN 'true' ELSE 'false' END))")}
65
+ END;
66
+ INSERT INTO org_events(id,team_id,kind,resource_kind,resource_id,created_at,actor_json,data_json)
67
+ SELECT 'evt_'||lower(hex(randomblob(16))),m.team_id,'membership.changed','membership',m.principal_id,${now},'{"principal":null,"session":null,"lineage":[]}',json_object('principal_id',m.principal_id,'role',m.role,'active',json(CASE WHEN m.active=1 THEN 'true' ELSE 'false' END),'revision',m.revision) FROM memberships m;
68
+ INSERT INTO org_events(id,team_id,kind,resource_kind,resource_id,created_at,actor_json,data_json)
69
+ SELECT 'evt_'||lower(hex(randomblob(16))),s.team_id,'session.changed','session',s.id,${now},'{"principal":null,"session":null,"lineage":[]}',${session('s.team_id', 's.id')} FROM sessions s WHERE s.deleted_at IS NULL;
70
+ INSERT INTO org_events(id,team_id,kind,resource_kind,resource_id,created_at,actor_json,data_json)
71
+ SELECT 'evt_'||lower(hex(randomblob(16))),p.team_id,'resource.changed','machine',p.machine_id,p.changed_at,'{"principal":null,"session":null,"lineage":[]}',json_object('id',p.machine_id,'name',p.name,'linked',json(CASE WHEN p.linked=1 THEN 'true' ELSE 'false' END)) FROM machine_presence p;
72
+ `;
@@ -0,0 +1,28 @@
1
+ // Identity evidence is Org-owned. Declaring a person grants no membership or credential.
2
+ export default `
3
+ CREATE TABLE identity_principals (
4
+ team_id TEXT NOT NULL REFERENCES teams(id), principal_id TEXT NOT NULL REFERENCES principals(id),
5
+ declared_by TEXT NOT NULL REFERENCES principals(id), created_at INTEGER NOT NULL,
6
+ PRIMARY KEY(team_id,principal_id)
7
+ ) STRICT;
8
+ CREATE TABLE identity_observations (
9
+ team_id TEXT NOT NULL REFERENCES teams(id), id TEXT NOT NULL, first_seen INTEGER NOT NULL, last_seen INTEGER NOT NULL,
10
+ PRIMARY KEY(team_id,id)
11
+ ) STRICT;
12
+ CREATE TABLE identity_sightings (
13
+ team_id TEXT NOT NULL, id TEXT NOT NULL, observation_id TEXT NOT NULL, location TEXT NOT NULL,
14
+ observed_at INTEGER NOT NULL, recorded_at INTEGER NOT NULL, recorder_id TEXT NOT NULL REFERENCES principals(id),
15
+ name TEXT, evidence_json TEXT NOT NULL CHECK(json_valid(evidence_json)),
16
+ PRIMARY KEY(team_id,id), FOREIGN KEY(team_id,observation_id) REFERENCES identity_observations(team_id,id)
17
+ ) STRICT;
18
+ CREATE INDEX identity_sightings_lookup ON identity_sightings(team_id,observation_id,recorded_at,id);
19
+ CREATE TABLE identity_links (
20
+ team_id TEXT NOT NULL, id TEXT NOT NULL, observation_id TEXT NOT NULL, principal_id TEXT NOT NULL REFERENCES principals(id),
21
+ classification TEXT NOT NULL CHECK(classification IN ('proven','inferred')),
22
+ proof_kind TEXT NOT NULL, evidence_json TEXT NOT NULL CHECK(json_valid(evidence_json)),
23
+ created_by TEXT NOT NULL REFERENCES principals(id), created_at INTEGER NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
24
+ revoked_at INTEGER, revoked_by TEXT REFERENCES principals(id), installation_id TEXT REFERENCES app_installations(id),
25
+ PRIMARY KEY(team_id,id), FOREIGN KEY(team_id,observation_id) REFERENCES identity_observations(team_id,id)
26
+ ) STRICT;
27
+ CREATE UNIQUE INDEX identity_one_proven ON identity_links(team_id,observation_id) WHERE classification='proven' AND revoked_at IS NULL;
28
+ `;
@@ -0,0 +1,60 @@
1
+ // Agent identity is a principal; its assignments and intended profile live in the orchestrator.
2
+ import { agentSessionEventTriggers } from '../session-snapshot.mjs';
3
+ export default `
4
+ -- Replace only the column-local enum. Principal ids and every foreign key stay in place.
5
+ ALTER TABLE principals RENAME COLUMN kind TO previous_kind;
6
+ ALTER TABLE principals ADD COLUMN kind TEXT NOT NULL DEFAULT 'service' CHECK(kind IN ('user','service','agent'));
7
+ UPDATE principals SET kind=previous_kind;
8
+ ALTER TABLE principals DROP COLUMN previous_kind;
9
+
10
+ CREATE TABLE agents (
11
+ team_id TEXT NOT NULL REFERENCES teams(id), id TEXT NOT NULL REFERENCES principals(id),
12
+ name TEXT NOT NULL, parent_id TEXT, custodian_id TEXT NOT NULL REFERENCES principals(id),
13
+ lineage_json TEXT NOT NULL CHECK(json_valid(lineage_json)), sponsor_id TEXT REFERENCES principals(id),
14
+ profile_ref TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('active','suspended','ended')),
15
+ main_session_id TEXT, created_at INTEGER NOT NULL, ended_at INTEGER, end_reason TEXT,
16
+ revision INTEGER NOT NULL DEFAULT 1,
17
+ PRIMARY KEY(team_id,id), FOREIGN KEY(team_id,parent_id) REFERENCES agents(team_id,id),
18
+ FOREIGN KEY(team_id,main_session_id) REFERENCES sessions(team_id,id)
19
+ ) STRICT;
20
+ CREATE UNIQUE INDEX live_agent_names ON agents(team_id,COALESCE(parent_id,''),name) WHERE state!='ended';
21
+ ALTER TABLE teams ADD COLUMN root_agent_id TEXT REFERENCES principals(id);
22
+ CREATE TABLE principal_sponsors (
23
+ team_id TEXT NOT NULL REFERENCES teams(id), principal_id TEXT NOT NULL REFERENCES principals(id),
24
+ sponsor_id TEXT NOT NULL REFERENCES principals(id), set_by TEXT NOT NULL REFERENCES principals(id),
25
+ created_at INTEGER NOT NULL, PRIMARY KEY(team_id,principal_id)
26
+ ) STRICT;
27
+ CREATE TABLE agent_sessions (
28
+ team_id TEXT NOT NULL, session_id TEXT NOT NULL, agent_id TEXT,
29
+ starter_json TEXT NOT NULL CHECK(json_valid(starter_json)),
30
+ profile_json TEXT NOT NULL CHECK(json_valid(profile_json)),
31
+ resolved_at INTEGER NOT NULL, main INTEGER NOT NULL DEFAULT 0 CHECK(main IN (0,1)),
32
+ ended_at INTEGER, end_reason TEXT, revision INTEGER NOT NULL DEFAULT 1,
33
+ PRIMARY KEY(team_id,session_id),
34
+ FOREIGN KEY(team_id,session_id) REFERENCES sessions(team_id,id),
35
+ FOREIGN KEY(team_id,agent_id) REFERENCES agents(team_id,id)
36
+ ) STRICT;
37
+ CREATE TABLE session_profile_history (
38
+ team_id TEXT NOT NULL, session_id TEXT NOT NULL, revision INTEGER NOT NULL,
39
+ profile_json TEXT NOT NULL CHECK(json_valid(profile_json)), recorded_at INTEGER NOT NULL,
40
+ PRIMARY KEY(team_id,session_id,revision),
41
+ FOREIGN KEY(team_id,session_id) REFERENCES agent_sessions(team_id,session_id)
42
+ ) STRICT;
43
+ CREATE TABLE agent_credentials (
44
+ credential_id TEXT PRIMARY KEY REFERENCES credentials(id),
45
+ parent_credential_id TEXT NOT NULL REFERENCES credentials(id),
46
+ agent_id TEXT NOT NULL REFERENCES principals(id), team_id TEXT NOT NULL REFERENCES teams(id),
47
+ created_at INTEGER NOT NULL
48
+ ) STRICT;
49
+ CREATE UNIQUE INDEX one_agent_main ON agent_sessions(team_id,agent_id) WHERE main=1 AND ended_at IS NULL AND agent_id IS NOT NULL;
50
+ CREATE TABLE agent_session_closures (
51
+ team_id TEXT NOT NULL, id TEXT NOT NULL, agent_id TEXT NOT NULL, session_id TEXT NOT NULL,
52
+ machine_id TEXT NOT NULL, source_id TEXT NOT NULL, harness TEXT NOT NULL, native_id TEXT NOT NULL,
53
+ state TEXT NOT NULL DEFAULT 'pending' CHECK(state IN ('pending','claimed','closed','failed','unknown')),
54
+ reason TEXT NOT NULL, created_at INTEGER NOT NULL, result_json TEXT, claimed_at INTEGER, finished_at INTEGER,
55
+ PRIMARY KEY(team_id,id), UNIQUE(team_id,agent_id,machine_id,harness,native_id),
56
+ FOREIGN KEY(team_id,agent_id) REFERENCES agents(team_id,id),
57
+ FOREIGN KEY(team_id,machine_id) REFERENCES machines(team_id,id)
58
+ ) STRICT;
59
+ ${agentSessionEventTriggers()}
60
+ `;
@@ -0,0 +1,82 @@
1
+ // One role-and-binding shape (mirrored ontology C5): roles bundle actions; a binding grants a role or actions to a
2
+ // principal or a group on one resource. Session and machine grants are bindings; their old names are views.
3
+ export default `
4
+ CREATE TABLE roles (
5
+ team_id TEXT NOT NULL REFERENCES teams(id), name TEXT NOT NULL,
6
+ actions_json TEXT NOT NULL CHECK(json_valid(actions_json)), builtin INTEGER NOT NULL DEFAULT 0 CHECK(builtin IN (0,1)),
7
+ created_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(team_id,name)
8
+ ) STRICT;
9
+ INSERT INTO roles(team_id,name,actions_json,builtin)
10
+ SELECT t.id,r.name,r.actions,1 FROM teams t,(
11
+ SELECT 'owner' name,'["*"]' actions UNION ALL
12
+ SELECT 'admin','["*"]' UNION ALL
13
+ SELECT 'member','["teams.read","members.read","groups.read","sessions.read","sessions.write","machines.read","sources.read","events.read","agents.read","identity.read","identity.observe"]' UNION ALL
14
+ SELECT 'viewer','["teams.read","members.read","groups.read","sessions.read","machines.read","events.read","agents.read","identity.read"]' UNION ALL
15
+ SELECT 'reader','["sessions.read"]' UNION ALL
16
+ SELECT 'manager','["sessions.read","sessions.write","sessions.access"]'
17
+ ) r;
18
+ CREATE TRIGGER team_builtin_roles AFTER INSERT ON teams BEGIN
19
+ INSERT INTO roles(team_id,name,actions_json,builtin) VALUES
20
+ (NEW.id,'owner','["*"]',1),(NEW.id,'admin','["*"]',1),
21
+ (NEW.id,'member','["teams.read","members.read","groups.read","sessions.read","sessions.write","machines.read","sources.read","events.read","agents.read","identity.read","identity.observe"]',1),
22
+ (NEW.id,'viewer','["teams.read","members.read","groups.read","sessions.read","machines.read","events.read","agents.read","identity.read"]',1),
23
+ (NEW.id,'reader','["sessions.read"]',1),(NEW.id,'manager','["sessions.read","sessions.write","sessions.access"]',1);
24
+ END;
25
+ CREATE TABLE permission_bindings (
26
+ team_id TEXT NOT NULL REFERENCES teams(id), id TEXT NOT NULL,
27
+ holder_kind TEXT NOT NULL CHECK(holder_kind IN ('principal','group')), holder_id TEXT NOT NULL,
28
+ role TEXT, actions_json TEXT CHECK(actions_json IS NULL OR json_valid(actions_json)),
29
+ resource_kind TEXT NOT NULL, resource_id TEXT NOT NULL,
30
+ effect TEXT NOT NULL DEFAULT 'allow' CHECK(effect IN ('allow','deny')),
31
+ conditions_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(conditions_json)),
32
+ issuer_id TEXT, created_at INTEGER NOT NULL DEFAULT 0, expires_at INTEGER, revoked_at INTEGER,
33
+ revision INTEGER NOT NULL DEFAULT 1, app_installation_id TEXT,
34
+ CHECK(role IS NOT NULL OR actions_json IS NOT NULL),
35
+ PRIMARY KEY(team_id,id)
36
+ ) STRICT;
37
+ CREATE INDEX permission_bindings_resource ON permission_bindings(team_id,resource_kind,resource_id,holder_id,expires_at);
38
+ CREATE INDEX permission_bindings_holder ON permission_bindings(team_id,holder_id);
39
+ CREATE INDEX permission_bindings_installation ON permission_bindings(app_installation_id);
40
+ CREATE UNIQUE INDEX one_session_binding ON permission_bindings(team_id,resource_id,holder_kind,holder_id)
41
+ WHERE resource_kind='session' AND effect='allow' AND revoked_at IS NULL;
42
+ INSERT INTO permission_bindings(team_id,id,holder_kind,holder_id,role,actions_json,resource_kind,resource_id,conditions_json,issuer_id,created_at,expires_at,revision,app_installation_id)
43
+ SELECT g.team_id,g.id,CASE WHEN EXISTS(SELECT 1 FROM groups_table x WHERE x.team_id=g.team_id AND x.id=g.subject_id) THEN 'group' ELSE 'principal' END,
44
+ g.subject_id,NULL,g.caps_json,'machine',g.machine_id,json_object('targets',json(g.targets_json)),g.issuer_id,g.created_at,g.expires_at,g.revision,g.app_installation_id
45
+ FROM machine_grants g;
46
+ INSERT INTO permission_bindings(team_id,id,holder_kind,holder_id,role,resource_kind,resource_id,expires_at)
47
+ SELECT team_id,id,subject_kind,subject_id,role,'session',session_id,expires_at FROM session_grants;
48
+ DROP TABLE machine_grants;
49
+ DROP TABLE session_grants;
50
+ CREATE VIEW machine_grants AS
51
+ SELECT team_id,id,resource_id AS machine_id,holder_id AS subject_id,actions_json AS caps_json,
52
+ json_extract(conditions_json,'$.targets') AS targets_json,issuer_id,created_at,expires_at,revision,app_installation_id
53
+ FROM permission_bindings WHERE resource_kind='machine' AND effect='allow' AND revoked_at IS NULL;
54
+ CREATE TRIGGER machine_grants_insert INSTEAD OF INSERT ON machine_grants BEGIN
55
+ INSERT INTO permission_bindings(team_id,id,holder_kind,holder_id,actions_json,resource_kind,resource_id,conditions_json,issuer_id,created_at,expires_at,revision,app_installation_id)
56
+ VALUES (NEW.team_id,NEW.id,CASE WHEN EXISTS(SELECT 1 FROM groups_table x WHERE x.team_id=NEW.team_id AND x.id=NEW.subject_id) THEN 'group' ELSE 'principal' END,
57
+ NEW.subject_id,NEW.caps_json,'machine',NEW.machine_id,json_object('targets',json(NEW.targets_json)),NEW.issuer_id,COALESCE(NEW.created_at,0),NEW.expires_at,COALESCE(NEW.revision,1),NEW.app_installation_id);
58
+ END;
59
+ CREATE TRIGGER machine_grants_update INSTEAD OF UPDATE ON machine_grants BEGIN
60
+ UPDATE permission_bindings SET holder_id=NEW.subject_id,actions_json=NEW.caps_json,resource_id=NEW.machine_id,
61
+ conditions_json=json_object('targets',json(NEW.targets_json)),issuer_id=NEW.issuer_id,expires_at=NEW.expires_at,
62
+ revision=NEW.revision,app_installation_id=NEW.app_installation_id
63
+ WHERE team_id=OLD.team_id AND id=OLD.id;
64
+ END;
65
+ CREATE TRIGGER machine_grants_delete INSTEAD OF DELETE ON machine_grants BEGIN
66
+ DELETE FROM permission_bindings WHERE team_id=OLD.team_id AND id=OLD.id;
67
+ END;
68
+ CREATE VIEW session_grants AS
69
+ SELECT team_id,id,resource_id AS session_id,holder_kind AS subject_kind,holder_id AS subject_id,role,expires_at
70
+ FROM permission_bindings WHERE resource_kind='session' AND effect='allow' AND revoked_at IS NULL;
71
+ CREATE TRIGGER session_grants_insert INSTEAD OF INSERT ON session_grants BEGIN
72
+ INSERT INTO permission_bindings(team_id,id,holder_kind,holder_id,role,resource_kind,resource_id,expires_at)
73
+ VALUES (NEW.team_id,NEW.id,NEW.subject_kind,NEW.subject_id,NEW.role,'session',NEW.session_id,NEW.expires_at);
74
+ END;
75
+ CREATE TRIGGER session_grants_update INSTEAD OF UPDATE ON session_grants BEGIN
76
+ UPDATE permission_bindings SET holder_kind=NEW.subject_kind,holder_id=NEW.subject_id,role=NEW.role,resource_id=NEW.session_id,expires_at=NEW.expires_at
77
+ WHERE team_id=OLD.team_id AND id=OLD.id;
78
+ END;
79
+ CREATE TRIGGER session_grants_delete INSTEAD OF DELETE ON session_grants BEGIN
80
+ DELETE FROM permission_bindings WHERE team_id=OLD.team_id AND id=OLD.id;
81
+ END;
82
+ `;
@@ -0,0 +1,20 @@
1
+ // Standard org doors (mirrored ontology C6 and M6): a team admits the members of an identity issuer's group named in the
2
+ // sign-in token's `groups` claim; a team may take its membership from one installed app (the linked source), read-only
3
+ // otherwise; a removal the source asks for that the team cannot apply is kept as an alarm.
4
+ export default `
5
+ CREATE TABLE identity_group_links (
6
+ team_id TEXT NOT NULL REFERENCES teams(id), issuer TEXT NOT NULL, group_name TEXT NOT NULL,
7
+ linked_by TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(team_id,issuer,group_name)
8
+ ) STRICT;
9
+ CREATE TABLE membership_sources (
10
+ team_id TEXT PRIMARY KEY REFERENCES teams(id), installation_id TEXT NOT NULL, linked_by TEXT NOT NULL, linked_at INTEGER NOT NULL
11
+ ) STRICT;
12
+ CREATE TABLE group_admissions (
13
+ team_id TEXT NOT NULL REFERENCES teams(id), principal_id TEXT NOT NULL, issuer TEXT NOT NULL, admitted_at INTEGER NOT NULL,
14
+ PRIMARY KEY(team_id,principal_id)
15
+ ) STRICT;
16
+ CREATE TABLE membership_alarms (
17
+ team_id TEXT NOT NULL REFERENCES teams(id), id TEXT NOT NULL, principal_id TEXT, issuer TEXT NOT NULL, subject TEXT NOT NULL,
18
+ reason TEXT NOT NULL, created_at INTEGER NOT NULL, cleared_at INTEGER, PRIMARY KEY(team_id,id)
19
+ ) STRICT;
20
+ `;