@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.
- package/browser-channel.d.ts +1 -1
- package/browser-channel.mjs +5 -2
- package/package.json +1 -1
- package/server/apps.mjs +125 -26
- package/server/auth.mjs +25 -2
- package/server/delegated.mjs +4 -4
- package/server/http.mjs +6 -3
- package/server/machine-link.mjs +33 -10
- package/server/migrations/014.mjs +8 -0
- package/server/migrations/index.mjs +2 -1
package/browser-channel.d.ts
CHANGED
|
@@ -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;
|
package/browser-channel.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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 {
|
|
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(`
|
|
86
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
155
|
-
const
|
|
156
|
-
|
|
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(
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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');
|
package/server/delegated.mjs
CHANGED
|
@@ -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;
|
package/server/machine-link.mjs
CHANGED
|
@@ -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(
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
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). */
|