@volter/teams 0.1.82 → 0.1.84
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/package.json +2 -1
- package/server/apps.mjs +359 -0
- package/server/auth.mjs +25 -2
- package/server/core.mjs +5 -12
- package/server/delegated.mjs +15 -12
- package/server/http.mjs +22 -14
- package/server/identity.mjs +7 -3
- package/server/machine-link.mjs +36 -12
- package/server/migrations/013.mjs +38 -0
- package/server/migrations/014.mjs +8 -0
- package/server/migrations/index.mjs +3 -1
- package/server/publications.mjs +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/teams",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.84",
|
|
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",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"./server/core": "./server/core.mjs",
|
|
24
24
|
"./server/identity-orgs": "./server/identity-orgs.mjs",
|
|
25
25
|
"./server/http": "./server/http.mjs",
|
|
26
|
+
"./server/apps": "./server/apps.mjs",
|
|
26
27
|
"./server/auth": "./server/auth.mjs",
|
|
27
28
|
"./server/machine-link": "./server/machine-link.mjs",
|
|
28
29
|
"./server/request-json": "./server/request-json.mjs",
|
package/server/apps.mjs
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// Consent, OAuth-style one-time exchange, attenuation and durable Standard Webhooks.
|
|
2
|
+
// This module imports no product adapter. Applications keep their configuration/id maps.
|
|
3
|
+
import { randomBytes, createHash, createCipheriv, createDecipheriv, createHmac } from 'node:crypto';
|
|
4
|
+
import { hashToken, makeToken, isAdmin, canRead, canManage, appGrantAllows, APP_LINEAGE_REVOKED } from './auth.mjs';
|
|
5
|
+
import { exact } from './request-json.mjs';
|
|
6
|
+
import { closeAppChannels, closeMachineChannels } from './machine-link.mjs';
|
|
7
|
+
|
|
8
|
+
export const APP_ACTIONS = Object.freeze([
|
|
9
|
+
'teams.read', 'members.read', 'members.write', 'groups.read', 'groups.write',
|
|
10
|
+
'sessions.read', 'sessions.write', 'sessions.control', 'sessions.access',
|
|
11
|
+
'machines.read', 'machines.control', 'machines.write', 'machines.access', 'sources.read', 'sources.write',
|
|
12
|
+
'identity.login', 'delegation.surfaces', 'delegation.sessions', 'delegation.notices',
|
|
13
|
+
]);
|
|
14
|
+
export const APP_EVENTS = Object.freeze(['app.registered', 'app.installed', 'app.uninstalled']);
|
|
15
|
+
const TOKEN_MS = 90 * 86400000, CODE_MS = 10 * 60000, DELEGATION_MS = 15 * 60000;
|
|
16
|
+
const error = (status, code, message) => { throw Object.assign(new Error(message), { status, code }); };
|
|
17
|
+
const statement = (sql, ...params) => ({ sql, params });
|
|
18
|
+
const guard = (sql, params, message = 'application authorization changed') => ({ sql, params, status: 403, code: 'forbidden', message });
|
|
19
|
+
const parse = JSON.parse;
|
|
20
|
+
const keyBytes = api => {
|
|
21
|
+
if (!api.appRootSecret) error(503, 'unavailable', 'the Teams root secret is required for app secret custody');
|
|
22
|
+
return createHash('sha256').update(`Teams app custody v1\0${api.appRootSecret}`).digest();
|
|
23
|
+
};
|
|
24
|
+
function seal(api, secret, id) {
|
|
25
|
+
const iv = randomBytes(12), cipher = createCipheriv('aes-256-gcm', keyBytes(api), iv);
|
|
26
|
+
cipher.setAAD(Buffer.from(id));
|
|
27
|
+
const encrypted = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
|
|
28
|
+
return [iv, cipher.getAuthTag(), encrypted].map(part => part.toString('base64url')).join('.');
|
|
29
|
+
}
|
|
30
|
+
function unseal(api, secret, id) {
|
|
31
|
+
const [iv, tag, ciphertext] = secret.split('.').map(part => Buffer.from(part, 'base64url'));
|
|
32
|
+
const cipher = createDecipheriv('aes-256-gcm', keyBytes(api), iv);
|
|
33
|
+
cipher.setAAD(Buffer.from(id)); cipher.setAuthTag(tag);
|
|
34
|
+
return Buffer.concat([cipher.update(ciphertext), cipher.final()]).toString('utf8');
|
|
35
|
+
}
|
|
36
|
+
function text(value, name, max = 256) {
|
|
37
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max || /[\x00-\x1f]/.test(value)) error(400, 'invalid_request', `${name} must be a nonempty string of at most ${max} characters`);
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
function secureUrl(value, name, originOnly = false) {
|
|
41
|
+
text(value, name, 2048);
|
|
42
|
+
let url; try { url = new URL(value); } catch { error(400, 'invalid_request', `${name} must be an HTTPS URL`); }
|
|
43
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
44
|
+
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || url.username || url.password || url.hash || (originOnly && value !== url.origin)) error(400, 'invalid_request', `${name} must be HTTPS (HTTP is loopback-only)${originOnly ? ' and an exact origin' : ''}`);
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
function list(value, name, allowed, max = 40) {
|
|
48
|
+
if (!Array.isArray(value) || value.length > max || value.some(item => typeof item !== 'string' || !allowed.includes(item)) || new Set(value).size !== value.length) error(400, 'invalid_request', `${name} must be a distinct list from the app catalogue`);
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
const appJson = row => ({ id: row.id, team_id: row.team_id, principal_id: row.principal_id, name: row.name, description: row.description, actions: parse(row.actions_json), event_kinds: parse(row.events_json), redirect_urls: parse(row.redirect_urls_json), webhook_url: row.webhook_url, issuer: row.issuer, state: row.state, created_at: row.created_at });
|
|
52
|
+
const installJson = row => ({ id: row.id, app_id: row.app_id, team_id: row.team_id, principal_id: row.principal_id, installer_id: row.installer_id, actions: parse(row.actions_json), state: row.state, created_at: row.created_at, uninstalled_at: row.uninstalled_at });
|
|
53
|
+
const admin = auth => {
|
|
54
|
+
if (!isAdmin(auth) || auth.installation_id) error(403, 'forbidden', 'an organization admin must consent to app changes');
|
|
55
|
+
};
|
|
56
|
+
function adminGuards(auth, now) {
|
|
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])];
|
|
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
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Restrict every ordinary door, including replay, before the existing resource evaluator. Unknown doors fail closed. */
|
|
70
|
+
export function appRouteAction(method, sub) {
|
|
71
|
+
if (method === 'GET' && (sub === '' || sub === '/me' || sub === '/capabilities' || sub === '/teams')) return 'teams.read';
|
|
72
|
+
if (/^\/members(?:\/[^/]+)?$/.test(sub)) return method === 'GET' ? 'members.read' : 'members.write';
|
|
73
|
+
if (/^\/groups(?:\/[^/]+(?:\/members\/[^/]+)?)?$/.test(sub)) return method === 'GET' ? 'groups.read' : 'groups.write';
|
|
74
|
+
if (/^\/sessions\/[^/]+\/(access|view-bindings)(?:\/[^/]+)?$/.test(sub)) return 'sessions.access';
|
|
75
|
+
if(method==='GET'&&/^\/sessions(?:\/(?:search|events|[^/]+(?:\/(?:messages|events|artifacts\/[^/]+))?))?$/.test(sub))return 'sessions.read';
|
|
76
|
+
if(['PATCH','DELETE'].includes(method)&&/^\/sessions\/[^/]+$/.test(sub))return 'sessions.write';
|
|
77
|
+
if(method==='GET'&&/^\/machines(?:\/(?:events|[^/]+(?:\/roots)?))?$/.test(sub))return 'machines.read';
|
|
78
|
+
if(/^\/machines\/[^/]+\/(?:access|grants|publications)(?:\/[^/]+)?$/.test(sub))return 'machines.access';
|
|
79
|
+
if((method==='POST'&&sub==='/machines')||(method==='DELETE'&&/^\/machines\/[^/]+$/.test(sub)))return 'machines.write';
|
|
80
|
+
if (/^\/sources(?:\/[^/]+(?:\/config)?)?$/.test(sub)) return method === 'GET' ? 'sources.read' : 'sources.write';
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
export function enforceAppScope(auth, method, sub) {
|
|
84
|
+
if (!auth?.installation_id) return;
|
|
85
|
+
const action = appRouteAction(method, sub);
|
|
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
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Atomic mutation rechecks; parent credentials cannot survive revocation through a child. */
|
|
97
|
+
export function appAuthGuards(auth, now) {
|
|
98
|
+
const installation=auth?.installation_id??auth?.identity_installation_id;if(!installation)return [];
|
|
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
|
|
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]),
|
|
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
|
+
}
|
|
109
|
+
return checks;
|
|
110
|
+
}
|
|
111
|
+
export async function appAuthentication(api, auth, depth=0) {
|
|
112
|
+
if (!auth) return null;
|
|
113
|
+
if(depth>16)return null;
|
|
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
|
|
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]);
|
|
116
|
+
if (!row) {
|
|
117
|
+
const identity=await api.store.one(`SELECT i.id,i.state,a.state app_state,m.active,m.role FROM identity_app_credentials c JOIN app_installations i ON i.id=c.installation_id JOIN apps a ON a.id=i.app_id JOIN memberships m ON m.team_id=i.team_id AND m.principal_id=i.installer_id WHERE c.credential_id=?`,[auth.credential_id]);
|
|
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;}
|
|
119
|
+
return auth;
|
|
120
|
+
}
|
|
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()]);
|
|
123
|
+
if (revoked) return null;
|
|
124
|
+
const ranks=['viewer','member','admin','owner'];auth.role=ranks[Math.min(ranks.indexOf(auth.role),ranks.indexOf(row.role))];
|
|
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);
|
|
149
|
+
}
|
|
150
|
+
/** Exact issuer trust is an active, consented installation, scoped to its organization. */
|
|
151
|
+
export async function installedIssuer(api, issuer, action, teamId = null) {
|
|
152
|
+
return await api.store.one(`SELECT i.id,i.team_id,i.principal_id,i.created_at 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
|
|
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]);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Fan-out is durable and transactionally committed with the source mutation. C2 adds resource events. */
|
|
157
|
+
export async function appEventStatements(api, teamId, kind, data, addedDestination = null) {
|
|
158
|
+
const now = api.clock.nowMs(), eventId = api.ids.next('event');
|
|
159
|
+
const destinations = await api.store.query(`SELECT i.id FROM app_installations i JOIN apps a ON a.id=i.app_id WHERE i.team_id=? AND i.state='active' AND a.state='active' AND a.webhook_url IS NOT NULL AND EXISTS (SELECT 1 FROM json_each(a.events_json) WHERE value=?)`, [teamId, kind]);
|
|
160
|
+
if(addedDestination&&!destinations.some(row=>row.id===addedDestination))destinations.push({id:addedDestination});
|
|
161
|
+
const body = JSON.stringify({ id: eventId, type: kind, team_id: teamId, created_at: now, data });
|
|
162
|
+
return destinations.map(row => statement("INSERT INTO app_webhook_deliveries(id,installation_id,event_id,body,state,next_at,created_at) SELECT ?,?,?,?,'pending',?,? WHERE EXISTS (SELECT 1 FROM app_installations WHERE id=? AND state='active')", api.ids.next('delivery'), row.id, eventId, body, now, now, row.id));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function appPreAuth({ api, req, res, path, requestId, parseJson, send }) {
|
|
166
|
+
if (req.method !== 'POST' || path !== '/api/v1/apps/token') return false;
|
|
167
|
+
const body = await parseJson(req); exact(body, ['app_id', 'app_secret', 'code', 'redirect_url']);
|
|
168
|
+
for (const field of ['app_id','app_secret','code','redirect_url']) text(body[field], field, 2048);
|
|
169
|
+
const now = api.clock.nowMs();
|
|
170
|
+
const code = await api.store.one(`SELECT c.*,i.*,a.secret_hash FROM app_install_codes c JOIN app_installations i ON i.id=c.installation_id JOIN apps a ON a.id=i.app_id
|
|
171
|
+
WHERE c.code_hash=? AND i.app_id=? AND a.secret_hash=? AND c.redirect_url=? AND c.consumed_at IS NULL AND c.expires_at>? AND i.state='active' AND a.state='active'`, [hashToken(body.code), body.app_id, hashToken(body.app_secret), body.redirect_url, now]);
|
|
172
|
+
if (!code) error(401, 'authentication_required', 'invalid or consumed app installation code');
|
|
173
|
+
const signingSecret=unseal(api,code.signing_secret,code.installation_id);
|
|
174
|
+
const cid = api.ids.next('credential'), token = makeToken(cid), expiresAt = now + TOKEN_MS;
|
|
175
|
+
await api.store.guardedTransaction([
|
|
176
|
+
guard(`SELECT 1 FROM app_install_codes c JOIN app_installations i ON i.id=c.installation_id JOIN apps a ON a.id=i.app_id JOIN memberships m ON m.team_id=i.team_id AND m.principal_id=i.installer_id
|
|
177
|
+
WHERE c.code_hash=? AND c.consumed_at IS NULL AND c.expires_at>? AND i.state='active' AND a.state='active' AND a.secret_hash=? AND m.active=1 AND m.role IN ('owner','admin')`, [hashToken(body.code), now, hashToken(body.app_secret)]),
|
|
178
|
+
], [statement('UPDATE app_install_codes SET consumed_at=? WHERE code_hash=?', now, hashToken(body.code)),
|
|
179
|
+
statement('INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,expires_at,created_at) VALUES (?,?,?,\'service\',?,?,?)', cid, hashToken(token), code.principal_id, code.team_id, expiresAt, now),
|
|
180
|
+
statement('INSERT INTO app_credentials(credential_id,installation_id,actions_json,actor_json) VALUES (?,?,?,?)', cid, code.installation_id, code.actions_json, '[]')]);
|
|
181
|
+
// No bearer or signing secret enters a replay record, audit payload or URL.
|
|
182
|
+
send(res, 201, { version: 1, installation: installJson({ ...code, id: code.installation_id }), credential: { token, kind: 'service', expires_at: expiresAt, actions: parse(code.actions_json), subject: code.principal_id, act: [] }, webhook_secret: signingSecret }, requestId);
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function appRoute(ctx) {
|
|
187
|
+
const { api, req, res, auth, sub, requestId, parseJson, send, result, guardedMutation } = ctx;
|
|
188
|
+
if (!sub.startsWith('/apps') && !sub.startsWith('/app-installations')) return false;
|
|
189
|
+
// Attenuation is the one app-owned credential door; it grants no admin power.
|
|
190
|
+
const delegation = /^\/app-installations\/([^/]+)\/delegations$/.exec(sub);
|
|
191
|
+
if (delegation && req.method === 'POST') {
|
|
192
|
+
if (auth.installation_id !== delegation[1]) error(403, 'forbidden', 'only this installation may delegate its own token');
|
|
193
|
+
const body = await parseJson(req); exact(body, ['actions','actor','expires_in_ms','principal_id','grants','subject_token']);
|
|
194
|
+
const actions = list(body.actions, 'actions', auth.app_actions);
|
|
195
|
+
const actor = text(body.actor, 'actor');
|
|
196
|
+
const duration = body.expires_in_ms ?? DELEGATION_MS;
|
|
197
|
+
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');
|
|
198
|
+
const parent = await api.store.one('SELECT expires_at FROM credentials WHERE id=?', [auth.credential_id]);
|
|
199
|
+
const now=api.clock.nowMs(),epoch=await api.store.one('SELECT auth_epoch FROM teams WHERE id=?',[auth.team_id]);
|
|
200
|
+
const subject=body.principal_id==null?auth.principal_id:text(body.principal_id,'principal_id');
|
|
201
|
+
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;
|
|
202
|
+
if(auth.app_grants!=null&&grants===null)error(403,'forbidden','a descendant cannot remove its resource fence');
|
|
203
|
+
if(body.subject_token!=null){
|
|
204
|
+
if(body.principal_id==null)error(400,'invalid_request','subject_token requires principal_id');
|
|
205
|
+
text(body.subject_token,'subject_token',4096);
|
|
206
|
+
const credential=await api.store.one('SELECT id FROM credentials WHERE token_hash=?',[hashToken(body.subject_token)]);
|
|
207
|
+
source=credential?await credentialAuthority(api,credential.id,auth.team_id):null;
|
|
208
|
+
if(!source||source.principal_id!==subject)error(403,'forbidden','subject_token must prove the named active principal in this organization');
|
|
209
|
+
sourceId=source.credential_id;
|
|
210
|
+
}
|
|
211
|
+
if(body.principal_id!=null&&(!Array.isArray(grants)||!grants.length))error(400,'invalid_request','an existing principal requires explicit resource grants');
|
|
212
|
+
const targetMembership=await api.store.one('SELECT role,active FROM memberships WHERE team_id=? AND principal_id=?',[auth.team_id,subject]);
|
|
213
|
+
if(!targetMembership?.active)error(404,'not_found','principal not found');
|
|
214
|
+
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)};
|
|
215
|
+
if(grants!==null){
|
|
216
|
+
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');
|
|
217
|
+
const seen=new Set();
|
|
218
|
+
for(const g of grants){
|
|
219
|
+
exact(g,['kind','id','actions']);text(g.id,'grant.id');
|
|
220
|
+
if(g.kind!=='session'||seen.has(g.id))error(400,'invalid_request','grant must name a distinct local session');seen.add(g.id);
|
|
221
|
+
list(g.actions,'grant.actions',actions);
|
|
222
|
+
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');
|
|
223
|
+
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');
|
|
224
|
+
const session=await api.store.one('SELECT * FROM sessions WHERE team_id=? AND id=? AND deleted_at IS NULL',[auth.team_id,g.id]);
|
|
225
|
+
if(!session)error(404,'not_found','session not found');
|
|
226
|
+
await api.store.authorizedSession({teamId:auth.team_id,principalId:source.principal_id,credentialId:source.credential_id,ref:g.id,now});
|
|
227
|
+
const acl=await api.store.query('SELECT * FROM session_grants WHERE team_id=? AND session_id=?',[auth.team_id,g.id]);
|
|
228
|
+
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');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
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];
|
|
232
|
+
const response=result(api,auth,{token,kind:'service',expires_at:expiresAt,actions,subject,act,grants});
|
|
233
|
+
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])];
|
|
234
|
+
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));}
|
|
235
|
+
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});
|
|
236
|
+
send(res, 201, response, requestId); return true;
|
|
237
|
+
}
|
|
238
|
+
admin(auth);
|
|
239
|
+
if (req.method === 'GET' && sub === '/apps/catalogue') {
|
|
240
|
+
send(res, 200, result(api, auth, { actions: APP_ACTIONS, event_kinds: APP_EVENTS }), requestId); return true;
|
|
241
|
+
}
|
|
242
|
+
if (req.method === 'GET' && sub === '/apps') {
|
|
243
|
+
send(res, 200, { version: 1, context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id}, items: (await api.store.query("SELECT * FROM apps WHERE state='active' ORDER BY id LIMIT 1000")).map(appJson), next_cursor: null }, requestId); return true;
|
|
244
|
+
}
|
|
245
|
+
let match = /^\/apps\/([^/]+)$/.exec(sub);
|
|
246
|
+
if (match && req.method === 'GET') {
|
|
247
|
+
const app = await api.store.one("SELECT * FROM apps WHERE id=? AND state='active'", [match[1]]);
|
|
248
|
+
if (!app) error(404, 'not_found', 'app not found');
|
|
249
|
+
send(res, 200, result(api, auth, appJson(app)), requestId); return true;
|
|
250
|
+
}
|
|
251
|
+
if(match&&req.method==='DELETE'){
|
|
252
|
+
const app=await api.store.one("SELECT * FROM apps WHERE id=? AND team_id=? AND state='active'",[match[1],auth.team_id]);
|
|
253
|
+
if(!app)error(404,'not_found','app not found');
|
|
254
|
+
const now=api.clock.nowMs();
|
|
255
|
+
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]);
|
|
256
|
+
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))])],[
|
|
257
|
+
statement("UPDATE apps SET state='retired',secret_hash='' WHERE id=?",app.id),
|
|
258
|
+
statement("UPDATE app_installations SET state='uninstalled',uninstalled_at=?,signing_secret='' WHERE app_id=?",now,app.id),
|
|
259
|
+
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),
|
|
260
|
+
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),
|
|
261
|
+
statement('DELETE FROM app_install_codes WHERE installation_id IN (SELECT id FROM app_installations WHERE app_id=?)',app.id),
|
|
262
|
+
statement('DELETE FROM machine_grants WHERE app_installation_id IN (SELECT id FROM app_installations WHERE app_id=?)',app.id),
|
|
263
|
+
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),
|
|
264
|
+
statement('UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id IN (SELECT team_id FROM app_installations WHERE app_id=?)',app.id),
|
|
265
|
+
],{status:204,body:null,targetId:app.id});
|
|
266
|
+
const installs=await api.store.query('SELECT * FROM app_installations WHERE app_id=?',[app.id]);
|
|
267
|
+
for(const install of installs){closeAppChannels(api,install.id,'app_deregistered');api.events.publish(install.team_id,{type:'authorization_changed',installation_id:install.id});}
|
|
268
|
+
for(const grant of grants)closeMachineChannels(api,grant.team_id,grant.machine_id,{grantId:grant.id,reason:'app_deregistered'});
|
|
269
|
+
send(res,204,null,requestId);return true;
|
|
270
|
+
}
|
|
271
|
+
if (req.method === 'POST' && sub === '/apps') {
|
|
272
|
+
const body = await parseJson(req); exact(body, ['name','description','actions','event_kinds','redirect_urls','webhook_url','issuer']);
|
|
273
|
+
const name = text(body.name, 'name', 80), description = body.description ?? '';
|
|
274
|
+
if (typeof description !== 'string' || description.length > 2000) error(400, 'invalid_request', 'description must be at most 2000 characters');
|
|
275
|
+
const actions = list(body.actions, 'actions', APP_ACTIONS), events = list(body.event_kinds ?? [], 'event_kinds', APP_EVENTS);
|
|
276
|
+
if (!actions.length || !Array.isArray(body.redirect_urls) || !body.redirect_urls.length || body.redirect_urls.length > 10 || new Set(body.redirect_urls).size !== body.redirect_urls.length) error(400, 'invalid_request', 'actions and one to ten distinct redirect URLs are required');
|
|
277
|
+
const redirects = body.redirect_urls.map(value => secureUrl(value, 'redirect_url'));
|
|
278
|
+
const webhook = body.webhook_url == null ? null : secureUrl(body.webhook_url, 'webhook_url');
|
|
279
|
+
if (events.length && !webhook) error(400, 'invalid_request', 'event subscriptions require a webhook URL');
|
|
280
|
+
const issuer = body.issuer == null ? null : secureUrl(body.issuer, 'issuer', true);
|
|
281
|
+
if (actions.some(action => action.startsWith('delegation.') || action === 'identity.login') && !issuer) error(400, 'invalid_request', 'identity and delegation actions require an issuer');
|
|
282
|
+
const now = api.clock.nowMs(), id = api.ids.next('app'), principal = api.ids.next('service'), secret = `sca_${randomBytes(32).toString('base64url')}`;
|
|
283
|
+
const app = { id, team_id: auth.team_id, principal_id: principal, name, description, actions_json: JSON.stringify(actions), events_json: JSON.stringify(events), redirect_urls_json: JSON.stringify(redirects), webhook_url: webhook, issuer, state: 'active', created_at: now };
|
|
284
|
+
const response = result(api, auth, { ...appJson(app), secret });
|
|
285
|
+
await guardedMutation(api, res, auth, adminGuards(auth, now), [statement("INSERT INTO principals(id,kind,display_name) VALUES (?,'service',?)", principal, name), statement('INSERT INTO apps(id,team_id,principal_id,name,description,actions_json,events_json,redirect_urls_json,webhook_url,issuer,secret_hash,state,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,\'active\',?)', id, auth.team_id, principal, name, description, app.actions_json, app.events_json, app.redirect_urls_json, webhook, issuer, hashToken(secret), now), ...await appEventStatements(api, auth.team_id, 'app.registered', appJson(app))], { status: 201, body: response, targetId: id });
|
|
286
|
+
send(res, 201, response, requestId); return true;
|
|
287
|
+
}
|
|
288
|
+
match = /^\/apps\/([^/]+)\/secret$/.exec(sub);
|
|
289
|
+
if(match&&req.method==='POST') {
|
|
290
|
+
const body=await parseJson(req);exact(body,[]);
|
|
291
|
+
const now=api.clock.nowMs(),secret=`sca_${randomBytes(32).toString('base64url')}`;
|
|
292
|
+
const response=result(api,auth,{id:match[1],secret});
|
|
293
|
+
await guardedMutation(api,res,auth,[...adminGuards(auth,now),guard("SELECT 1 FROM apps WHERE id=? AND team_id=? AND state='active'",[match[1],auth.team_id],'app not found')],[statement('UPDATE apps SET secret_hash=? WHERE id=?',hashToken(secret),match[1])],{status:200,body:response,targetId:match[1]});
|
|
294
|
+
send(res,200,response,requestId);return true;
|
|
295
|
+
}
|
|
296
|
+
match = /^\/apps\/([^/]+)\/installations$/.exec(sub);
|
|
297
|
+
if (match && req.method === 'POST') {
|
|
298
|
+
const body = await parseJson(req); exact(body, ['redirect_url','state','actions']);
|
|
299
|
+
const app = await api.store.one("SELECT * FROM apps WHERE id=? AND state='active'", [match[1]]);
|
|
300
|
+
if (!app) error(404, 'not_found', 'app not found');
|
|
301
|
+
if (!parse(app.redirect_urls_json).includes(body.redirect_url)) error(400, 'invalid_request', 'redirect_url must exactly match a registered URL');
|
|
302
|
+
const state = text(body.state, 'state', 512), actions = list(body.actions ?? parse(app.actions_json), 'actions', parse(app.actions_json));
|
|
303
|
+
const existing = await api.store.one('SELECT * FROM app_installations WHERE app_id=? AND team_id=?', [app.id, auth.team_id]);
|
|
304
|
+
if (existing?.state === 'active') error(409, 'conflict', 'app is already installed; uninstall before re-consenting');
|
|
305
|
+
const now = api.clock.nowMs(), id = existing?.id ?? api.ids.next('installation'), principal = existing?.principal_id ?? api.ids.next('service');
|
|
306
|
+
const signingSecret = `whsec_${randomBytes(32).toString('base64')}`, sealed = seal(api, signingSecret, id), code = `sci_${randomBytes(32).toString('base64url')}`;
|
|
307
|
+
const role = actions.some(action => /^(members|groups|sources)\.write$/.test(action)) ? 'admin' : actions.some(action => action.endsWith('.write') || action.endsWith('.control') || action.endsWith('.access')) ? 'member' : 'viewer';
|
|
308
|
+
const install = { id, app_id: app.id, team_id: auth.team_id, principal_id: principal, installer_id: auth.principal_id, actions_json: JSON.stringify(actions), role, state: 'active', created_at: now, uninstalled_at: null };
|
|
309
|
+
const response = result(api, auth, { installation: installJson(install), code, state, redirect_url: body.redirect_url, expires_at: now + CODE_MS });
|
|
310
|
+
const statements = existing ? [statement('UPDATE memberships SET active=1,role=?,revision=revision+1 WHERE team_id=? AND principal_id=?', role, auth.team_id, principal), statement("UPDATE app_installations SET installer_id=?,actions_json=?,role=?,state='active',signing_secret=?,created_at=?,uninstalled_at=NULL WHERE id=?", auth.principal_id, install.actions_json, role, sealed, now, id)] : [statement("INSERT INTO principals(id,kind,display_name) VALUES (?,'service',?)", principal, app.name), statement('INSERT INTO memberships(team_id,principal_id,role) VALUES (?,?,?)', auth.team_id, principal, role), statement("INSERT INTO app_installations(id,app_id,team_id,principal_id,installer_id,actions_json,role,state,signing_secret,created_at) VALUES (?,?,?,?,?,?,?,'active',?,?)", id, app.id, auth.team_id, principal, auth.principal_id, install.actions_json, role, sealed, now)];
|
|
311
|
+
await guardedMutation(api, res, auth, [...adminGuards(auth, now), guard("SELECT 1 FROM apps WHERE id=? AND state='active' AND actions_json=? AND redirect_urls_json=?", [app.id, app.actions_json, app.redirect_urls_json]), ...(existing ? [guard("SELECT 1 FROM app_installations WHERE id=? AND state='uninstalled'", [id])] : [])], [...statements, statement('DELETE FROM app_install_codes WHERE installation_id=?', id), statement('INSERT INTO app_install_codes(code_hash,installation_id,redirect_url,expires_at) VALUES (?,?,?,?)', hashToken(code), id, body.redirect_url, now + CODE_MS), ...await appEventStatements(api, auth.team_id, 'app.installed', installJson(install), app.webhook_url&&parse(app.events_json).includes('app.installed')?id:null)], { status: 201, body: response, targetId: id });
|
|
312
|
+
send(res, 201, response, requestId); return true;
|
|
313
|
+
}
|
|
314
|
+
if (sub === '/app-installations' && req.method === 'GET') {
|
|
315
|
+
send(res, 200, { version: 1, context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id}, items: (await api.store.query('SELECT * FROM app_installations WHERE team_id=? ORDER BY id LIMIT 1000', [auth.team_id])).map(installJson), next_cursor: null }, requestId); return true;
|
|
316
|
+
}
|
|
317
|
+
match = /^\/app-installations\/([^/]+)$/.exec(sub);
|
|
318
|
+
if (match && (req.method === 'GET' || req.method === 'DELETE')) {
|
|
319
|
+
const install = await api.store.one('SELECT * FROM app_installations WHERE id=? AND team_id=?', [match[1], auth.team_id]);
|
|
320
|
+
if (!install) error(404, 'not_found', 'installation not found');
|
|
321
|
+
if (req.method === 'GET') { send(res, 200, result(api, auth, installJson(install)), requestId); return true; }
|
|
322
|
+
const now = api.clock.nowMs();
|
|
323
|
+
const grants=await api.store.query('SELECT id,machine_id FROM machine_grants WHERE app_installation_id=?',[install.id]);
|
|
324
|
+
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 });
|
|
325
|
+
closeAppChannels(api,install.id,'app_uninstalled');
|
|
326
|
+
for(const grant of grants)closeMachineChannels(api,auth.team_id,grant.machine_id,{grantId:grant.id,reason:'app_uninstalled'});
|
|
327
|
+
api.events.publish(auth.team_id, { type: 'authorization_changed', installation_id:install.id });
|
|
328
|
+
send(res, 204, null, requestId); return true;
|
|
329
|
+
}
|
|
330
|
+
error(404, 'not_found', 'app operation not found');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Standard Webhooks: stable delivery id across retries, timestamp refreshed and HMAC over exact body bytes. */
|
|
334
|
+
export async function deliverAppWebhooks(api) {
|
|
335
|
+
const due = await api.store.query("SELECT id FROM app_webhook_deliveries WHERE state='pending' AND next_at<=? AND (lease_until IS NULL OR lease_until<=?) ORDER BY next_at,id LIMIT 10", [api.clock.nowMs(), api.clock.nowMs()]);
|
|
336
|
+
for (const { id } of due) {
|
|
337
|
+
const now = api.clock.nowMs(), lease = now + 30000;
|
|
338
|
+
try {
|
|
339
|
+
await api.store.guardedTransaction([guard("SELECT 1 FROM app_webhook_deliveries WHERE id=? AND state='pending' AND (lease_until IS NULL OR lease_until<=?)", [id, now])], [statement('UPDATE app_webhook_deliveries SET lease_until=?,attempts=attempts+1 WHERE id=?', lease, id)]);
|
|
340
|
+
} catch (e) { if (e.code === 'forbidden') continue; throw e; }
|
|
341
|
+
const row = await api.store.one(`SELECT d.*,i.signing_secret,i.state installation_state,a.webhook_url,a.state app_state,m.active,installer.active installer_active,installer.role installer_role
|
|
342
|
+
FROM app_webhook_deliveries d JOIN app_installations i ON i.id=d.installation_id 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 WHERE d.id=?`, [id]);
|
|
343
|
+
let status = null, delivered = false;
|
|
344
|
+
if (row.state !== 'pending' || row.installation_state !== 'active' || row.app_state !== 'active' || !row.active || !row.installer_active || !['owner','admin'].includes(row.installer_role)) {
|
|
345
|
+
await api.store.transaction([statement("UPDATE app_webhook_deliveries SET state='cancelled',lease_until=NULL WHERE id=?", id)]); continue;
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
const secret = unseal(api, row.signing_secret, row.installation_id), timestamp = String(Math.floor(api.clock.nowMs() / 1000));
|
|
349
|
+
const signature = createHmac('sha256', Buffer.from(secret.slice(6), 'base64')).update(`${id}.${timestamp}.${row.body}`).digest('base64');
|
|
350
|
+
const response = await fetch(row.webhook_url, { method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(10000), headers: { 'content-type': 'application/json', 'webhook-id': id, 'webhook-timestamp': timestamp, 'webhook-signature': `v1,${signature}` }, body: row.body });
|
|
351
|
+
status = response.status; delivered = status >= 200 && status < 300;
|
|
352
|
+
await response.body?.cancel();
|
|
353
|
+
} catch { /* transport/custody failures are retryable; never log secrets, response bodies or credential URLs */ }
|
|
354
|
+
const state = delivered ? 'delivered' : row.attempts >= 8 ? 'failed' : 'pending';
|
|
355
|
+
await api.store.transaction([statement("UPDATE app_webhook_deliveries SET state=?,last_status=?,lease_until=NULL,next_at=? WHERE id=? AND lease_until=? AND state='pending'", state, status, api.clock.nowMs() + Math.min(3600000, 1000 * 2 ** row.attempts), id, lease)]);
|
|
356
|
+
}
|
|
357
|
+
await api.store.transaction([statement('DELETE FROM app_install_codes WHERE expires_at<=?', api.clock.nowMs()), statement("DELETE FROM app_webhook_deliveries WHERE state!='pending' AND created_at<?", api.clock.nowMs() - 7 * 86400000)]);
|
|
358
|
+
return due.length >= 10;
|
|
359
|
+
}
|
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/core.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deliverAppWebhooks } from './apps.mjs';
|
|
1
2
|
import { reconcileMachinePresence } from './machine-events.mjs';
|
|
2
3
|
// The Teams server, independent of its host. `createTeamsApi` builds the API
|
|
3
4
|
// object every route and door reads; a host attaches a store (`api.attach`),
|
|
@@ -14,22 +15,13 @@ const productionIds={next(kind){return `${prefix[kind]??kind}_${randomBytes(16).
|
|
|
14
15
|
const productionClock={nowMs:()=>Date.now()};
|
|
15
16
|
export { productionIds, productionClock };
|
|
16
17
|
|
|
17
|
-
export function createTeamsApi({clock=productionClock,ids=productionIds,publicOrigin=null,
|
|
18
|
-
// An identity service people may sign in with (protocol P02 federation); its tokens name this server's public origin.
|
|
19
|
-
identityIssuer=normalizePublicOrigin(identityIssuer);
|
|
20
|
-
if(identityIssuer&&!publicOrigin)throw new TypeError('an identity issuer needs the public origin its tokens name');
|
|
21
|
-
// A workplace whose delegated tokens admit a caller to one pane or folder (server/delegated.mjs); they name this origin too.
|
|
22
|
-
delegatedIssuer=normalizePublicOrigin(delegatedIssuer);
|
|
23
|
-
if(!Array.isArray(delegatedIssuers)||delegatedIssuers.some(origin=>typeof origin!=='string'||!origin.trim()))throw new TypeError('delegatedIssuers must be an array of origins');
|
|
24
|
-
delegatedIssuers=Object.freeze([...new Set([delegatedIssuer,...delegatedIssuers.map(normalizePublicOrigin)].filter(Boolean))]);
|
|
25
|
-
if(delegatedIssuers.length&&!publicOrigin)throw new TypeError('a delegated issuer needs the public origin its tokens name');
|
|
18
|
+
export function createTeamsApi({clock=productionClock,ids=productionIds,publicOrigin=null,appRootSecret=null,identityOrganizations=null,uploadLimitBytes=256*1024*1024}={}) {
|
|
26
19
|
if(typeof clock?.nowMs!=='function'||typeof ids?.next!=='function')throw new TypeError('clock.nowMs and ids.next are required');
|
|
27
20
|
if(!Number.isSafeInteger(uploadLimitBytes)||uploadLimitBytes<1||uploadLimitBytes>256*1024*1024)throw new TypeError('uploadLimitBytes must be between 1 and 268435456');
|
|
28
21
|
let store=null,serverId=null,localOrigin=null,harnessRoutes=null;
|
|
29
22
|
const events=new TeamEvents();
|
|
30
23
|
// Teams linked to organizations the identity service keeps for this server (identity-orgs.mjs).
|
|
31
|
-
|
|
32
|
-
return {clock,ids,events,publicOrigin,identityIssuer,identityOrganizations,delegatedIssuer,delegatedIssuers,uploadLimitBytes,rateState:new Map(),openStreams:new Map(),securityEvents:[],
|
|
24
|
+
return {clock,ids,events,publicOrigin,appRootSecret,identityOrganizations,uploadLimitBytes,rateState:new Map(),openStreams:new Map(),securityEvents:[],
|
|
33
25
|
get store(){return store;},get serverId(){return serverId;},get localOrigin(){return localOrigin;},get harnessRoutes(){return harnessRoutes;},
|
|
34
26
|
attach(next){store=next.store??store;if(next.serverId!==undefined)serverId=next.serverId;if(next.localOrigin!==undefined)localOrigin=next.localOrigin;if(next.harnessRoutes!==undefined)harnessRoutes=next.harnessRoutes;},
|
|
35
27
|
};
|
|
@@ -121,7 +113,8 @@ export async function maintain(api){const now=api.clock.nowMs();
|
|
|
121
113
|
// Presence reconciliation can INSERT. Never put it ahead of the deletes
|
|
122
114
|
// that make room when the database is full.
|
|
123
115
|
await reconcileMachinePresence(api);
|
|
124
|
-
const
|
|
116
|
+
const webhookBacklog=await deliverAppWebhooks(api);
|
|
117
|
+
const result={pruned_versions:stale.length,pruned_parts:parts.length,pruned_idempotency:expired.length,pruned_uploads:orphaned.length,pruned_blobs:unusedBlobs.length,backlog:webhookBacklog||unusedBlobs.length>=50||orphaned.length>=10||expired.length>=1000||stale.length>=VERSION_PRUNE_BATCH||parts.length>=PART_PRUNE_BATCH};
|
|
125
118
|
if(result.pruned_versions||result.pruned_parts||result.pruned_idempotency||result.pruned_uploads)console.log(`maintain: pruned ${result.pruned_uploads} unreachable bundles, ${result.pruned_idempotency} expired replay records, ${result.pruned_parts} parts, ${result.pruned_versions} versions${result.backlog?' (backlog)':''}`);
|
|
126
119
|
return result;}
|
|
127
120
|
/** Session versions `maintain` removes per run: small enough for one Durable Object alarm; a backlog runs again in seconds. */
|
package/server/delegated.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { installedIssuer } from './apps.mjs';
|
|
1
2
|
// Delegated access (RH2 RFC 0013, panes and folders): a workplace that decides who may see or drive a pane or a
|
|
2
3
|
// folder signs a short-lived token saying so, and this server admits exactly that, as it admits a machine grant.
|
|
3
|
-
// The workplace
|
|
4
|
+
// The workplace issuer is trusted through an admin-consented app installation; its keys are at
|
|
4
5
|
// `<issuer>/.well-known/jwks.json` (Ed25519, EdDSA). A token names the principal, the Surface (its kind, and in its
|
|
5
6
|
// config this server's team, the machine and the target) and `view` or `control`; its audience is this server's
|
|
6
7
|
// public origin. Verified with WebCrypto, so the same code runs in Node and in a Worker.
|
|
@@ -67,7 +68,7 @@ async function issuerKey(api, issuer, kid) {
|
|
|
67
68
|
* and the caller refuses it exactly as an unknown credential.
|
|
68
69
|
*/
|
|
69
70
|
export async function delegatedAccessOf(api, token) {
|
|
70
|
-
const claims = await verifiedClaims(api, token);
|
|
71
|
+
const claims = await verifiedClaims(api, token, 'delegation.surfaces');
|
|
71
72
|
if (!claims) return null;
|
|
72
73
|
// Short-lived by construction: an issued-at, not in the future, and no more than MAX_LIFETIME_S to its expiry.
|
|
73
74
|
const now = Math.floor(api.clock.nowMs() / 1000);
|
|
@@ -81,7 +82,7 @@ export async function delegatedAccessOf(api, token) {
|
|
|
81
82
|
// Every publication is into one Room, so a token must name the Room it opens the surface in.
|
|
82
83
|
const room = surface.room ?? claims.room ?? null;
|
|
83
84
|
if (typeof room !== 'string' || !room) return null;
|
|
84
|
-
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 };
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
/** Longest a session-view token may live, whatever it says: the workplace mints them for minutes. */
|
|
@@ -93,7 +94,7 @@ const SESSION_TOKEN_MAX_S = 15 * 60;
|
|
|
93
94
|
* viewing it; the server admits it only for a session its owner bound to that Room and Conversation.
|
|
94
95
|
*/
|
|
95
96
|
export async function delegatedSessionOf(api, token) {
|
|
96
|
-
const claims = await verifiedClaims(api, token);
|
|
97
|
+
const claims = await verifiedClaims(api, token, 'delegation.sessions');
|
|
97
98
|
if (!claims) return null;
|
|
98
99
|
const session = claims.session;
|
|
99
100
|
if (!session || typeof session.team !== 'string' || typeof session.id !== 'string' || !session.id) return null;
|
|
@@ -105,12 +106,12 @@ export async function delegatedSessionOf(api, token) {
|
|
|
105
106
|
// from what the browser sends.
|
|
106
107
|
const name = typeof claims.name === 'string' && claims.name.trim() ? claims.name.trim().slice(0, 80) : null;
|
|
107
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 };
|
|
108
|
-
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 };
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
/** A signed Room lifecycle notice. This scope grants no transcript read or user-turn control. */
|
|
112
113
|
export async function delegatedRoomNoticeOf(api, token) {
|
|
113
|
-
const c = await verifiedClaims(api, token);
|
|
114
|
+
const c = await verifiedClaims(api, token, 'delegation.notices');
|
|
114
115
|
if (!c || c.scope !== 'room-notice' || !c.session || c.sub !== c.session.agent) return null;
|
|
115
116
|
const n = c.notice;
|
|
116
117
|
if (![c.room, c.conversation, c.session.team, c.session.id, c.session.harness].every(v => typeof v === 'string' && v.length > 0 && v.length <= 256)) return null;
|
|
@@ -119,22 +120,24 @@ export async function delegatedRoomNoticeOf(api, token) {
|
|
|
119
120
|
|| typeof n.text !== 'string' || !n.text.trim() || n.text.length > 16000) return null;
|
|
120
121
|
const now = Math.floor(api.clock.nowMs() / 1000);
|
|
121
122
|
if (!Number.isFinite(c.iat) || c.exp - c.iat > SESSION_TOKEN_MAX_S || c.exp - now > SESSION_TOKEN_MAX_S) return null;
|
|
122
|
-
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,
|
|
123
124
|
roomName: typeof c.roomName === 'string' ? c.roomName.slice(0, 120) : c.room,
|
|
124
125
|
sessionRef: c.session.id, harness: c.session.harness, subject: c.sub, teamId: c.session.team, notice: n };
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
/** A token's claims when it is one this server trusts: signed by the issuer's key, for this server, in its lifetime. */
|
|
128
|
-
async function verifiedClaims(api, token) {
|
|
129
|
-
if (!api.
|
|
129
|
+
async function verifiedClaims(api, token, action) {
|
|
130
|
+
if (!api.publicOrigin || !looksDelegated(token)) return null;
|
|
130
131
|
try {
|
|
131
132
|
const [head, body, signature] = token.split('.');
|
|
132
133
|
const header = json(head);
|
|
133
134
|
if (header.alg !== 'EdDSA' || typeof header.kid !== 'string') return null;
|
|
134
|
-
// Only
|
|
135
|
+
// Only installed issuer origins may select keys; an untrusted claim never triggers a fetch.
|
|
135
136
|
// Cache by issuer as well as kid so staging cannot reuse a production key identifier.
|
|
136
137
|
const claims = json(body);
|
|
137
|
-
|
|
138
|
+
const teamId=claims.surface?.config?.team??claims.session?.team;
|
|
139
|
+
if(typeof teamId!=='string'||claims.aud!==api.publicOrigin)return null;
|
|
140
|
+
const installation=await installedIssuer(api,claims.iss,action,teamId);if(!installation||!Number.isFinite(claims.iat)||claims.iat<Math.floor(installation.created_at/1000))return null;
|
|
138
141
|
const key = await issuerKey(api, claims.iss, header.kid);
|
|
139
142
|
if (!key) return null;
|
|
140
143
|
const signed = new TextEncoder().encode(`${head}.${body}`);
|
|
@@ -142,7 +145,7 @@ async function verifiedClaims(api, token) {
|
|
|
142
145
|
const now = Math.floor(api.clock.nowMs() / 1000);
|
|
143
146
|
if (typeof claims.exp !== 'number' || claims.exp <= now - SKEW_S || (typeof claims.nbf === 'number' && claims.nbf > now + SKEW_S)) return null;
|
|
144
147
|
if (typeof claims.sub !== 'string' || !claims.sub) return null;
|
|
145
|
-
return claims;
|
|
148
|
+
return {...claims, installation_id:installation.id,installed_at:installation.created_at};
|
|
146
149
|
} catch {
|
|
147
150
|
return null;
|
|
148
151
|
}
|
package/server/http.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { appRoute, appPreAuth, appAuthentication, enforceAppScope, appAuthGuards, installedIssuer } from './apps.mjs';
|
|
1
2
|
import { machineEventStream } from './machine-events.mjs';
|
|
2
3
|
import { randomBytes } from 'node:crypto';
|
|
3
4
|
import { bearer, hashToken, canManage, isAdmin, makeToken } from './auth.mjs';
|
|
@@ -13,10 +14,10 @@ class HttpError extends Error { constructor(status,code,message,details=null){su
|
|
|
13
14
|
const fail=(status,code,message,details)=>{throw new HttpError(status,code,message,details)};
|
|
14
15
|
const parseJson=req=>parseRequestJson(req,fail);
|
|
15
16
|
const result=(api,auth,data)=>({version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},data});
|
|
16
|
-
function sideEffectStatements(api,res,status,body,now,targetId=null,journal=false){const statements=[];if(status<400&&res._idempotencyRecord){const record=res._idempotencyRecord,safe=redactSecretReplay(record.path,body);statements.push({sql:'INSERT INTO idempotency(team_id,principal_id,route,key,request_digest,response_status,response_json,expires_at) VALUES (?,?,?,?,?,?,?,?)',params:[record.teamId,record.principalId,record.route,record.key,record.requestDigest,status,journal?{$journalJsonValue:safe}:JSON.stringify(safe),now+86400000]});}if(res._auditRecord){const record=res._auditRecord;statements.push({sql:'INSERT INTO audit(team_id,id,actor_id,operation,target_id,outcome,revision,created_at) VALUES (
|
|
17
|
+
function sideEffectStatements(api,res,status,body,now,targetId=null,journal=false){const statements=[];if(status<400&&res._idempotencyRecord){const record=res._idempotencyRecord,safe=redactSecretReplay(record.path,body);statements.push({sql:'INSERT INTO idempotency(team_id,principal_id,route,key,request_digest,response_status,response_json,expires_at) VALUES (?,?,?,?,?,?,?,?)',params:[record.teamId,record.principalId,record.route,record.key,record.requestDigest,status,journal?{$journalJsonValue:safe}:JSON.stringify(safe),now+86400000]});}if(res._auditRecord){const record=res._auditRecord;statements.push({sql:'INSERT INTO audit(team_id,id,actor_id,operation,target_id,outcome,revision,created_at,actor_json) VALUES (?,?,?,?,?,?,?,?,?)',params:[record.teamId,api.ids.next('audit'),record.actorId,record.operation,targetId,status<400?'allowed':'denied',journal?{$journalRevision:true}:null,now,JSON.stringify(record.act??[])]});}return statements;}
|
|
17
18
|
async function persistSideEffects(api,res,status,body){const statements=sideEffectStatements(api,res,status,body,api.clock.nowMs());if(statements.length)await api.store.transaction(statements);}
|
|
18
19
|
function send(res,status,body,requestId,headers={}){const deliver=()=>{res.writeHead(status,{'Content-Type':'application/json; charset=utf-8','Cache-Control':'no-store','X-Request-ID':requestId,...headers});res.end(body===null?'':JSON.stringify(body));};if(!res._sideEffectsCommitted&&res._api&&(res._idempotencyRecord||res._auditRecord))return persistSideEffects(res._api,res,status,body).then(deliver);deliver();}
|
|
19
|
-
async function guardedMutation(api,res,auth,guards,statements,{status,body,targetId=null,journal=false}){const now=api.clock.nowMs(),all=[...statements,...sideEffectStatements(api,res,status,body,now,targetId,journal)],result=journal?await api.store.journalTransaction(auth.team_id,guards,all):await api.store.guardedTransaction(guards,all);res._sideEffectsCommitted=true;return result;}
|
|
20
|
+
async function guardedMutation(api,res,auth,guards,statements,{status,body,targetId=null,journal=false}){const now=api.clock.nowMs(),all=[...statements,...sideEffectStatements(api,res,status,body,now,targetId,journal)],result=journal?await api.store.journalTransaction(auth.team_id,[...guards,...appAuthGuards(auth,now)],all):await api.store.guardedTransaction([...guards,...appAuthGuards(auth,now)],all);res._sideEffectsCommitted=true;return result;}
|
|
20
21
|
const requireRevision=(req,current,label='resource')=>{const expected=Number(String(req.headers['if-match']??'').replaceAll('"',''));if(!Number.isSafeInteger(expected))fail(400,'invalid_request','If-Match is required');if(expected!==current)fail(409,'revision_mismatch',`${label} revision changed`);return expected;};
|
|
21
22
|
const CORS={'Access-Control-Allow-Origin':'*','Access-Control-Expose-Headers':'ETag, X-Request-ID, Content-Range'};
|
|
22
23
|
const esc=(x)=>String(x).replace(/[\\%_]/g,'\\$&');
|
|
@@ -30,8 +31,11 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
|
|
|
30
31
|
const url=new URL(req.url,'http://local'); const path=url.pathname;
|
|
31
32
|
// The hosted viewer's files (viewer.mjs): public; the page's delegated token is checked at every door it opens.
|
|
32
33
|
if(req.method==='GET'&&(path==='/view'||path.startsWith('/view/'))&&await serveViewer(res,path))return;
|
|
34
|
+
if(path==='/api/v1/apps/token')checkRate(api,`app-exchange:${req.socket.remoteAddress}`,10,5);
|
|
35
|
+
if(await appPreAuth({api,req,res,path,requestId,parseJson,send}))return;
|
|
33
36
|
if(api.harnessRoutes&&await api.harnessRoutes.preAuth({api,req,res,path,requestId}))return;
|
|
34
37
|
const auth=await authenticate(api,bearer(req.headers.authorization));res._authenticated=Boolean(auth);
|
|
38
|
+
if(auth?.installation_id){const scoped=/^\/api\/v1\/teams\/([^/]+)(\/.*)?$/.exec(path);if(scoped&&scoped[1]!==auth.team_id)fail(403,'forbidden','app token belongs to another organization');const target=scoped?.[2]??path.replace(/^\/api\/v1/,'');if(target===`/app-installations/${auth.installation_id}/delegations`&&req.method==='POST'){}else enforceAppScope(auth,req.method,target);}
|
|
35
39
|
if(api.harnessRoutes&&await api.harnessRoutes.authed({api,req,res,auth,path,requestId}))return;
|
|
36
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);}
|
|
37
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,12 +46,12 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
|
|
|
42
46
|
checkRate(api,`credential:${auth.credential_id}:${bulk?'bulk':'ordinary'}`,bulk||auth.kind==='machine'?600:120,bulk||auth.kind==='machine'?60:30);
|
|
43
47
|
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);}
|
|
44
48
|
if(req.method==='POST'&&path==='/api/v1/me/identities')return await linkIdentity(api,req,res,auth,requestId);
|
|
45
|
-
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'
|
|
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);
|
|
46
50
|
if(req.method==='GET'&&path==='/api/v1/teams')return await listTeams(api,res,auth,url,requestId);
|
|
47
51
|
if(req.method==='POST'&&path==='/api/v1/teams')return await createTeam(api,req,res,auth,path,requestId);
|
|
48
52
|
const match=/^\/api\/v1\/teams\/([^/]+)(\/.*)?$/.exec(path);if(!match)fail(404,'not_found','not found');const teamId=match[1],sub=match[2]??'';
|
|
49
53
|
if(auth.kind==='user'&&auth.team_id===null){const membership=await api.store.one('SELECT role,active FROM memberships WHERE team_id=? AND principal_id=?',[teamId,auth.principal_id]);if(!membership?.active)fail(401,'authentication_required','authentication required');auth.role=membership.role;auth.active=membership.active;auth.team_id=teamId;auth.group_ids=(await api.store.query('SELECT group_id FROM group_members WHERE team_id=? AND principal_id=?',[teamId,auth.principal_id])).map(x=>x.group_id);}else if(auth.team_id!==teamId||!auth.active)fail(401,'authentication_required','authentication required');
|
|
50
|
-
if(req.method!=='GET')res._auditRecord={teamId:auth.team_id,actorId:auth.principal_id,operation:`${req.method} ${path}`};
|
|
54
|
+
if(req.method!=='GET')res._auditRecord={teamId:auth.team_id,actorId:auth.principal_id,act:auth.act??[],operation:`${req.method} ${path}`};
|
|
51
55
|
if(['machine','ingest'].includes(auth.kind)){
|
|
52
56
|
const machineDoor=auth.kind==='machine'&&(sub==='/machines/events'||/^\/machines\/[^/]+\/configuration$/.test(sub)||sub===`/machines/${auth.machine_id}/heartbeat`||sub.startsWith(`/machines/${auth.machine_id}/effects`)||new RegExp(`^/machines/${auth.machine_id}/attachments/[^/]+(?:/frames)?$`).test(sub));
|
|
53
57
|
const sourceDoor=sub.startsWith(`/sources/${auth.source_id}/captures`)||sub.startsWith(`/sources/${auth.source_id}/uploads`);
|
|
@@ -56,6 +60,7 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
|
|
|
56
60
|
let m;if(api.harnessRoutes&&await api.harnessRoutes.beforeIdempotency({api,req,res,auth,url,path,sub,requestId}))return;
|
|
57
61
|
const intrinsicPart=req.method==='PUT'&&(/\/captures\/[^/]+\/parts\/\d+$/.test(sub)||/\/uploads\/[^/]+\/chunks\/\d+$/.test(sub)),intrinsicReplay=intrinsicPart||(req.method==='POST'&&(/^\/sources\/[^/]+\/captures$/.test(sub)||/\/captures\/[^/]+\/commit$/.test(sub)||/\/effects\/[^/]+\/(?:claim|result)$/.test(sub)));
|
|
58
62
|
if(['POST','PATCH','DELETE'].includes(req.method)&&!intrinsicPart){const key=req.headers['idempotency-key'];if(typeof key!=='string'||key.length<16||key.length>128||!/^[\x20-\x7e]+$/.test(key))fail(400,'invalid_request','Idempotency-Key must be 16..128 printable ASCII characters');if(!intrinsicReplay){const body=req.method==='DELETE'?null:await parseJson(req),route=`${req.method} ${path}`,requestDigest=sha256(canonicalJson({method:req.method,path,if_match:req.headers['if-match']??null,body})),old=await api.store.one('SELECT * FROM idempotency WHERE team_id=? AND principal_id=? AND route=? AND key=? AND expires_at>?',[auth.team_id,auth.principal_id,route,key,api.clock.nowMs()]);if(old){if(old.request_digest!==requestDigest)fail(409,'idempotency_conflict','idempotency key was reused with a different request');await authorizeReplay(api,auth,req.method,sub,body);return send(res,old.response_status,JSON.parse(old.response_json),requestId);}res._idempotencyRecord={teamId:auth.team_id,principalId:auth.principal_id,route,key,requestDigest,path};}}
|
|
63
|
+
if(await appRoute({api,req,res,auth,sub,requestId,parseJson,send,result,guardedMutation}))return;
|
|
59
64
|
if(req.method==='POST'&&sub==='/invitations')return await createInvitation(api,req,res,auth,requestId);
|
|
60
65
|
if(req.method==='POST'&&sub==='/credentials')return await createCredential(api,req,res,auth,requestId);
|
|
61
66
|
if(req.method==='DELETE'&&(m=/^\/credentials\/([^/]+)$/.exec(sub)))return await revokeCredential(api,req,res,auth,m[1],requestId);
|
|
@@ -85,11 +90,12 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
|
|
|
85
90
|
fail(404,'not_found','not found');
|
|
86
91
|
}catch(error){const code=error.code??(error instanceof ContractError?'invalid_request':'internal_error');const status=error.status??({invalid_request:400,authentication_required:401,forbidden:403,not_found:404,source_revoked:403,session_deleted:410,request_too_large:413,batch_gap:409,capture_conflict:409,source_paused:409,resync_required:409,revision_mismatch:409,conflict:409,rate_limited:429}[code]??500);if(!res._auditRecord){api.securityEvents.push({at:api.clock.nowMs(),request_id:requestId,status,code,authenticated:Boolean(res._authenticated)});if(api.securityEvents.length>1000)api.securityEvents.shift();}send(res,status,validationFailure(Object.assign(error,{code}),requestId),requestId);}};}
|
|
87
92
|
function checkRate(api,key,perMinute,burst){const now=api.clock.nowMs(),state=api.rateState.get(key)??{tokens:burst,at:now};state.tokens=Math.min(burst,state.tokens+(now-state.at)*(perMinute/60000));state.at=now;if(state.tokens<1)fail(429,'rate_limited','rate limit exceeded',{retry_after_seconds:Math.ceil((1-state.tokens)/(perMinute/60000)/1000)});state.tokens-=1;api.rateState.set(key,state);}
|
|
88
|
-
function redactSecretReplay(path,body){if(body===null||typeof body!=='object')return body;const copy=structuredClone(body);if(path.endsWith('/invitations')&©.data)delete copy.data.enrollment_code;if(path.endsWith('/machines')&©.data)copy.data.credential=null;if(path.endsWith('/credentials'))copy.data=null;return copy;}
|
|
93
|
+
function redactSecretReplay(path,body){if(body===null||typeof body!=='object')return body;const copy=structuredClone(body);if(path.endsWith('/invitations')&©.data)delete copy.data.enrollment_code;if(path.endsWith('/machines')&©.data)copy.data.credential=null;if(path.endsWith('/credentials')||path.endsWith('/delegations'))copy.data=null;if((path.endsWith('/apps')||path.endsWith('/secret'))&©.data)delete copy.data.secret;if(path.endsWith('/installations')&©.data)delete copy.data.code;return copy;}
|
|
89
94
|
|
|
90
95
|
async function authorizeReplay(api,auth,method,sub,body){
|
|
91
96
|
const now=api.clock.nowMs();
|
|
92
97
|
let match;
|
|
98
|
+
if(sub.startsWith('/apps')||sub.startsWith('/app-installations')){if(auth.installation_id&&sub===`/app-installations/${auth.installation_id}/delegations`&&method==='POST')return;if(!isAdmin(auth)||auth.installation_id)fail(403,'forbidden','application admin authorization changed');return;}
|
|
93
99
|
if((match=/^\/sources\/([^/]+)\/captures$/.exec(sub))){const source=await api.store.one('SELECT state,machine_id,visibility FROM sources WHERE team_id=? AND id=?',[auth.team_id,match[1]]);if(!source||source.state!=='active'||auth.source_id!==match[1])fail(403,'forbidden','source authorization changed');if(auth.kind==='machine'){const rule=await api.store.one("SELECT 1 ok FROM machines m JOIN sync_rules r ON r.team_id=m.team_id AND r.machine_id=m.id WHERE m.team_id=? AND m.id=? AND m.revoked_at IS NULL AND r.id=? AND r.source_id=? AND r.state='active' AND r.revision=? AND r.visibility=?",[auth.team_id,auth.machine_id,body?.rule_id,match[1],body?.rule_revision,body?.visibility]);if(!rule)fail(403,'forbidden','sync rule authorization changed');}else if(auth.kind!=='ingest'||body?.visibility!==source.visibility)fail(403,'forbidden','source policy changed');return;}
|
|
94
100
|
if((match=/^\/machines\/([^/]+)\/heartbeat$/.exec(sub))&&auth.kind==='machine'){const machine=await api.store.one('SELECT 1 ok FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL',[auth.team_id,match[1]]);if(!machine||auth.machine_id!==match[1])fail(403,'forbidden','machine authorization changed');return;}
|
|
95
101
|
if(!['user','service'].includes(auth.kind))fail(403,'forbidden','credential kind cannot replay this operation');
|
|
@@ -105,7 +111,7 @@ async function authorizeReplay(api,auth,method,sub,body){
|
|
|
105
111
|
if(sub==='/correlations'&&method==='POST'){if(auth.role==='viewer')fail(403,'forbidden','correlation authorization changed');return;}
|
|
106
112
|
}
|
|
107
113
|
|
|
108
|
-
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 auth;}
|
|
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.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);}
|
|
109
115
|
async function grants(api,team,id){return api.store.query('SELECT * FROM session_grants WHERE team_id=? AND session_id=?',[team,id]);}
|
|
110
116
|
const authGuards=(auth,_admissionNow)=>{
|
|
111
117
|
const now=auth.clock?.nowMs()??_admissionNow;
|
|
@@ -131,7 +137,7 @@ async function createTeam(api,req,res,auth,path,requestId){
|
|
|
131
137
|
}
|
|
132
138
|
async function createCredential(api,req,res,auth,requestId){
|
|
133
139
|
if(auth.kind!=='user')fail(403,'forbidden','only users may issue credentials');const body=await parseJson(req);exact(body,['kind','expires_in_ms']);const maxExpiry=body.kind==='service'?7776000000:2592000000;if(!['user','service'].includes(body.kind)||!Number.isSafeInteger(body.expires_in_ms)||body.expires_in_ms<1||body.expires_in_ms>maxExpiry)fail(400,'invalid_request','invalid credential request');
|
|
134
|
-
const id=api.ids.next('credential'),token=makeToken(id),now=api.clock.nowMs(),expiresAt=now+body.expires_in_ms,response=result(api,auth,{token,kind:body.kind,expires_at:expiresAt});await guardedMutation(api,res,auth,authGuards(auth,now),[{sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?)',params:[id,hashToken(token),auth.principal_id,body.kind,body.kind==='user'?null:auth.team_id,expiresAt,now]}],{status:201,body:response,targetId:id});send(res,201,response,requestId);
|
|
140
|
+
const id=api.ids.next('credential'),token=makeToken(id),now=api.clock.nowMs(),expiresAt=now+body.expires_in_ms,response=result(api,auth,{token,kind:body.kind,expires_at:expiresAt});await guardedMutation(api,res,auth,authGuards(auth,now),[{sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?)',params:[id,hashToken(token),auth.principal_id,body.kind,body.kind==='user'&&!auth.identity_installation_id?null:auth.team_id,expiresAt,now]},...(auth.identity_installation_id?[{sql:'INSERT INTO identity_app_credentials(credential_id,installation_id) VALUES (?,?)',params:[id,auth.identity_installation_id]}]:[])],{status:201,body:response,targetId:id});send(res,201,response,requestId);
|
|
135
141
|
}
|
|
136
142
|
async function revokeCredential(api,req,res,auth,id,requestId){
|
|
137
143
|
if(!['user','service'].includes(auth.kind))fail(403,'forbidden','credential kind cannot use this operation');const credential=await api.store.one('SELECT * FROM credentials WHERE id=? AND revoked_at IS NULL',[id]);if(!credential)fail(404,'not_found','not found');const globalUser=credential.kind==='user'&&credential.team_id===null;if(globalUser?credential.principal_id!==auth.principal_id:credential.team_id!==auth.team_id||credential.principal_id!==auth.principal_id&&!isAdmin(auth))fail(404,'not_found','not found');
|
|
@@ -140,7 +146,7 @@ async function revokeCredential(api,req,res,auth,id,requestId){
|
|
|
140
146
|
async function createInvitation(api,req,res,auth,requestId){if(!isAdmin(auth))fail(403,'forbidden','forbidden');const body=await parseJson(req);exact(body,['display_name','role','expires_in_ms']);if(!['admin','member','viewer'].includes(body.role)||!Number.isSafeInteger(body.expires_in_ms)||body.expires_in_ms<1||body.expires_in_ms>604800000)fail(400,'invalid_request','invalid invitation');const pid=api.ids.next('user'),iid=api.ids.next('invitation'),secret=`sci_${iid}.${randomBytes(32).toString('base64url')}`,now=api.clock.nowMs(),exp=now+body.expires_in_ms,response=result(api,auth,{id:iid,role:body.role,expires_at:exp,enrollment_code:secret});await guardedMutation(api,res,auth,authGuards(auth,now),[{sql:'INSERT INTO principals(id,kind,display_name) VALUES (?,?,?)',params:[pid,'user',body.display_name]},{sql:'INSERT INTO invitations(team_id,id,principal_id,secret_hash,role,expires_at) VALUES (?,?,?,?,?,?)',params:[auth.team_id,iid,pid,hashToken(secret),body.role,exp]}],{status:201,body:response,targetId:iid});send(res,201,response,requestId);}
|
|
141
147
|
async function acceptInvite(api,res,body,requestId){exact(body,['enrollment_code','access_token']);
|
|
142
148
|
// Accepted with an identity service's token, the new member is linked to it from the start.
|
|
143
|
-
const person=body.access_token===undefined?null:await identityOf(api,body.access_token);if(body.access_token!==undefined&&!person)fail(401,'authentication_required','the identity token did not verify');const digest=hashToken(body.enrollment_code??'');const inv=await api.store.one('SELECT i.*,p.display_name,t.name team_name,t.revision team_revision FROM invitations i JOIN principals p ON p.id=i.principal_id JOIN teams t ON t.id=i.team_id WHERE i.secret_hash=?',[digest]),now=api.clock.nowMs();if(!inv||inv.consumed_at!=null||inv.revoked_at!=null||inv.expires_at<=now)fail(401,'authentication_required','invalid invitation');const cid=api.ids.next('credential'),token=makeToken(cid);await api.store.guardedTransaction([{sql:'SELECT 1 ok FROM invitations WHERE id=? AND consumed_at IS NULL AND revoked_at IS NULL AND expires_at>?',params:[inv.id,now],code:'authentication_required',status:401,message:'invalid invitation'}],[{sql:'UPDATE invitations SET consumed_at=? WHERE id=?',params:[now,inv.id]},{sql:'INSERT INTO memberships(team_id,principal_id,role) VALUES (?,?,?)',params:[inv.team_id,inv.principal_id,inv.role]},...(person?[{sql:'INSERT INTO principal_identities(issuer,subject,principal_id,linked_at) VALUES (?,?,?,?)',params:[person.issuer,person.subject,inv.principal_id,now]}]:[]),{sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?)',params:[cid,hashToken(token),inv.principal_id,'user',null,now+2592000000,now]},{sql:'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',params:[inv.team_id]}]);send(res,201,{principal:{id:inv.principal_id,kind:'user',display_name:inv.display_name},credential:{token,kind:'user',expires_at:now+2592000000},team:{id:inv.team_id,name:inv.team_name,revision:inv.team_revision}},requestId);}
|
|
149
|
+
const person=body.access_token===undefined?null:await identityOf(api,body.access_token);if(body.access_token!==undefined&&!person)fail(401,'authentication_required','the identity token did not verify');const digest=hashToken(body.enrollment_code??'');const inv=await api.store.one('SELECT i.*,p.display_name,t.name team_name,t.revision team_revision FROM invitations i JOIN principals p ON p.id=i.principal_id JOIN teams t ON t.id=i.team_id WHERE i.secret_hash=?',[digest]),now=api.clock.nowMs();if(!inv||inv.consumed_at!=null||inv.revoked_at!=null||inv.expires_at<=now)fail(401,'authentication_required','invalid invitation');const installation=person?await installedIssuer(api,person.issuer,'identity.login',inv.team_id):null;if(person&&!installation)fail(401,'authentication_required','identity app is not installed in the invitation organization');const cid=api.ids.next('credential'),token=makeToken(cid);await api.store.guardedTransaction([{sql:'SELECT 1 ok FROM invitations WHERE id=? AND consumed_at IS NULL AND revoked_at IS NULL AND expires_at>?',params:[inv.id,now],code:'authentication_required',status:401,message:'invalid invitation'},...(installation?[{sql:"SELECT 1 FROM app_installations WHERE id=? AND state='active'",params:[installation.id],code:'authentication_required',status:401,message:'identity app was uninstalled'}]:[])],[{sql:'UPDATE invitations SET consumed_at=? WHERE id=?',params:[now,inv.id]},{sql:'INSERT INTO memberships(team_id,principal_id,role) VALUES (?,?,?)',params:[inv.team_id,inv.principal_id,inv.role]},...(person?[{sql:'INSERT INTO principal_identities(issuer,subject,principal_id,linked_at) VALUES (?,?,?,?)',params:[person.issuer,person.subject,inv.principal_id,now]}]:[]),{sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?)',params:[cid,hashToken(token),inv.principal_id,'user',installation?inv.team_id:null,now+2592000000,now]},...(installation?[{sql:'INSERT INTO identity_app_credentials(credential_id,installation_id) VALUES (?,?)',params:[cid,installation.id]}]:[]),{sql:'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',params:[inv.team_id]}]);send(res,201,{principal:{id:inv.principal_id,kind:'user',display_name:inv.display_name},credential:{token,kind:'user',expires_at:now+2592000000},team:{id:inv.team_id,name:inv.team_name,revision:inv.team_revision}},requestId);}
|
|
144
150
|
async function removeMember(api,req,res,auth,pid,requestId){if(!isAdmin(auth))fail(403,'forbidden','forbidden');const target=await api.store.one('SELECT * FROM memberships WHERE team_id=? AND principal_id=? AND active=1',[auth.team_id,pid]);if(!target)fail(404,'not_found','not found');requireRevision(req,target.revision,'membership');if(target.role==='owner'&&auth.role!=='owner')fail(403,'forbidden','only owners may remove owners');const published=publisherRevocation(api,auth.team_id,pid),now=api.clock.nowMs(),guards=[...authGuards(auth,now),{sql:"SELECT 1 ok FROM memberships WHERE team_id=? AND principal_id=? AND active=1 AND revision=? AND (role!='owner' OR (SELECT count(*) FROM memberships WHERE team_id=? AND role='owner' AND active=1)>1)",params:[auth.team_id,pid,target.revision,auth.team_id],code:'conflict',status:409,message:'membership changed or last owner would be removed'}],statements=[{sql:'UPDATE memberships SET active=0,revision=revision+1 WHERE team_id=? AND principal_id=?',params:[auth.team_id,pid]},{sql:'UPDATE credentials SET revoked_at=? WHERE team_id=? AND principal_id=?',params:[now,auth.team_id,pid]},{sql:"UPDATE sources SET state='paused',revision=revision+1 WHERE team_id=? AND custodian_id=? AND state='active'",params:[auth.team_id,pid]},{sql:'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',params:[auth.team_id]},published.statement];await guardedMutation(api,res,auth,guards,statements,{status:204,body:null,targetId:pid});api.events.publish(auth.team_id,{type:'authorization_changed',principal_id:pid});closeChannelsFor(api,auth.team_id,{principalId:pid,reason:'membership_removed'});await published.closeChannels();send(res,204,null,requestId);}
|
|
145
151
|
/** Federation (P02): a verified identity token is a member's sign-in, answered with the same 30-day credential an invitation gives. */
|
|
146
152
|
async function identityLogin(api,res,body,requestId){
|
|
@@ -155,8 +161,9 @@ async function identityLogin(api,res,body,requestId){
|
|
|
155
161
|
// with no invitation (identity-orgs.mjs): they join as members, exactly as an accepted invitation would add them.
|
|
156
162
|
if(!row)row=await joinThroughOrganization(api,person,body.team_id)&&await memberRow();
|
|
157
163
|
if(!row)fail(401,'authentication_required','no member of this team is linked to that identity; accept an invitation with it, or link it while signed in');
|
|
164
|
+
const installation=await installedIssuer(api,person.issuer,'identity.login',row.team_id);if(!installation)fail(401,'authentication_required','identity app is not installed in this organization');
|
|
158
165
|
const now=api.clock.nowMs(),cid=api.ids.next('credential'),token=makeToken(cid);
|
|
159
|
-
await api.store.
|
|
166
|
+
await api.store.guardedTransaction([{sql:"SELECT 1 FROM app_installations i JOIN apps a ON a.id=i.app_id WHERE i.id=? AND i.state='active' AND a.state='active'",params:[installation.id],code:'authentication_required',status:401,message:'identity app was uninstalled'}],[{sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?)',params:[cid,hashToken(token),row.principal_id,'user',row.team_id,now+2592000000,now]},{sql:'INSERT INTO identity_app_credentials(credential_id,installation_id) VALUES (?,?)',params:[cid,installation.id]}]);
|
|
160
167
|
send(res,201,{principal:{id:row.principal_id,kind:'user',display_name:row.display_name},credential:{token,kind:'user',expires_at:now+2592000000},team:{id:row.team_id,name:row.team_name,revision:row.team_revision}},requestId);
|
|
161
168
|
}
|
|
162
169
|
/** The one team a signed-in person belongs to, as a member or through a linked organization. */
|
|
@@ -174,12 +181,13 @@ async function teamsOfPerson(api,person){
|
|
|
174
181
|
const ever=new Set((await api.store.query('SELECT m.team_id FROM principal_identities i JOIN memberships m ON m.principal_id=i.principal_id WHERE i.issuer=? AND i.subject=?',[person.issuer,person.subject])).map(row=>row.team_id));
|
|
175
182
|
const linked=[];
|
|
176
183
|
// An organization the door cannot answer for offers no team: the teams the person is a member of still list.
|
|
177
|
-
for(const team of await api.store.query('SELECT id,name FROM teams ORDER BY name',[])){if(ever.has(team.id))continue;const slug=linkedOrganization(api,team);if(slug&&await inOrganization(api,person.subject,slug).catch(()=>false))linked.push(team);}
|
|
184
|
+
for(const team of await api.store.query('SELECT id,name FROM teams ORDER BY name',[])){if(ever.has(team.id)||!await installedIssuer(api,person.issuer,'identity.login',team.id))continue;const slug=linkedOrganization(api,team);if(slug&&await inOrganization({...api,identityIssuer:person.issuer},person.subject,slug).catch(()=>false))linked.push(team);}
|
|
178
185
|
return {member,linked};
|
|
179
186
|
}
|
|
180
187
|
/** The teams a signed-in person is an active member of, with their role. */
|
|
181
|
-
function teamsOfMember(api,person){
|
|
182
|
-
|
|
188
|
+
async function teamsOfMember(api,person){
|
|
189
|
+
const rows=await api.store.query('SELECT t.id,t.name,m.role FROM principal_identities i JOIN memberships m ON m.principal_id=i.principal_id AND m.active=1 JOIN teams t ON t.id=m.team_id WHERE i.issuer=? AND i.subject=? ORDER BY t.name',[person.issuer,person.subject]);
|
|
190
|
+
const permitted=[];for(const row of rows)if(await installedIssuer(api,person.issuer,'identity.login',row.id))permitted.push(row);return permitted;
|
|
183
191
|
}
|
|
184
192
|
/** Which teams a verified identity token may sign in to, so a person on several chooses rather than being refused. */
|
|
185
193
|
async function identityTeams(api,res,body,requestId){
|
|
@@ -190,9 +198,9 @@ async function identityTeams(api,res,body,requestId){
|
|
|
190
198
|
}
|
|
191
199
|
/** Make `person` a member of team `team` when the team is linked to an organization they belong to; true if joined. */
|
|
192
200
|
async function joinThroughOrganization(api,person,team){
|
|
193
|
-
const found=await api.store.one('SELECT id,name FROM teams WHERE id=? OR name=?',[team,team]);if(!found)return false;
|
|
201
|
+
const found=await api.store.one('SELECT id,name FROM teams WHERE id=? OR name=?',[team,team]);if(!found||!await installedIssuer(api,person.issuer,'identity.login',found.id))return false;
|
|
194
202
|
const slug=linkedOrganization(api,found);if(!slug)return false;
|
|
195
|
-
if(!await inOrganization(api,person.subject,slug))return false;
|
|
203
|
+
if(!await inOrganization({...api,identityIssuer:person.issuer},person.subject,slug))return false;
|
|
196
204
|
const now=api.clock.nowMs(),linked=await api.store.one('SELECT principal_id FROM principal_identities WHERE issuer=? AND subject=?',[person.issuer,person.subject]);
|
|
197
205
|
const pid=linked?.principal_id??api.ids.next('user');
|
|
198
206
|
const statements=[];
|
package/server/identity.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { installedIssuer } from './apps.mjs';
|
|
1
2
|
// Federation (protocol P02): a person signed in with an identity service
|
|
2
3
|
// (Volter, https://id.volter.ai) proves who they are with an access token
|
|
3
4
|
// whose audience is this server's public origin, verified from the issuer's
|
|
@@ -7,9 +8,12 @@ import { verifyIdentityToken } from '@volter/identity';
|
|
|
7
8
|
|
|
8
9
|
/** The issuer and subject a token names, or null when it does not verify or no issuer is configured. */
|
|
9
10
|
export async function identityOf(api, token) {
|
|
10
|
-
if (!api.
|
|
11
|
+
if (!api.publicOrigin || typeof token !== 'string' || !token || token.length>32768) return null;
|
|
11
12
|
try {
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
// Unverified iss only selects an already installed issuer; it never chooses an arbitrary key URL.
|
|
14
|
+
const claims=JSON.parse(Buffer.from(token.split('.')[1]??'', 'base64url').toString('utf8'));
|
|
15
|
+
if(typeof claims.iss!=='string'||!await installedIssuer(api,claims.iss,'identity.login'))return null;
|
|
16
|
+
const person=await verifyIdentityToken(token,{issuer:claims.iss,audience:api.publicOrigin});
|
|
17
|
+
return {issuer:claims.iss,subject:person.subject,email:person.email??null,name:person.name??null};
|
|
14
18
|
} catch { return null; }
|
|
15
19
|
}
|
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
|
}
|
|
@@ -157,11 +168,11 @@ export async function authorizeMachineUpgrade(api, { authenticate, url, headers
|
|
|
157
168
|
const presented = bearerFrom(headers);
|
|
158
169
|
// A Room member typing into a session bound to their Room: the session names the machine, the server finds it.
|
|
159
170
|
const session = /^\/api\/v1\/teams\/([^/]+)\/sessions\/([^/]+)\/channel$/.exec(pathname);
|
|
160
|
-
if (session) return
|
|
171
|
+
if (session) return looksDelegated(presented) ? await delegatedSessionChannel(api, session[1], decodeURIComponent(session[2]), presented) : { status: 404, code: 'not_found', message: 'not found' };
|
|
161
172
|
const match = /^\/api\/v1\/teams\/([^/]+)\/machines\/([^/]+)\/(link|channel)$/.exec(pathname);
|
|
162
173
|
if (!match) return { status: 404, code: 'not_found', message: 'not found' };
|
|
163
174
|
const [, teamId, machineId, door] = match;
|
|
164
|
-
if (door === 'channel' &&
|
|
175
|
+
if (door === 'channel' && looksDelegated(presented)) return await delegatedChannel(api, teamId, machineId, presented);
|
|
165
176
|
const auth = await authenticate(api, presented);
|
|
166
177
|
if (!auth) return { status: 401, code: 'authentication_required', message: 'authentication required' };
|
|
167
178
|
if (door === 'link') {
|
|
@@ -176,6 +187,7 @@ export async function authorizeMachineUpgrade(api, { authenticate, url, headers
|
|
|
176
187
|
if (!membership?.active) return { status: 401, code: 'authentication_required', message: 'authentication required' };
|
|
177
188
|
Object.assign(auth, { team_id: teamId, role: membership.role, active: membership.active, group_ids: (await api.store.query('SELECT group_id FROM group_members WHERE team_id=? AND principal_id=?', [teamId, auth.principal_id])).map((row) => row.group_id) });
|
|
178
189
|
} else if (auth.team_id !== teamId || !auth.active) return { status: 401, code: 'authentication_required', message: 'authentication required' };
|
|
190
|
+
if(auth.installation_id){if(!auth.app_actions.includes('machines.control'))return {status:403,code:'forbidden',message:'app lacks machines.control scope'};}
|
|
179
191
|
if (!await claimFor(api, auth, machineId)) return { status: 404, code: 'not_found', message: 'machine not found' };
|
|
180
192
|
return { door, teamId, machineId, auth };
|
|
181
193
|
}
|
|
@@ -184,6 +196,16 @@ export async function authorizeMachineUpgrade(api, { authenticate, url, headers
|
|
|
184
196
|
* A delegated token's channel: its grant, minted now on exactly the published surface the token names, or the same
|
|
185
197
|
* refusal as no machine. A token for a surface nobody published here (or one since unpublished) opens nothing.
|
|
186
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
|
+
|
|
187
209
|
async function delegatedChannel(api, teamId, machineId, token) {
|
|
188
210
|
const access = await delegatedAccessOf(api, token);
|
|
189
211
|
if (!access) return { status: 401, code: 'authentication_required', message: 'authentication required' };
|
|
@@ -197,18 +219,19 @@ async function delegatedChannel(api, teamId, machineId, token) {
|
|
|
197
219
|
// The publication, and its publisher's standing, must still hold as the grant is written: an unpublish or a
|
|
198
220
|
// member removal racing this open wins.
|
|
199
221
|
await api.store.guardedTransaction([
|
|
222
|
+
installedGuard(access,'delegation.surfaces'),
|
|
200
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' },
|
|
201
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' },
|
|
202
225
|
], [
|
|
203
226
|
// Delegated grants live as long as their tokens; the expired ones go as new ones come.
|
|
204
227
|
{ sql: "DELETE FROM machine_grants WHERE team_id=? AND issuer_id LIKE 'delegated:%' AND expires_at<=?", params: [teamId, now] },
|
|
205
|
-
{ 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] },
|
|
206
229
|
]);
|
|
207
230
|
} catch (error) {
|
|
208
231
|
if (error.code === 'not_found') return { status: 404, code: 'not_found', message: 'machine not found' };
|
|
209
232
|
throw error;
|
|
210
233
|
}
|
|
211
|
-
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 } };
|
|
212
235
|
}
|
|
213
236
|
|
|
214
237
|
/**
|
|
@@ -227,12 +250,13 @@ async function delegatedSessionChannel(api, teamId, sessionRef, token) {
|
|
|
227
250
|
const now = api.clock.nowMs(), id = api.ids.next('grant'), principal = delegatedPrincipal(access);
|
|
228
251
|
const issuer = `delegated:${new URL(access.issuer).host}:session:${target.sessionId}`;
|
|
229
252
|
await api.store.guardedTransaction([
|
|
253
|
+
installedGuard(access,'delegation.sessions'),
|
|
230
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' },
|
|
231
255
|
], [
|
|
232
256
|
{ sql: "DELETE FROM machine_grants WHERE team_id=? AND issuer_id LIKE 'delegated:%' AND expires_at<=?", params: [teamId, now] },
|
|
233
|
-
{ 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] },
|
|
234
258
|
]).catch((error) => { if (error.code !== 'not_found') throw error; return null; });
|
|
235
|
-
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 } };
|
|
236
260
|
}
|
|
237
261
|
|
|
238
262
|
/**
|
|
@@ -331,13 +355,13 @@ async function acceptChannel(api, auth, machineId, duplex, closeSocket) {
|
|
|
331
355
|
} catch { closeSocket('refused'); return; }
|
|
332
356
|
const open = new Set();
|
|
333
357
|
const entry = {
|
|
334
|
-
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,
|
|
335
359
|
close: (reason) => closeSocket(reason),
|
|
336
360
|
dropStreams: (reason) => { for (const pair of open) pair.close(reason); },
|
|
337
361
|
};
|
|
338
362
|
channels(api).add(entry);
|
|
339
363
|
// A delegated channel ends when its token does; the viewer reopens it with a fresh one.
|
|
340
|
-
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;
|
|
341
365
|
channel.on('error', () => {});
|
|
342
366
|
channel.on('close', () => { if (expiry) clearTimeout(expiry); channels(api).delete(entry); entry.dropStreams('caller closed'); });
|
|
343
367
|
channel.on('stream', async (stream, accept, refuse) => {
|
|
@@ -393,8 +417,8 @@ export async function deliverSessionRoomNotice(api, access) {
|
|
|
393
417
|
if (pending.has(digest)) return pending.get(digest);
|
|
394
418
|
const delivery = (async () => {
|
|
395
419
|
const id = api.ids.next('grant'), principal = delegatedPrincipal(access), now = api.clock.nowMs();
|
|
396
|
-
await api.store.
|
|
397
|
-
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] }]);
|
|
398
422
|
try {
|
|
399
423
|
const link = await linkWithin(api, key(access.teamId, target.machineId), LINK_WAIT_MS);
|
|
400
424
|
if (!link) throw new Error('machine offline; Room notice remains pending');
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Apps own settings and foreign-id maps outside Teams. These rows are consent and credentials only.
|
|
2
|
+
export default `
|
|
3
|
+
ALTER TABLE audit ADD COLUMN actor_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(actor_json));
|
|
4
|
+
CREATE TABLE apps (
|
|
5
|
+
id TEXT PRIMARY KEY, team_id TEXT NOT NULL REFERENCES teams(id),
|
|
6
|
+
principal_id TEXT NOT NULL REFERENCES principals(id), name TEXT NOT NULL, description TEXT NOT NULL,
|
|
7
|
+
actions_json TEXT NOT NULL CHECK(json_valid(actions_json)), events_json TEXT NOT NULL CHECK(json_valid(events_json)),
|
|
8
|
+
redirect_urls_json TEXT NOT NULL CHECK(json_valid(redirect_urls_json)), webhook_url TEXT, issuer TEXT,
|
|
9
|
+
secret_hash TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('active','retired')), created_at INTEGER NOT NULL
|
|
10
|
+
) STRICT;
|
|
11
|
+
CREATE TABLE app_installations (
|
|
12
|
+
id TEXT PRIMARY KEY, app_id TEXT NOT NULL REFERENCES apps(id), team_id TEXT NOT NULL REFERENCES teams(id),
|
|
13
|
+
principal_id TEXT NOT NULL REFERENCES principals(id), installer_id TEXT NOT NULL REFERENCES principals(id),
|
|
14
|
+
actions_json TEXT NOT NULL CHECK(json_valid(actions_json)), role TEXT NOT NULL CHECK(role IN ('admin','member','viewer')),
|
|
15
|
+
state TEXT NOT NULL CHECK(state IN ('active','uninstalled')), signing_secret TEXT NOT NULL,
|
|
16
|
+
created_at INTEGER NOT NULL, uninstalled_at INTEGER, UNIQUE(app_id,team_id)
|
|
17
|
+
) STRICT;
|
|
18
|
+
CREATE TABLE app_install_codes (
|
|
19
|
+
code_hash TEXT PRIMARY KEY, installation_id TEXT NOT NULL REFERENCES app_installations(id),
|
|
20
|
+
redirect_url TEXT NOT NULL, expires_at INTEGER NOT NULL, consumed_at INTEGER
|
|
21
|
+
) STRICT;
|
|
22
|
+
CREATE TABLE app_credentials (
|
|
23
|
+
credential_id TEXT PRIMARY KEY REFERENCES credentials(id), installation_id TEXT NOT NULL REFERENCES app_installations(id),
|
|
24
|
+
parent_id TEXT REFERENCES credentials(id), actions_json TEXT NOT NULL CHECK(json_valid(actions_json)),
|
|
25
|
+
actor_json TEXT NOT NULL CHECK(json_valid(actor_json))
|
|
26
|
+
) STRICT;
|
|
27
|
+
CREATE TABLE identity_app_credentials (
|
|
28
|
+
credential_id TEXT PRIMARY KEY REFERENCES credentials(id), installation_id TEXT NOT NULL REFERENCES app_installations(id)
|
|
29
|
+
) STRICT;
|
|
30
|
+
CREATE TABLE app_webhook_deliveries (
|
|
31
|
+
id TEXT PRIMARY KEY, installation_id TEXT NOT NULL REFERENCES app_installations(id), event_id TEXT NOT NULL,
|
|
32
|
+
body TEXT NOT NULL CHECK(json_valid(body)), state TEXT NOT NULL CHECK(state IN ('pending','delivered','failed','cancelled')),
|
|
33
|
+
attempts INTEGER NOT NULL DEFAULT 0, next_at INTEGER NOT NULL, lease_until INTEGER, created_at INTEGER NOT NULL,
|
|
34
|
+
last_status INTEGER, UNIQUE(installation_id,event_id)
|
|
35
|
+
) STRICT;
|
|
36
|
+
CREATE INDEX app_delivery_due ON app_webhook_deliveries(state,next_at);
|
|
37
|
+
CREATE INDEX app_credential_installation ON app_credentials(installation_id);
|
|
38
|
+
`;
|
|
@@ -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
|
+
`;
|
|
@@ -11,7 +11,9 @@ import m009 from './009.mjs';
|
|
|
11
11
|
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
|
+
import m014 from './014.mjs';
|
|
16
|
+
export const MIGRATIONS = [m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014];
|
|
15
17
|
/** A message row's `until_revision` while its session's current version still holds it (schema 9). */
|
|
16
18
|
export const OPEN_REVISION = Number.MAX_SAFE_INTEGER;
|
|
17
19
|
/** SQL: message row `m` belongs to the version at revision ?1 (bind the revision twice). */
|
package/server/publications.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { installedIssuer } from './apps.mjs';
|
|
1
2
|
// Published surfaces: what a delegated token (./delegated.mjs) may open.
|
|
2
3
|
//
|
|
3
4
|
// /api/v1/teams/{team}/machines/{id}/publications[/{publication}] list, publish, unpublish
|
|
@@ -80,7 +81,7 @@ export async function publicationRoute({ api, req, res, auth, machineId, publica
|
|
|
80
81
|
if (!publishableTarget(body.kind, body.target)) fail(400, 'invalid_request', body.kind === 'pane' ? 'target must be one pane, pane:<key> (no wildcard)' : 'target must be one absolute folder, root:/path (not root:/, no wildcard, normalized)');
|
|
81
82
|
const workplace = typeof body.workplace === 'string' ? originOf(body.workplace) : null;
|
|
82
83
|
if (!workplace) fail(400, 'invalid_request', 'workplace must be an origin (https://host)');
|
|
83
|
-
if (!api.
|
|
84
|
+
if (!await installedIssuer(api,workplace,'delegation.surfaces',auth.team_id)) fail(400, 'invalid_request', `this server does not trust ${workplace} as a delegated issuer`);
|
|
84
85
|
if (typeof body.room !== 'string' || !body.room || body.room.length > 256) fail(400, 'invalid_request', 'room must name the workplace Room');
|
|
85
86
|
if (body.surface_key !== undefined && (typeof body.surface_key !== 'string' || !body.surface_key || body.surface_key.length > 256)) fail(400, 'invalid_request', 'surface_key must be a non-empty string');
|
|
86
87
|
const existing = await api.store.one('SELECT * FROM machine_publications WHERE team_id=? AND machine_id=? AND target=? AND workplace=? AND room=?', [auth.team_id, machineId, body.target, workplace, body.room]);
|