@volter/teams 0.1.83 → 0.1.85

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.
@@ -59,7 +59,7 @@ export declare class TurnDeliveryUnconfirmedError extends Error {
59
59
  * does not undo delivery. After acceptance, a lost, invalid or cancelled receipt
60
60
  * rejects with TurnDeliveryUnconfirmedError. Reuse the same key when retrying it.
61
61
  */
62
- export function sendBrowserTurn(channel: BrowserChannel, text: string, options?: { turnKey?: string; signal?: AbortSignal }): Promise<BrowserTurnAnswer>;
62
+ export function sendBrowserTurn(channel: BrowserChannel, text: string, options?: { turnKey?: string; target?: { harness: string; session: string }; signal?: AbortSignal }): Promise<BrowserTurnAnswer>;
63
63
 
64
64
  export interface MailHistoryEntry {
65
65
  id: string; from: string; fromName: string; to: string; text: string; at: number;
@@ -178,12 +178,15 @@ export class TurnDeliveryUnconfirmedError extends Error {
178
178
  * is free), or `{ code, text }` when it was not delivered; rejects when the machine refuses the stream.
179
179
  * `turnKey`: one key per line the person typed, reused when they retry it, so a line the machine took after its
180
180
  * first send was given up on is not typed twice (a machine of door vocabulary 3 or later keeps each key once).
181
+ * `target`: on a machine channel, the session that takes the line (`{ harness, session }`, the session's native
182
+ * id); a session channel's grant names its session, and needs none.
181
183
  * Receipt waits are bounded to thirty seconds and 256 KB. `signal` cancels the
182
184
  * wait; neither cancellation nor timeout undoes a turn already accepted.
183
185
  */
184
- export async function sendBrowserTurn(channel, text, { turnKey, signal } = {}) {
186
+ export async function sendBrowserTurn(channel, text, { turnKey, target, signal } = {}) {
185
187
  signal?.throwIfAborted();
186
- const stream = await channel.open('mail', { request: { op: 'turn', text, ...(turnKey ? { turn_key: String(turnKey) } : {}) } });
188
+ const named = target ? { harness: String(target.harness), session: String(target.session) } : {};
189
+ const stream = await channel.open('mail', { request: { op: 'turn', text, ...named, ...(turnKey ? { turn_key: String(turnKey) } : {}) } });
187
190
  return new Promise((resolve, reject) => {
188
191
  let answer = '', bytes = 0, settled = false;
189
192
  const cleanup = () => { clearTimeout(timer); stream.off('data', data); stream.off('close', closed); signal?.removeEventListener('abort', cancelled); };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/teams",
3
- "version": "0.1.83",
3
+ "version": "0.1.85",
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/apps.mjs CHANGED
@@ -1,10 +1,9 @@
1
1
  // Consent, OAuth-style one-time exchange, attenuation and durable Standard Webhooks.
2
2
  // This module imports no product adapter. Applications keep their configuration/id maps.
3
3
  import { randomBytes, createHash, createCipheriv, createDecipheriv, createHmac } from 'node:crypto';
4
- import { hashToken, makeToken, isAdmin } from './auth.mjs';
4
+ import { hashToken, makeToken, isAdmin, canRead, canManage, appGrantAllows, APP_LINEAGE_REVOKED } from './auth.mjs';
5
5
  import { exact } from './request-json.mjs';
6
- import { closeChannelsFor, closeDelegatedChannels, closeMachineChannels } from './machine-link.mjs';
7
- import { delegatedGrantIssuer } from './publications.mjs';
6
+ import { closeAppChannels, closeMachineChannels } from './machine-link.mjs';
8
7
 
9
8
  export const APP_ACTIONS = Object.freeze([
10
9
  'teams.read', 'members.read', 'members.write', 'groups.read', 'groups.write',
@@ -57,6 +56,15 @@ const admin = auth => {
57
56
  function adminGuards(auth, now) {
58
57
  return [guard('SELECT 1 FROM credentials WHERE id=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)', [auth.credential_id, now]), guard("SELECT 1 FROM memberships WHERE team_id=? AND principal_id=? AND active=1 AND role IN ('owner','admin')", [auth.team_id, auth.principal_id])];
59
58
  }
59
+ function sessionAuthorityGuard(source,teamId,id,manage,now){
60
+ return guard(`SELECT 1 FROM sessions s JOIN memberships m ON m.team_id=s.team_id AND m.principal_id=?
61
+ WHERE s.team_id=? AND s.id=? AND s.deleted_at IS NULL AND m.active=1 AND (?=0 OR m.role!='viewer')
62
+ AND ((?=0 AND s.visibility='team') OR s.custodian_id=? OR EXISTS (SELECT 1 FROM session_grants g
63
+ WHERE g.team_id=s.team_id AND g.session_id=s.id AND (?=0 OR g.role='manager') AND (g.expires_at IS NULL OR g.expires_at>?)
64
+ AND ((g.subject_kind='principal' AND g.subject_id=?) OR (g.subject_kind='group' AND EXISTS
65
+ (SELECT 1 FROM group_members gm WHERE gm.team_id=s.team_id AND gm.principal_id=? AND gm.group_id=g.subject_id)))))`,
66
+ [source.principal_id,teamId,id,manage?1:0,manage?1:0,source.principal_id,manage?1:0,now,source.principal_id,source.principal_id]);
67
+ }
60
68
 
61
69
  /** Restrict every ordinary door, including replay, before the existing resource evaluator. Unknown doors fail closed. */
62
70
  export function appRouteAction(method, sub) {
@@ -76,18 +84,33 @@ export function enforceAppScope(auth, method, sub) {
76
84
  if (!auth?.installation_id) return;
77
85
  const action = appRouteAction(method, sub);
78
86
  if (!action || !auth.app_actions.includes(action)) error(403, 'forbidden', 'this operation is outside the consented app token scopes');
87
+ if(auth.app_grants!=null){
88
+ auth.app_operation_action=action;
89
+ const session=/^\/sessions\/([^/]+)/.exec(sub);
90
+ // Collections filter through the same fence; other resource families have no passed grant.
91
+ if(action==='teams.read')return;
92
+ if(!action.startsWith('sessions.')||(!session&&!['/sessions','/sessions/search','/sessions/events'].includes(sub)))error(403,'forbidden','this operation is outside the delegated resource grants');
93
+ if(session&&!['search','events'].includes(session[1])&&!appGrantAllows(auth,'session',decodeURIComponent(session[1]),action))error(404,'not_found','session not found');
94
+ }
79
95
  }
80
96
  /** Atomic mutation rechecks; parent credentials cannot survive revocation through a child. */
81
97
  export function appAuthGuards(auth, now) {
82
98
  const installation=auth?.installation_id??auth?.identity_installation_id;if(!installation)return [];
83
99
  const checks=[guard(`SELECT 1 FROM app_installations i JOIN apps a ON a.id=i.app_id JOIN memberships m ON m.team_id=i.team_id AND m.principal_id=i.principal_id JOIN memberships installer ON installer.team_id=i.team_id AND installer.principal_id=i.installer_id
84
100
  WHERE i.id=? AND i.team_id=? AND i.state='active' AND a.state='active' AND m.active=1 AND installer.active=1 AND installer.role IN ('owner','admin')`, [installation, auth.team_id]),
85
- guard(`WITH RECURSIVE lineage(id,parent_id) AS (SELECT credential_id,parent_id FROM app_credentials WHERE credential_id=? UNION ALL SELECT ac.credential_id,ac.parent_id FROM app_credentials ac JOIN lineage l ON ac.credential_id=l.parent_id)
86
- SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM lineage l JOIN credentials c ON c.id=l.id WHERE c.revoked_at IS NOT NULL OR (c.expires_at IS NOT NULL AND c.expires_at<=?))`, [auth.credential_id, now])];
101
+ guard(`SELECT 1 WHERE NOT EXISTS (${APP_LINEAGE_REVOKED})`, [auth.credential_id, now])];
102
+ if(auth.app_grants!=null){
103
+ checks.push(guard('SELECT 1 FROM teams WHERE id=? AND auth_epoch=?',[auth.team_id,auth.app_auth_epoch]),...appAuthGuards(auth.app_grant_source,now));
104
+ for(const grant of auth.app_grants){
105
+ const source=auth.app_grant_source,manage=grant.actions.some(a=>a!=='sessions.read');
106
+ checks.push(sessionAuthorityGuard(source,auth.team_id,grant.id,manage,now));
107
+ }
108
+ }
87
109
  return checks;
88
110
  }
89
- export async function appAuthentication(api, auth) {
111
+ export async function appAuthentication(api, auth, depth=0) {
90
112
  if (!auth) return null;
113
+ if(depth>16)return null;
91
114
  const row = await api.store.one(`SELECT ac.*,i.team_id,i.principal_id,i.role,i.state,a.state app_state,installer.active installer_active,installer.role installer_role
92
115
  FROM app_credentials ac JOIN app_installations i ON i.id=ac.installation_id JOIN apps a ON a.id=i.app_id JOIN memberships installer ON installer.team_id=i.team_id AND installer.principal_id=i.installer_id WHERE ac.credential_id=?`, [auth.credential_id]);
93
116
  if (!row) {
@@ -95,12 +118,34 @@ export async function appAuthentication(api, auth) {
95
118
  if(identity){if(identity.state!=='active'||identity.app_state!=='active'||!identity.active||!['owner','admin'].includes(identity.role))return null;auth.identity_installation_id=identity.id;}
96
119
  return auth;
97
120
  }
98
- if (!auth.active || row.state !== 'active' || row.app_state !== 'active' || !row.installer_active || !['owner','admin'].includes(row.installer_role) || row.team_id !== auth.team_id || row.principal_id !== auth.principal_id) return null;
99
- const revoked = await api.store.one(`WITH RECURSIVE lineage(id,parent_id) AS (SELECT credential_id,parent_id FROM app_credentials WHERE credential_id=? UNION ALL SELECT ac.credential_id,ac.parent_id FROM app_credentials ac JOIN lineage l ON ac.credential_id=l.parent_id)
100
- SELECT 1 FROM lineage l JOIN credentials c ON c.id=l.id WHERE c.revoked_at IS NOT NULL OR (c.expires_at IS NOT NULL AND c.expires_at<=?) LIMIT 1`, [auth.credential_id, api.clock.nowMs()]);
121
+ if (!auth.active || row.state !== 'active' || row.app_state !== 'active' || !row.installer_active || !['owner','admin'].includes(row.installer_role) || row.team_id !== auth.team_id || (row.subject_principal_id??row.principal_id) !== auth.principal_id) return null;
122
+ const revoked = await api.store.one(APP_LINEAGE_REVOKED, [auth.credential_id, api.clock.nowMs()]);
101
123
  if (revoked) return null;
102
124
  const ranks=['viewer','member','admin','owner'];auth.role=ranks[Math.min(ranks.indexOf(auth.role),ranks.indexOf(row.role))];
103
- return Object.assign(auth, { installation_id: row.installation_id, app_actions: parse(row.actions_json), act: parse(row.actor_json) });
125
+ Object.assign(auth, { installation_id: row.installation_id, app_actions: parse(row.actions_json), act: parse(row.actor_json), app_grants:parse(row.grants_json) });
126
+ if(auth.app_grants!==null){
127
+ const epoch=await api.store.one('SELECT auth_epoch FROM teams WHERE id=?',[auth.team_id]);auth.app_auth_epoch=epoch.auth_epoch;
128
+ const source=await credentialAuthority(api,row.subject_credential_id??row.parent_id,auth.team_id,depth+1);
129
+ if(!source)return null;
130
+ auth.app_grant_source=source;
131
+ const held=[];
132
+ for(const grant of auth.app_grants){
133
+ try{await api.store.authorizedSession({teamId:auth.team_id,principalId:source.principal_id,credentialId:source.credential_id,ref:grant.id,now:api.clock.nowMs()});}catch{continue;}
134
+ const session=await api.store.one('SELECT * FROM sessions WHERE team_id=? AND id=?',[auth.team_id,grant.id]);
135
+ if(!session||session.deleted_at!=null)continue;
136
+ const grants=await api.store.query('SELECT * FROM session_grants WHERE team_id=? AND session_id=?',[auth.team_id,grant.id]);
137
+ held.push({...grant,actions:grant.actions.filter(action=>appGrantAllows(source,'session',grant.id,action)&&(action==='sessions.read'||canManage(source,session,grants)))});
138
+ }
139
+ auth.app_grants=held;
140
+ }
141
+ return auth;
142
+ }
143
+ async function credentialAuthority(api,id,teamId,depth=0){
144
+ const now=api.clock.nowMs();
145
+ const auth=await api.store.one(`SELECT c.id credential_id,c.kind,c.principal_id,c.expires_at,m.team_id,m.role,m.active FROM credentials c JOIN memberships m ON m.principal_id=c.principal_id AND m.team_id=? WHERE c.id=? AND c.kind IN ('user','service') AND (c.team_id IS NULL OR c.team_id=?) AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>?)`,[teamId,id,teamId,now]);
146
+ if(!auth?.active)return null;
147
+ auth.now=now;auth.group_ids=(await api.store.query('SELECT group_id FROM group_members WHERE team_id=? AND principal_id=?',[teamId,auth.principal_id])).map(r=>r.group_id);
148
+ return appAuthentication(api,auth,depth);
104
149
  }
105
150
  /** Exact issuer trust is an active, consented installation, scoped to its organization. */
106
151
  export async function installedIssuer(api, issuer, action, teamId = null) {
@@ -108,6 +153,12 @@ export async function installedIssuer(api, issuer, action, teamId = null) {
108
153
  WHERE a.issuer=? AND a.state='active' AND i.state='active' AND m.active=1 AND installer.active=1 AND installer.role IN ('owner','admin') AND (? IS NULL OR i.team_id=?) AND EXISTS (SELECT 1 FROM json_each(i.actions_json) WHERE value=?) LIMIT 1`, [issuer, teamId, teamId, action]);
109
154
  }
110
155
 
156
+ /** Every issuer some team trusts for sign-in (installedIssuer's trust, any team): what a client may offer. */
157
+ export async function identityIssuers(api) {
158
+ return (await api.store.query(`SELECT DISTINCT a.issuer FROM app_installations i JOIN apps a ON a.id=i.app_id JOIN memberships m ON m.team_id=i.team_id AND m.principal_id=i.principal_id JOIN memberships installer ON installer.team_id=i.team_id AND installer.principal_id=i.installer_id
159
+ WHERE a.issuer IS NOT NULL AND a.state='active' AND i.state='active' AND m.active=1 AND installer.active=1 AND installer.role IN ('owner','admin') AND EXISTS (SELECT 1 FROM json_each(i.actions_json) WHERE value='identity.login') ORDER BY a.issuer`, [])).map(row => row.issuer);
160
+ }
161
+
111
162
  /** Fan-out is durable and transactionally committed with the source mutation. C2 adds resource events. */
112
163
  export async function appEventStatements(api, teamId, kind, data, addedDestination = null) {
113
164
  const now = api.clock.nowMs(), eventId = api.ids.next('event');
@@ -145,15 +196,49 @@ export async function appRoute(ctx) {
145
196
  const delegation = /^\/app-installations\/([^/]+)\/delegations$/.exec(sub);
146
197
  if (delegation && req.method === 'POST') {
147
198
  if (auth.installation_id !== delegation[1]) error(403, 'forbidden', 'only this installation may delegate its own token');
148
- const body = await parseJson(req); exact(body, ['actions','actor','expires_in_ms']);
199
+ const body = await parseJson(req); exact(body, ['actions','actor','expires_in_ms','principal_id','grants','subject_token']);
149
200
  const actions = list(body.actions, 'actions', auth.app_actions);
150
201
  const actor = text(body.actor, 'actor');
151
202
  const duration = body.expires_in_ms ?? DELEGATION_MS;
152
203
  if (!Number.isSafeInteger(duration) || duration < 1000 || duration > DELEGATION_MS || auth.act.length >= 16) error(400, 'invalid_request', 'delegation is limited to fifteen minutes and sixteen actors');
153
204
  const parent = await api.store.one('SELECT expires_at FROM credentials WHERE id=?', [auth.credential_id]);
154
- const now = api.clock.nowMs(), expiresAt = Math.min(now + duration, parent.expires_at ?? Infinity), cid = api.ids.next('credential'), token = makeToken(cid), act = [...auth.act, actor];
155
- const response = result(api, auth, { token, kind: 'service', expires_at: expiresAt, actions, subject: auth.principal_id, act });
156
- await guardedMutation(api, res, auth, appAuthGuards(auth, now), [statement('INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,expires_at,created_at) VALUES (?,?,?,\'service\',?,?,?)', cid, hashToken(token), auth.principal_id, auth.team_id, expiresAt, now), statement('INSERT INTO app_credentials(credential_id,installation_id,parent_id,actions_json,actor_json) VALUES (?,?,?,?,?)', cid, auth.installation_id, auth.credential_id, JSON.stringify(actions), JSON.stringify(act))], { status: 201, body: response, targetId: cid });
205
+ const now=api.clock.nowMs(),epoch=await api.store.one('SELECT auth_epoch FROM teams WHERE id=?',[auth.team_id]);
206
+ const subject=body.principal_id==null?auth.principal_id:text(body.principal_id,'principal_id');
207
+ let grants=body.grants===undefined?(auth.app_grants?.map(g=>({...g,actions:g.actions.filter(a=>actions.includes(a))}))??null):body.grants,source=auth,sourceId=null;
208
+ if(auth.app_grants!=null&&grants===null)error(403,'forbidden','a descendant cannot remove its resource fence');
209
+ if(body.subject_token!=null){
210
+ if(body.principal_id==null)error(400,'invalid_request','subject_token requires principal_id');
211
+ text(body.subject_token,'subject_token',4096);
212
+ const credential=await api.store.one('SELECT id FROM credentials WHERE token_hash=?',[hashToken(body.subject_token)]);
213
+ source=credential?await credentialAuthority(api,credential.id,auth.team_id):null;
214
+ if(!source||source.principal_id!==subject)error(403,'forbidden','subject_token must prove the named active principal in this organization');
215
+ sourceId=source.credential_id;
216
+ }
217
+ if(body.principal_id!=null&&(!Array.isArray(grants)||!grants.length))error(400,'invalid_request','an existing principal requires explicit resource grants');
218
+ const targetMembership=await api.store.one('SELECT role,active FROM memberships WHERE team_id=? AND principal_id=?',[auth.team_id,subject]);
219
+ if(!targetMembership?.active)error(404,'not_found','principal not found');
220
+ const target={kind:'service',principal_id:subject,active:1,role:targetMembership.role,now,group_ids:(await api.store.query('SELECT group_id FROM group_members WHERE team_id=? AND principal_id=?',[auth.team_id,subject])).map(r=>r.group_id)};
221
+ if(grants!==null){
222
+ if(!Array.isArray(grants)||grants.length>100||actions.some(a=>a!=='teams.read'&&!['sessions.read','sessions.write','sessions.access'].includes(a)))error(400,'invalid_request','resource delegation accepts at most 100 session grants and session/Teams metadata actions');
223
+ const seen=new Set();
224
+ for(const g of grants){
225
+ exact(g,['kind','id','actions']);text(g.id,'grant.id');
226
+ if(g.kind!=='session'||seen.has(g.id))error(400,'invalid_request','grant must name a distinct local session');seen.add(g.id);
227
+ list(g.actions,'grant.actions',actions);
228
+ if(!g.actions.includes('sessions.read')||g.actions.some(a=>!['sessions.read','sessions.write','sessions.access'].includes(a)))error(400,'invalid_request','a session grant requires sessions.read and may add write/access');
229
+ for(const action of g.actions)if(!appGrantAllows(auth,'session',g.id,action)||!appGrantAllows(source,'session',g.id,action)||(source.app_actions&&!source.app_actions.includes(action)))error(403,'forbidden','delegation cannot widen its parent or subject grants');
230
+ const session=await api.store.one('SELECT * FROM sessions WHERE team_id=? AND id=? AND deleted_at IS NULL',[auth.team_id,g.id]);
231
+ if(!session)error(404,'not_found','session not found');
232
+ await api.store.authorizedSession({teamId:auth.team_id,principalId:source.principal_id,credentialId:source.credential_id,ref:g.id,now});
233
+ const acl=await api.store.query('SELECT * FROM session_grants WHERE team_id=? AND session_id=?',[auth.team_id,g.id]);
234
+ if(!canRead(target,session,acl)||g.actions.some(a=>a!=='sessions.read'&&(!canManage(source,session,acl)||!canManage(target,session,acl))))error(403,'forbidden','grants must be held by both the source and the represented principal');
235
+ }
236
+ }
237
+ const expiresAt=Math.min(now+duration,parent.expires_at??Infinity,source.expires_at??Infinity),cid=api.ids.next('credential'),token=makeToken(cid),act=[...auth.act,actor];
238
+ const response=result(api,auth,{token,kind:'service',expires_at:expiresAt,actions,subject,act,grants});
239
+ const boundaryNow=api.clock.nowMs(),checks=[...appAuthGuards(auth,boundaryNow),...appAuthGuards(source,boundaryNow),guard('SELECT 1 FROM teams WHERE id=? AND auth_epoch=?',[auth.team_id,epoch.auth_epoch]),guard('SELECT 1 FROM memberships WHERE team_id=? AND principal_id=? AND active=1 AND role=?',[auth.team_id,subject,targetMembership.role]),guard('SELECT 1 FROM credentials WHERE id=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)',[source.credential_id,boundaryNow])];
240
+ for(const grant of grants??[]){const manage=grant.actions.some(a=>a!=='sessions.read');checks.push(sessionAuthorityGuard(source,auth.team_id,grant.id,manage,boundaryNow),sessionAuthorityGuard(target,auth.team_id,grant.id,manage,boundaryNow));}
241
+ await guardedMutation(api,res,auth,checks,[statement('INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,expires_at,created_at) VALUES (?,?,?,\'service\',?,?,?)',cid,hashToken(token),subject,auth.team_id,expiresAt,now),statement('INSERT INTO app_credentials(credential_id,installation_id,parent_id,actions_json,actor_json,subject_principal_id,subject_credential_id,grants_json) VALUES (?,?,?,?,?,?,?,?)',cid,auth.installation_id,auth.credential_id,JSON.stringify(actions),JSON.stringify(act),subject,sourceId,JSON.stringify(grants))],{status:201,body:response,targetId:cid});
157
242
  send(res, 201, response, requestId); return true;
158
243
  }
159
244
  admin(auth);
@@ -165,10 +250,30 @@ export async function appRoute(ctx) {
165
250
  }
166
251
  let match = /^\/apps\/([^/]+)$/.exec(sub);
167
252
  if (match && req.method === 'GET') {
168
- const app = await api.store.one('SELECT * FROM apps WHERE id=?', [match[1]]);
253
+ const app = await api.store.one("SELECT * FROM apps WHERE id=? AND state='active'", [match[1]]);
169
254
  if (!app) error(404, 'not_found', 'app not found');
170
255
  send(res, 200, result(api, auth, appJson(app)), requestId); return true;
171
256
  }
257
+ if(match&&req.method==='DELETE'){
258
+ const app=await api.store.one("SELECT * FROM apps WHERE id=? AND team_id=? AND state='active'",[match[1],auth.team_id]);
259
+ if(!app)error(404,'not_found','app not found');
260
+ const now=api.clock.nowMs();
261
+ const grants=await api.store.query('SELECT g.id,g.team_id,g.machine_id FROM machine_grants g JOIN app_installations i ON i.id=g.app_installation_id WHERE i.app_id=?',[app.id]);
262
+ await guardedMutation(api,res,auth,[...adminGuards(auth,now),guard("SELECT 1 FROM apps WHERE id=? AND team_id=? AND state='active'",[app.id,auth.team_id]),guard('SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM machine_grants g JOIN app_installations i ON i.id=g.app_installation_id WHERE i.app_id=? AND g.id NOT IN (SELECT value FROM json_each(?)))',[app.id,JSON.stringify(grants.map(g=>g.id))])],[
263
+ statement("UPDATE apps SET state='retired',secret_hash='' WHERE id=?",app.id),
264
+ statement("UPDATE app_installations SET state='uninstalled',uninstalled_at=?,signing_secret='' WHERE app_id=?",now,app.id),
265
+ statement('UPDATE credentials SET revoked_at=? WHERE id IN (SELECT ac.credential_id FROM app_credentials ac JOIN app_installations i ON i.id=ac.installation_id WHERE i.app_id=? UNION SELECT c.credential_id FROM identity_app_credentials c JOIN app_installations i ON i.id=c.installation_id WHERE i.app_id=?)',now,app.id,app.id),
266
+ statement('UPDATE memberships SET active=0,revision=revision+1 WHERE (team_id,principal_id) IN (SELECT team_id,principal_id FROM app_installations WHERE app_id=?)',app.id),
267
+ statement('DELETE FROM app_install_codes WHERE installation_id IN (SELECT id FROM app_installations WHERE app_id=?)',app.id),
268
+ statement('DELETE FROM machine_grants WHERE app_installation_id IN (SELECT id FROM app_installations WHERE app_id=?)',app.id),
269
+ statement("UPDATE app_webhook_deliveries SET state='cancelled',lease_until=NULL WHERE state='pending' AND installation_id IN (SELECT id FROM app_installations WHERE app_id=?)",app.id),
270
+ statement('UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id IN (SELECT team_id FROM app_installations WHERE app_id=?)',app.id),
271
+ ],{status:204,body:null,targetId:app.id});
272
+ const installs=await api.store.query('SELECT * FROM app_installations WHERE app_id=?',[app.id]);
273
+ for(const install of installs){closeAppChannels(api,install.id,'app_deregistered');api.events.publish(install.team_id,{type:'authorization_changed',installation_id:install.id});}
274
+ for(const grant of grants)closeMachineChannels(api,grant.team_id,grant.machine_id,{grantId:grant.id,reason:'app_deregistered'});
275
+ send(res,204,null,requestId);return true;
276
+ }
172
277
  if (req.method === 'POST' && sub === '/apps') {
173
278
  const body = await parseJson(req); exact(body, ['name','description','actions','event_kinds','redirect_urls','webhook_url','issuer']);
174
279
  const name = text(body.name, 'name', 80), description = body.description ?? '';
@@ -221,17 +326,11 @@ export async function appRoute(ctx) {
221
326
  if (!install) error(404, 'not_found', 'installation not found');
222
327
  if (req.method === 'GET') { send(res, 200, result(api, auth, installJson(install)), requestId); return true; }
223
328
  const now = api.clock.nowMs();
224
- const app=await api.store.one('SELECT issuer FROM apps WHERE id=?',[install.app_id]);
225
- const sessionPrefix=app.issuer?`delegated:${new URL(app.issuer).host}:session:`:null;
226
- const delegatedGrants=sessionPrefix?await api.store.query('SELECT id,machine_id FROM machine_grants WHERE team_id=? AND substr(issuer_id,1,?)=?',[auth.team_id,sessionPrefix.length,sessionPrefix]):[];
227
- await guardedMutation(api, res, auth, adminGuards(auth, now), [statement("UPDATE app_installations SET state='uninstalled',uninstalled_at=? WHERE id=?", now, install.id), statement('UPDATE credentials SET revoked_at=? WHERE id IN (SELECT credential_id FROM app_credentials WHERE installation_id=? UNION SELECT credential_id FROM identity_app_credentials WHERE installation_id=?)', now, install.id, install.id), statement('UPDATE memberships SET active=0,revision=revision+1 WHERE team_id=? AND principal_id=?', auth.team_id, install.principal_id), statement('DELETE FROM app_install_codes WHERE installation_id=?', install.id), ...(sessionPrefix?[statement('DELETE FROM machine_grants WHERE team_id=? AND substr(issuer_id,1,?)=?',auth.team_id,sessionPrefix.length,sessionPrefix)]:[]), statement("UPDATE app_webhook_deliveries SET state='cancelled' WHERE installation_id=? AND state='pending'", install.id), statement('UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?', auth.team_id), ...await appEventStatements(api, auth.team_id, 'app.uninstalled', installJson({ ...install, state: 'uninstalled', uninstalled_at: now }))], { status: 204, body: null, targetId: install.id });
228
- closeChannelsFor(api, auth.team_id, { principalId: install.principal_id, reason: 'app_uninstalled' });
229
- api.events.publish(auth.team_id, { type: 'authorization_changed', principal_id: install.principal_id });
230
- for(const grant of delegatedGrants)closeMachineChannels(api,auth.team_id,grant.machine_id,{grantId:grant.id,reason:'app_uninstalled'});
231
- if (app.issuer) {
232
- const publications = await api.store.query('SELECT id,machine_id FROM machine_publications WHERE team_id=? AND workplace=?', [auth.team_id, app.issuer]);
233
- for (const publication of publications) closeDelegatedChannels(api, auth.team_id, publication.machine_id, delegatedGrantIssuer(app.issuer, publication.id), 'app_uninstalled');
234
- }
329
+ const grants=await api.store.query('SELECT id,machine_id FROM machine_grants WHERE app_installation_id=?',[install.id]);
330
+ await guardedMutation(api, res, auth, [...adminGuards(auth, now),guard('SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM machine_grants WHERE app_installation_id=? AND id NOT IN (SELECT value FROM json_each(?)))',[install.id,JSON.stringify(grants.map(g=>g.id))])], [statement("UPDATE app_installations SET state='uninstalled',uninstalled_at=?,signing_secret='' WHERE id=?", now, install.id), statement('UPDATE credentials SET revoked_at=? WHERE id IN (SELECT credential_id FROM app_credentials WHERE installation_id=? UNION SELECT credential_id FROM identity_app_credentials WHERE installation_id=?)', now, install.id, install.id), statement('UPDATE memberships SET active=0,revision=revision+1 WHERE team_id=? AND principal_id=?', auth.team_id, install.principal_id), statement('DELETE FROM app_install_codes WHERE installation_id=?', install.id), statement('DELETE FROM machine_grants WHERE app_installation_id=?',install.id), statement("UPDATE app_webhook_deliveries SET state='cancelled',lease_until=NULL WHERE installation_id=? AND state='pending'", install.id), statement('UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?', auth.team_id), ...await appEventStatements(api, auth.team_id, 'app.uninstalled', installJson({ ...install, state: 'uninstalled', uninstalled_at: now }))], { status: 204, body: null, targetId: install.id });
331
+ closeAppChannels(api,install.id,'app_uninstalled');
332
+ for(const grant of grants)closeMachineChannels(api,auth.team_id,grant.machine_id,{grantId:grant.id,reason:'app_uninstalled'});
333
+ api.events.publish(auth.team_id, { type: 'authorization_changed', installation_id:install.id });
235
334
  send(res, 204, null, requestId); return true;
236
335
  }
237
336
  error(404, 'not_found', 'app operation not found');
package/server/auth.mjs CHANGED
@@ -3,11 +3,34 @@ export const hashToken = token => createHash('sha256').update(token).digest('hex
3
3
  export function makeToken(credentialId, bytes = randomBytes(32)) { return `sct_${credentialId}.${Buffer.from(bytes).toString('base64url')}`; }
4
4
  export function tokenMatches(token, digest) { const actual = Buffer.from(hashToken(token)); const expected = Buffer.from(String(digest)); return actual.length === expected.length && timingSafeEqual(actual, expected); }
5
5
  export function bearer(header) { const match = /^Bearer ([^\s]+)$/.exec(header ?? ''); return match?.[1] ?? null; }
6
+ /** An absent fence is a root app token; [] grants nothing. Never infer authority from a named actor. */
7
+ export function appGrantAllows(auth, kind, id, action) {
8
+ return auth?.app_grants == null || auth.app_grants.some(g => g.kind === kind && g.id === id && g.actions.includes(action));
9
+ }
10
+ /** Both app parent and the credential that passed a represented principal's grants stay live. */
11
+ export const APP_LINEAGE = `WITH RECURSIVE lineage(id) AS (
12
+ SELECT ? UNION SELECT ac.parent_id FROM app_credentials ac JOIN lineage l ON ac.credential_id=l.id WHERE ac.parent_id IS NOT NULL
13
+ UNION SELECT ac.subject_credential_id FROM app_credentials ac JOIN lineage l ON ac.credential_id=l.id WHERE ac.subject_credential_id IS NOT NULL
14
+ )`;
15
+ export const APP_LINEAGE_REVOKED = `${APP_LINEAGE}
16
+ SELECT 1 FROM lineage l JOIN credentials c ON c.id=l.id
17
+ LEFT JOIN memberships m ON m.team_id=c.team_id AND m.principal_id=c.principal_id
18
+ LEFT JOIN app_credentials ac ON ac.credential_id=c.id
19
+ LEFT JOIN identity_app_credentials ic ON ic.credential_id=c.id
20
+ LEFT JOIN app_installations i ON i.id=COALESCE(ac.installation_id,ic.installation_id)
21
+ LEFT JOIN apps a ON a.id=i.app_id
22
+ LEFT JOIN memberships installer ON installer.team_id=i.team_id AND installer.principal_id=i.installer_id
23
+ LEFT JOIN memberships service ON service.team_id=i.team_id AND service.principal_id=i.principal_id
24
+ WHERE c.revoked_at IS NOT NULL OR (c.expires_at IS NOT NULL AND c.expires_at<=?)
25
+ OR (c.team_id IS NOT NULL AND COALESCE(m.active,0)!=1)
26
+ OR (i.id IS NOT NULL AND (i.state!='active' OR a.state!='active' OR COALESCE(installer.active,0)!=1
27
+ OR installer.role NOT IN ('owner','admin') OR COALESCE(service.active,0)!=1)) LIMIT 1`;
6
28
  export function canRead(auth, session, grants = []) {
7
29
  if (!auth || !['user','service'].includes(auth.kind) || !auth.active || session.deleted_at != null) return false;
30
+ if (!appGrantAllows(auth, 'session', session.id, 'sessions.read')) return false;
8
31
  if (session.visibility === 'team' || session.custodian_id === auth.principal_id) return true;
9
32
  const now = auth.now;
10
33
  return grants.some(g => (g.subject_id === auth.principal_id || auth.group_ids?.includes(g.subject_id)) && (g.expires_at == null || g.expires_at > now));
11
34
  }
12
- export function canManage(auth, session, grants = []) { if (!auth?.active || !['user','service'].includes(auth.kind) || auth.role === 'viewer') return false; return session.custodian_id === auth.principal_id || grants.some(g => g.role === 'manager' && (g.subject_id === auth.principal_id || auth.group_ids?.includes(g.subject_id)) && (g.expires_at == null || g.expires_at > auth.now)); }
13
- export const isAdmin = auth => auth?.active && ['user','service'].includes(auth.kind) && (auth.role === 'owner' || auth.role === 'admin');
35
+ export function canManage(auth, session, grants = []) { if (!auth?.active || !['user','service'].includes(auth.kind) || auth.role === 'viewer' || (auth.app_grants != null && !auth.app_grants.some(g=>g.kind==='session'&&g.id===session.id&&(auth.app_operation_action?g.actions.includes(auth.app_operation_action):g.actions.some(a=>a==='sessions.write'||a==='sessions.access'))))) return false; return session.custodian_id === auth.principal_id || grants.some(g => g.role === 'manager' && (g.subject_id === auth.principal_id || auth.group_ids?.includes(g.subject_id)) && (g.expires_at == null || g.expires_at > auth.now)); }
36
+ export const isAdmin = auth => auth?.active && auth.app_grants == null && ['user','service'].includes(auth.kind) && (auth.role === 'owner' || auth.role === 'admin');
@@ -82,7 +82,7 @@ export async function delegatedAccessOf(api, token) {
82
82
  // Every publication is into one Room, so a token must name the Room it opens the surface in.
83
83
  const room = surface.room ?? claims.room ?? null;
84
84
  if (typeof room !== 'string' || !room) return null;
85
- return { caps, expiresAt: claims.exp * 1000, issuer: claims.iss, kind: surface.kind, machineId: config.machine, room, scope: claims.scope, subject: claims.sub, target: config.target, teamId: config.team };
85
+ return { installationId:claims.installation_id, installedAt:claims.installed_at, caps, expiresAt: claims.exp * 1000, issuer: claims.iss, kind: surface.kind, machineId: config.machine, room, scope: claims.scope, subject: claims.sub, target: config.target, teamId: config.team };
86
86
  }
87
87
 
88
88
  /** Longest a session-view token may live, whatever it says: the workplace mints them for minutes. */
@@ -106,7 +106,7 @@ export async function delegatedSessionOf(api, token) {
106
106
  // from what the browser sends.
107
107
  const name = typeof claims.name === 'string' && claims.name.trim() ? claims.name.trim().slice(0, 80) : null;
108
108
  const roomContext = claims.roomContext === false ? null : { id: claims.room, name: typeof claims.roomName === 'string' ? claims.roomName.slice(0, 120) : claims.room, sender: claims.sub };
109
- return { roomContext, conversation: claims.conversation, expiresAt: claims.exp * 1000, issuer: claims.iss, name, room: claims.room, scope: claims.scope, sessionRef: session.id, subject: claims.sub, teamId: session.team };
109
+ return { installationId:claims.installation_id, installedAt:claims.installed_at, roomContext, conversation: claims.conversation, expiresAt: claims.exp * 1000, issuer: claims.iss, name, room: claims.room, scope: claims.scope, sessionRef: session.id, subject: claims.sub, teamId: session.team };
110
110
  }
111
111
 
112
112
  /** A signed Room lifecycle notice. This scope grants no transcript read or user-turn control. */
@@ -120,7 +120,7 @@ export async function delegatedRoomNoticeOf(api, token) {
120
120
  || typeof n.text !== 'string' || !n.text.trim() || n.text.length > 16000) return null;
121
121
  const now = Math.floor(api.clock.nowMs() / 1000);
122
122
  if (!Number.isFinite(c.iat) || c.exp - c.iat > SESSION_TOKEN_MAX_S || c.exp - now > SESSION_TOKEN_MAX_S) return null;
123
- return { conversation: c.conversation, expiresAt: c.exp * 1000, issuer: c.iss, room: c.room,
123
+ return { installationId:c.installation_id, installedAt:c.installed_at, conversation: c.conversation, expiresAt: c.exp * 1000, issuer: c.iss, room: c.room,
124
124
  roomName: typeof c.roomName === 'string' ? c.roomName.slice(0, 120) : c.room,
125
125
  sessionRef: c.session.id, harness: c.session.harness, subject: c.sub, teamId: c.session.team, notice: n };
126
126
  }
@@ -145,7 +145,7 @@ async function verifiedClaims(api, token, action) {
145
145
  const now = Math.floor(api.clock.nowMs() / 1000);
146
146
  if (typeof claims.exp !== 'number' || claims.exp <= now - SKEW_S || (typeof claims.nbf === 'number' && claims.nbf > now + SKEW_S)) return null;
147
147
  if (typeof claims.sub !== 'string' || !claims.sub) return null;
148
- return claims;
148
+ return {...claims, installation_id:installation.id,installed_at:installation.created_at};
149
149
  } catch {
150
150
  return null;
151
151
  }
package/server/http.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { appRoute, appPreAuth, appAuthentication, enforceAppScope, appAuthGuards, installedIssuer } from './apps.mjs';
1
+ import { appRoute, appPreAuth, appAuthentication, enforceAppScope, appAuthGuards, installedIssuer, identityIssuers } from './apps.mjs';
2
2
  import { machineEventStream } from './machine-events.mjs';
3
3
  import { randomBytes } from 'node:crypto';
4
4
  import { bearer, hashToken, canManage, isAdmin, makeToken } from './auth.mjs';
@@ -40,13 +40,16 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
40
40
  if(req.method==='POST'&&path==='/api/v1/invitations/accept'){checkRate(api,`login:${req.socket.remoteAddress}`,10,5);const body=await parseJson(req);return await acceptInvite(api,res,body,requestId);}
41
41
  if(req.method==='POST'&&path==='/api/v1/login/volter'){checkRate(api,`login:${req.socket.remoteAddress}`,10,5);const body=await parseJson(req);return await identityLogin(api,res,body,requestId);}
42
42
  if(req.method==='POST'&&path==='/api/v1/login/volter/teams'){checkRate(api,`login-teams:${req.socket.remoteAddress}`,10,5);const body=await parseJson(req);return await identityTeams(api,res,body,requestId);}
43
+ // How to sign in is public, as a client needs it before it has a credential: the modes this server takes and, for
44
+ // identity, the issuers some team trusts and the doors that exchange an issuer's token (identity.mjs verifies any
45
+ // OIDC issuer), so a client follows what is advertised rather than naming a provider.
46
+ if(req.method==='GET'&&path==='/api/v1/capabilities'){checkRate(api,`capabilities:${req.socket.remoteAddress}`,60,30);const issuers=api.publicOrigin?await identityIssuers(api):[];return send(res,200,{version:1,server_id:api.serverId,protocol:'teams.v1',operations:['catalog','search','read','follow','access','ingest'],auth_modes:['token','invitation','installed-app',...(issuers.length?['identity']:[])],app_token_exchange:'/api/v1/apps/token',...(issuers.length?{identity:{issuers,audience:api.publicOrigin,login:'/api/v1/login/volter',teams:'/api/v1/login/volter/teams'}}:{})},requestId);}
43
47
  if(!auth)fail(401,'authentication_required','authentication required');auth.now=api.clock.nowMs();auth.clock=api.clock;
44
48
  const bulk=/\/captures\/[^/]+\/parts\/\d+$/.test(path)||/\/uploads\/[^/]+\/chunks\/\d+$/.test(path);// A machine's own credential syncs its live sessions as they change (a capture is a begin and a commit), so it
45
49
  // gets the bulk allowance for ordinary requests too; a person's or a service's stays at 120 a minute.
46
50
  checkRate(api,`credential:${auth.credential_id}:${bulk?'bulk':'ordinary'}`,bulk||auth.kind==='machine'?600:120,bulk||auth.kind==='machine'?60:30);
47
51
  if(req.method==='GET'&&path==='/api/v1/me'){if(!['user','service'].includes(auth.kind))fail(403,'forbidden','credential kind cannot use this operation');const teams=await api.store.query('SELECT t.id,t.name,t.revision,m.role FROM memberships m JOIN teams t ON t.id=m.team_id WHERE m.principal_id=? AND m.active=1 AND (? IS NULL OR m.team_id=?)',[auth.principal_id,auth.team_id,auth.team_id]);return send(res,200,{server_id:api.serverId,principal:{id:auth.principal_id,kind:auth.principal_kind,display_name:auth.display_name},teams:teams.map(x=>({team:{id:x.id,name:x.name,revision:x.revision},role:x.role}))},requestId);}
48
52
  if(req.method==='POST'&&path==='/api/v1/me/identities')return await linkIdentity(api,req,res,auth,requestId);
49
- if(req.method==='GET'&&path==='/api/v1/capabilities')return send(res,200,{version:1,server_id:api.serverId,protocol:'teams.v1',operations:['catalog','search','read','follow','access','ingest'],auth_modes:['token','invitation','installed-app'],app_token_exchange:'/api/v1/apps/token'},requestId);
50
53
  if(req.method==='GET'&&path==='/api/v1/teams')return await listTeams(api,res,auth,url,requestId);
51
54
  if(req.method==='POST'&&path==='/api/v1/teams')return await createTeam(api,req,res,auth,path,requestId);
52
55
  const match=/^\/api\/v1\/teams\/([^/]+)(\/.*)?$/.exec(path);if(!match)fail(404,'not_found','not found');const teamId=match[1],sub=match[2]??'';
@@ -111,7 +114,7 @@ async function authorizeReplay(api,auth,method,sub,body){
111
114
  if(sub==='/correlations'&&method==='POST'){if(auth.role==='viewer')fail(403,'forbidden','correlation authorization changed');return;}
112
115
  }
113
116
 
114
- export async function authenticate(api,token){if(!token)return null;const digest=hashToken(token);const auth=await api.store.one(`SELECT c.id credential_id,c.kind,c.team_id,c.source_id,c.machine_id,c.principal_id,p.kind principal_kind,p.display_name,m.role,m.active FROM credentials c JOIN principals p ON p.id=c.principal_id LEFT JOIN memberships m ON m.team_id=c.team_id AND m.principal_id=c.principal_id WHERE c.token_hash=? AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>?)`,[digest,api.clock.nowMs()]);if(auth?.team_id)auth.group_ids=(await api.store.query('SELECT group_id FROM group_members WHERE team_id=? AND principal_id=?',[auth.team_id,auth.principal_id])).map(x=>x.group_id);return await appAuthentication(api,auth);}
117
+ export async function authenticate(api,token){if(!token)return null;const digest=hashToken(token);const auth=await api.store.one(`SELECT c.id credential_id,c.expires_at,c.kind,c.team_id,c.source_id,c.machine_id,c.principal_id,p.kind principal_kind,p.display_name,m.role,m.active FROM credentials c JOIN principals p ON p.id=c.principal_id LEFT JOIN memberships m ON m.team_id=c.team_id AND m.principal_id=c.principal_id WHERE c.token_hash=? AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>?)`,[digest,api.clock.nowMs()]);if(auth?.team_id)auth.group_ids=(await api.store.query('SELECT group_id FROM group_members WHERE team_id=? AND principal_id=?',[auth.team_id,auth.principal_id])).map(x=>x.group_id);return await appAuthentication(api,auth);}
115
118
  async function grants(api,team,id){return api.store.query('SELECT * FROM session_grants WHERE team_id=? AND session_id=?',[team,id]);}
116
119
  const authGuards=(auth,_admissionNow)=>{
117
120
  const now=auth.clock?.nowMs()??_admissionNow;
@@ -1,3 +1,4 @@
1
+ import { appAuthentication } from './apps.mjs';
1
2
  // Machine doors through the server (design §11).
2
3
  //
3
4
  // GET /api/v1/teams/{team}/machines/{id}/link the machine's one outbound link
@@ -55,8 +56,13 @@ export async function machineGrantRows(api, teamId, machineId) {
55
56
 
56
57
  /** What the caller may claim on the machine right now, or null (indistinguishable from no machine). */
57
58
  async function claimFor(api, auth, machineId) {
59
+ if(auth.installation_id&&auth.kind!=='delegated'&&!await appAuthentication(api,auth))return null;
58
60
  if (auth.kind === 'delegated') {
59
- const row = await api.store.one('SELECT id, revision FROM machine_grants WHERE team_id=? AND machine_id=? AND id=? AND expires_at>?', [auth.team_id, machineId, auth.grant_id, api.clock.nowMs()]);
61
+ 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
62
+ JOIN memberships installer ON installer.team_id=i.team_id AND installer.principal_id=i.installer_id
63
+ JOIN memberships service ON service.team_id=i.team_id AND service.principal_id=i.principal_id
64
+ WHERE g.team_id=? AND g.machine_id=? AND g.id=? AND g.expires_at>? AND i.state='active' AND a.state='active'
65
+ AND installer.active=1 AND installer.role IN ('owner','admin') AND service.active=1`,[auth.team_id,machineId,auth.grant_id,api.clock.nowMs()]);
60
66
  return row ? { principal: auth.principal_id, groups: [], grants: [row.id], revisions: { [row.id]: row.revision }, ...(auth.name ? { name: auth.name } : {}), ...(auth.roomContext ? { roomContext: auth.roomContext } : {}) } : null;
61
67
  }
62
68
  const machine = await api.store.one('SELECT custodian_id FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', [auth.team_id, machineId]);
@@ -87,6 +93,11 @@ export function closeChannelsFor(api, teamId, { principalId = null, groupId = nu
87
93
  return closed;
88
94
  }
89
95
 
96
+ /** Installation revocation closes its channels even when the bearer names another principal. */
97
+ export function closeAppChannels(api,installationId,reason='app_uninstalled'){
98
+ for(const entry of channels(api))if(entry.installationId===installationId)entry.close(reason);
99
+ }
100
+
90
101
  /** Close the channels a revocation or machine removal leaves without authority; the next stream would be refused anyway. */
91
102
  export function closeMachineChannels(api, teamId, machineId, { grantId = null, reason = 'access_revoked' } = {}) {
92
103
  let closed = 0;
@@ -130,7 +141,7 @@ export async function machineGrantRoute({ api, req, res, auth, machineId, grantI
130
141
  const row = { id, machine_id: machineId, subject_id: body.subject_id, caps: body.caps, targets: body.targets, issuer_id: auth.principal_id, created_at: now, expires_at: body.expires_at, revision: 1 };
131
142
  const response = result(api, auth, row);
132
143
  await guardedMutation(api, res, auth, [{ sql: 'SELECT 1 ok FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', params: [auth.team_id, machineId], code: 'not_found', status: 404, message: 'machine not found' }], [
133
- { sql: 'INSERT INTO machine_grants(team_id,id,machine_id,subject_id,caps_json,targets_json,issuer_id,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?)', params: [auth.team_id, id, machineId, body.subject_id, JSON.stringify(body.caps), JSON.stringify(body.targets), auth.principal_id, now, body.expires_at] },
144
+ { sql: 'INSERT INTO machine_grants(team_id,id,machine_id,subject_id,caps_json,targets_json,issuer_id,created_at,expires_at,app_installation_id) VALUES (?,?,?,?,?,?,?,?,?,?)', params: [auth.team_id, id, machineId, body.subject_id, JSON.stringify(body.caps), JSON.stringify(body.targets), auth.principal_id, now, body.expires_at, auth.installation_id??null] },
134
145
  ], { status: 201, body: response, targetId: id });
135
146
  return send(res, 201, response, requestId, { ETag: '"1"' });
136
147
  }
@@ -185,6 +196,16 @@ export async function authorizeMachineUpgrade(api, { authenticate, url, headers
185
196
  * A delegated token's channel: its grant, minted now on exactly the published surface the token names, or the same
186
197
  * refusal as no machine. A token for a surface nobody published here (or one since unpublished) opens nothing.
187
198
  */
199
+ function installedGuard(access,action){
200
+ return {sql:`SELECT 1 FROM app_installations i JOIN apps a ON a.id=i.app_id
201
+ JOIN memberships installer ON installer.team_id=i.team_id AND installer.principal_id=i.installer_id
202
+ JOIN memberships service ON service.team_id=i.team_id AND service.principal_id=i.principal_id
203
+ WHERE i.id=? AND i.team_id=? AND i.created_at=? AND i.state='active' AND a.state='active'
204
+ AND installer.active=1 AND installer.role IN ('owner','admin') AND service.active=1
205
+ AND EXISTS (SELECT 1 FROM json_each(i.actions_json) WHERE value=?)`,
206
+ params:[access.installationId,access.teamId,access.installedAt,action],status:404,code:'not_found',message:'integration authorization changed'};
207
+ }
208
+
188
209
  async function delegatedChannel(api, teamId, machineId, token) {
189
210
  const access = await delegatedAccessOf(api, token);
190
211
  if (!access) return { status: 401, code: 'authentication_required', message: 'authentication required' };
@@ -198,18 +219,19 @@ async function delegatedChannel(api, teamId, machineId, token) {
198
219
  // The publication, and its publisher's standing, must still hold as the grant is written: an unpublish or a
199
220
  // member removal racing this open wins.
200
221
  await api.store.guardedTransaction([
222
+ installedGuard(access,'delegation.surfaces'),
201
223
  { sql: 'SELECT 1 ok FROM machine_publications p JOIN machines m ON m.team_id=p.team_id AND m.id=p.machine_id AND m.custodian_id=p.publisher_id JOIN memberships mem ON mem.team_id=p.team_id AND mem.principal_id=p.publisher_id AND mem.active=1 WHERE p.team_id=? AND p.id=?', params: [teamId, publication.id], code: 'not_found', status: 404, message: 'machine not found' },
202
224
  { sql: 'SELECT 1 ok FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', params: [teamId, machineId], code: 'not_found', status: 404, message: 'machine not found' },
203
225
  ], [
204
226
  // Delegated grants live as long as their tokens; the expired ones go as new ones come.
205
227
  { sql: "DELETE FROM machine_grants WHERE team_id=? AND issuer_id LIKE 'delegated:%' AND expires_at<=?", params: [teamId, now] },
206
- { sql: 'INSERT INTO machine_grants(team_id,id,machine_id,subject_id,caps_json,targets_json,issuer_id,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?)', params: [teamId, id, machineId, principal, JSON.stringify(surfaceCaps(access.kind, access.scope, publication.max_scope)), JSON.stringify([publication.target]), issuer, now, access.expiresAt] },
228
+ { sql: 'INSERT INTO machine_grants(team_id,id,machine_id,subject_id,caps_json,targets_json,issuer_id,created_at,expires_at,app_installation_id) VALUES (?,?,?,?,?,?,?,?,?,?)', params: [teamId, id, machineId, principal, JSON.stringify(surfaceCaps(access.kind, access.scope, publication.max_scope)), JSON.stringify([publication.target]), issuer, now, access.expiresAt, access.installationId] },
207
229
  ]);
208
230
  } catch (error) {
209
231
  if (error.code === 'not_found') return { status: 404, code: 'not_found', message: 'machine not found' };
210
232
  throw error;
211
233
  }
212
- return { door: 'channel', teamId, machineId, auth: { kind: 'delegated', team_id: teamId, principal_id: principal, group_ids: [], grant_id: id, grant_issuer: issuer, expires_at: access.expiresAt } };
234
+ return { door: 'channel', teamId, machineId, auth: { kind: 'delegated', installation_id:access.installationId, team_id: teamId, principal_id: principal, group_ids: [], grant_id: id, grant_issuer: issuer, expires_at: access.expiresAt } };
213
235
  }
214
236
 
215
237
  /**
@@ -228,12 +250,13 @@ async function delegatedSessionChannel(api, teamId, sessionRef, token) {
228
250
  const now = api.clock.nowMs(), id = api.ids.next('grant'), principal = delegatedPrincipal(access);
229
251
  const issuer = `delegated:${new URL(access.issuer).host}:session:${target.sessionId}`;
230
252
  await api.store.guardedTransaction([
253
+ installedGuard(access,'delegation.sessions'),
231
254
  { sql: 'SELECT 1 ok FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', params: [teamId, target.machineId], code: 'not_found', status: 404, message: 'session not found' },
232
255
  ], [
233
256
  { sql: "DELETE FROM machine_grants WHERE team_id=? AND issuer_id LIKE 'delegated:%' AND expires_at<=?", params: [teamId, now] },
234
- { sql: 'INSERT INTO machine_grants(team_id,id,machine_id,subject_id,caps_json,targets_json,issuer_id,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?)', params: [teamId, id, target.machineId, principal, JSON.stringify(access.scope === 'control' ? ['observe', 'interact'] : ['observe']), JSON.stringify([`session:${target.harness}/${target.nativeId}`]), issuer, now, access.expiresAt] },
257
+ { sql: 'INSERT INTO machine_grants(team_id,id,machine_id,subject_id,caps_json,targets_json,issuer_id,created_at,expires_at,app_installation_id) VALUES (?,?,?,?,?,?,?,?,?,?)', params: [teamId, id, target.machineId, principal, JSON.stringify(access.scope === 'control' ? ['observe', 'interact'] : ['observe']), JSON.stringify([`session:${target.harness}/${target.nativeId}`]), issuer, now, access.expiresAt, access.installationId] },
235
258
  ]).catch((error) => { if (error.code !== 'not_found') throw error; return null; });
236
- return { door: 'channel', teamId, machineId: target.machineId, auth: { kind: 'delegated', team_id: teamId, principal_id: principal, group_ids: [], grant_id: id, grant_issuer: issuer, expires_at: access.expiresAt, name: access.name, roomContext: access.roomContext } };
259
+ return { door: 'channel', teamId, machineId: target.machineId, auth: { kind: 'delegated', installation_id:access.installationId, team_id: teamId, principal_id: principal, group_ids: [], grant_id: id, grant_issuer: issuer, expires_at: access.expiresAt, name: access.name, roomContext: access.roomContext } };
237
260
  }
238
261
 
239
262
  /**
@@ -332,13 +355,13 @@ async function acceptChannel(api, auth, machineId, duplex, closeSocket) {
332
355
  } catch { closeSocket('refused'); return; }
333
356
  const open = new Set();
334
357
  const entry = {
335
- teamId: auth.team_id, machineId, principal: auth.principal_id, claim: null, grantIssuer: auth.grant_issuer ?? null,
358
+ teamId: auth.team_id, machineId, installationId:auth.installation_id??null, principal: auth.principal_id, claim: null, grantIssuer: auth.grant_issuer ?? null,
336
359
  close: (reason) => closeSocket(reason),
337
360
  dropStreams: (reason) => { for (const pair of open) pair.close(reason); },
338
361
  };
339
362
  channels(api).add(entry);
340
363
  // A delegated channel ends when its token does; the viewer reopens it with a fresh one.
341
- const expiry = auth.kind === 'delegated' ? setTimeout(() => entry.close('token_expired'), Math.max(0, auth.expires_at - api.clock.nowMs())) : null;
364
+ const expiry = (auth.kind === 'delegated'||(auth.installation_id&&auth.expires_at-api.clock.nowMs()<=2147483647)) ? setTimeout(() => entry.close('token_expired'), Math.max(0, auth.expires_at - api.clock.nowMs())) : null;
342
365
  channel.on('error', () => {});
343
366
  channel.on('close', () => { if (expiry) clearTimeout(expiry); channels(api).delete(entry); entry.dropStreams('caller closed'); });
344
367
  channel.on('stream', async (stream, accept, refuse) => {
@@ -394,8 +417,8 @@ export async function deliverSessionRoomNotice(api, access) {
394
417
  if (pending.has(digest)) return pending.get(digest);
395
418
  const delivery = (async () => {
396
419
  const id = api.ids.next('grant'), principal = delegatedPrincipal(access), now = api.clock.nowMs();
397
- await api.store.transaction([{ sql: 'INSERT INTO machine_grants(team_id,id,machine_id,subject_id,caps_json,targets_json,issuer_id,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?)',
398
- params: [access.teamId, id, target.machineId, principal, JSON.stringify(['mail']), JSON.stringify([`mail:${to}`]), `delegated:room-notice:${digest}`, now, access.expiresAt] }]);
420
+ await api.store.guardedTransaction([installedGuard(access,'delegation.notices')],[{ sql: 'INSERT INTO machine_grants(team_id,id,machine_id,subject_id,caps_json,targets_json,issuer_id,created_at,expires_at,app_installation_id) VALUES (?,?,?,?,?,?,?,?,?,?)',
421
+ params: [access.teamId, id, target.machineId, principal, JSON.stringify(['mail']), JSON.stringify([`mail:${to}`]), `delegated:room-notice:${digest}`, now, access.expiresAt, access.installationId] }]);
399
422
  try {
400
423
  const link = await linkWithin(api, key(access.teamId, target.machineId), LINK_WAIT_MS);
401
424
  if (!link) throw new Error('machine offline; Room notice remains pending');
@@ -0,0 +1,8 @@
1
+ // Credential fences and lineage remain local; no foreign principal identifiers.
2
+ export default `
3
+ ALTER TABLE app_credentials ADD COLUMN subject_principal_id TEXT REFERENCES principals(id);
4
+ ALTER TABLE app_credentials ADD COLUMN subject_credential_id TEXT REFERENCES credentials(id);
5
+ ALTER TABLE app_credentials ADD COLUMN grants_json TEXT NOT NULL DEFAULT 'null' CHECK(json_valid(grants_json));
6
+ ALTER TABLE machine_grants ADD COLUMN app_installation_id TEXT REFERENCES app_installations(id);
7
+ CREATE INDEX machine_grants_installation ON machine_grants(app_installation_id);
8
+ `;
@@ -12,7 +12,8 @@ import m010 from './010.mjs';
12
12
  import m011 from './011.mjs';
13
13
  import m012 from './012.mjs';
14
14
  import m013 from './013.mjs';
15
- export const MIGRATIONS = [m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013];
15
+ import m014 from './014.mjs';
16
+ export const MIGRATIONS = [m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014];
16
17
  /** A message row's `until_revision` while its session's current version still holds it (schema 9). */
17
18
  export const OPEN_REVISION = Number.MAX_SAFE_INTEGER;
18
19
  /** SQL: message row `m` belongs to the version at revision ?1 (bind the revision twice). */