@volter/teams 0.1.82 → 0.1.83
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 +266 -0
- package/server/core.mjs +5 -12
- package/server/delegated.mjs +11 -8
- package/server/http.mjs +22 -14
- package/server/identity.mjs +7 -3
- package/server/machine-link.mjs +3 -2
- package/server/migrations/013.mjs +38 -0
- package/server/migrations/index.mjs +2 -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.83",
|
|
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,266 @@
|
|
|
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 } from './auth.mjs';
|
|
5
|
+
import { exact } from './request-json.mjs';
|
|
6
|
+
import { closeChannelsFor, closeDelegatedChannels, closeMachineChannels } from './machine-link.mjs';
|
|
7
|
+
import { delegatedGrantIssuer } from './publications.mjs';
|
|
8
|
+
|
|
9
|
+
export const APP_ACTIONS = Object.freeze([
|
|
10
|
+
'teams.read', 'members.read', 'members.write', 'groups.read', 'groups.write',
|
|
11
|
+
'sessions.read', 'sessions.write', 'sessions.control', 'sessions.access',
|
|
12
|
+
'machines.read', 'machines.control', 'machines.write', 'machines.access', 'sources.read', 'sources.write',
|
|
13
|
+
'identity.login', 'delegation.surfaces', 'delegation.sessions', 'delegation.notices',
|
|
14
|
+
]);
|
|
15
|
+
export const APP_EVENTS = Object.freeze(['app.registered', 'app.installed', 'app.uninstalled']);
|
|
16
|
+
const TOKEN_MS = 90 * 86400000, CODE_MS = 10 * 60000, DELEGATION_MS = 15 * 60000;
|
|
17
|
+
const error = (status, code, message) => { throw Object.assign(new Error(message), { status, code }); };
|
|
18
|
+
const statement = (sql, ...params) => ({ sql, params });
|
|
19
|
+
const guard = (sql, params, message = 'application authorization changed') => ({ sql, params, status: 403, code: 'forbidden', message });
|
|
20
|
+
const parse = JSON.parse;
|
|
21
|
+
const keyBytes = api => {
|
|
22
|
+
if (!api.appRootSecret) error(503, 'unavailable', 'the Teams root secret is required for app secret custody');
|
|
23
|
+
return createHash('sha256').update(`Teams app custody v1\0${api.appRootSecret}`).digest();
|
|
24
|
+
};
|
|
25
|
+
function seal(api, secret, id) {
|
|
26
|
+
const iv = randomBytes(12), cipher = createCipheriv('aes-256-gcm', keyBytes(api), iv);
|
|
27
|
+
cipher.setAAD(Buffer.from(id));
|
|
28
|
+
const encrypted = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
|
|
29
|
+
return [iv, cipher.getAuthTag(), encrypted].map(part => part.toString('base64url')).join('.');
|
|
30
|
+
}
|
|
31
|
+
function unseal(api, secret, id) {
|
|
32
|
+
const [iv, tag, ciphertext] = secret.split('.').map(part => Buffer.from(part, 'base64url'));
|
|
33
|
+
const cipher = createDecipheriv('aes-256-gcm', keyBytes(api), iv);
|
|
34
|
+
cipher.setAAD(Buffer.from(id)); cipher.setAuthTag(tag);
|
|
35
|
+
return Buffer.concat([cipher.update(ciphertext), cipher.final()]).toString('utf8');
|
|
36
|
+
}
|
|
37
|
+
function text(value, name, max = 256) {
|
|
38
|
+
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`);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
function secureUrl(value, name, originOnly = false) {
|
|
42
|
+
text(value, name, 2048);
|
|
43
|
+
let url; try { url = new URL(value); } catch { error(400, 'invalid_request', `${name} must be an HTTPS URL`); }
|
|
44
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
45
|
+
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' : ''}`);
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
function list(value, name, allowed, max = 40) {
|
|
49
|
+
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`);
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
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 });
|
|
53
|
+
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 });
|
|
54
|
+
const admin = auth => {
|
|
55
|
+
if (!isAdmin(auth) || auth.installation_id) error(403, 'forbidden', 'an organization admin must consent to app changes');
|
|
56
|
+
};
|
|
57
|
+
function adminGuards(auth, now) {
|
|
58
|
+
return [guard('SELECT 1 FROM credentials WHERE id=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)', [auth.credential_id, now]), guard("SELECT 1 FROM memberships WHERE team_id=? AND principal_id=? AND active=1 AND role IN ('owner','admin')", [auth.team_id, auth.principal_id])];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Restrict every ordinary door, including replay, before the existing resource evaluator. Unknown doors fail closed. */
|
|
62
|
+
export function appRouteAction(method, sub) {
|
|
63
|
+
if (method === 'GET' && (sub === '' || sub === '/me' || sub === '/capabilities' || sub === '/teams')) return 'teams.read';
|
|
64
|
+
if (/^\/members(?:\/[^/]+)?$/.test(sub)) return method === 'GET' ? 'members.read' : 'members.write';
|
|
65
|
+
if (/^\/groups(?:\/[^/]+(?:\/members\/[^/]+)?)?$/.test(sub)) return method === 'GET' ? 'groups.read' : 'groups.write';
|
|
66
|
+
if (/^\/sessions\/[^/]+\/(access|view-bindings)(?:\/[^/]+)?$/.test(sub)) return 'sessions.access';
|
|
67
|
+
if(method==='GET'&&/^\/sessions(?:\/(?:search|events|[^/]+(?:\/(?:messages|events|artifacts\/[^/]+))?))?$/.test(sub))return 'sessions.read';
|
|
68
|
+
if(['PATCH','DELETE'].includes(method)&&/^\/sessions\/[^/]+$/.test(sub))return 'sessions.write';
|
|
69
|
+
if(method==='GET'&&/^\/machines(?:\/(?:events|[^/]+(?:\/roots)?))?$/.test(sub))return 'machines.read';
|
|
70
|
+
if(/^\/machines\/[^/]+\/(?:access|grants|publications)(?:\/[^/]+)?$/.test(sub))return 'machines.access';
|
|
71
|
+
if((method==='POST'&&sub==='/machines')||(method==='DELETE'&&/^\/machines\/[^/]+$/.test(sub)))return 'machines.write';
|
|
72
|
+
if (/^\/sources(?:\/[^/]+(?:\/config)?)?$/.test(sub)) return method === 'GET' ? 'sources.read' : 'sources.write';
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
export function enforceAppScope(auth, method, sub) {
|
|
76
|
+
if (!auth?.installation_id) return;
|
|
77
|
+
const action = appRouteAction(method, sub);
|
|
78
|
+
if (!action || !auth.app_actions.includes(action)) error(403, 'forbidden', 'this operation is outside the consented app token scopes');
|
|
79
|
+
}
|
|
80
|
+
/** Atomic mutation rechecks; parent credentials cannot survive revocation through a child. */
|
|
81
|
+
export function appAuthGuards(auth, now) {
|
|
82
|
+
const installation=auth?.installation_id??auth?.identity_installation_id;if(!installation)return [];
|
|
83
|
+
const checks=[guard(`SELECT 1 FROM app_installations i JOIN apps a ON a.id=i.app_id JOIN memberships m ON m.team_id=i.team_id AND m.principal_id=i.principal_id JOIN memberships installer ON installer.team_id=i.team_id AND installer.principal_id=i.installer_id
|
|
84
|
+
WHERE i.id=? AND i.team_id=? AND i.state='active' AND a.state='active' AND m.active=1 AND installer.active=1 AND installer.role IN ('owner','admin')`, [installation, auth.team_id]),
|
|
85
|
+
guard(`WITH RECURSIVE lineage(id,parent_id) AS (SELECT credential_id,parent_id FROM app_credentials WHERE credential_id=? UNION ALL SELECT ac.credential_id,ac.parent_id FROM app_credentials ac JOIN lineage l ON ac.credential_id=l.parent_id)
|
|
86
|
+
SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM lineage l JOIN credentials c ON c.id=l.id WHERE c.revoked_at IS NOT NULL OR (c.expires_at IS NOT NULL AND c.expires_at<=?))`, [auth.credential_id, now])];
|
|
87
|
+
return checks;
|
|
88
|
+
}
|
|
89
|
+
export async function appAuthentication(api, auth) {
|
|
90
|
+
if (!auth) return null;
|
|
91
|
+
const row = await api.store.one(`SELECT ac.*,i.team_id,i.principal_id,i.role,i.state,a.state app_state,installer.active installer_active,installer.role installer_role
|
|
92
|
+
FROM app_credentials ac JOIN app_installations i ON i.id=ac.installation_id JOIN apps a ON a.id=i.app_id JOIN memberships installer ON installer.team_id=i.team_id AND installer.principal_id=i.installer_id WHERE ac.credential_id=?`, [auth.credential_id]);
|
|
93
|
+
if (!row) {
|
|
94
|
+
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]);
|
|
95
|
+
if(identity){if(identity.state!=='active'||identity.app_state!=='active'||!identity.active||!['owner','admin'].includes(identity.role))return null;auth.identity_installation_id=identity.id;}
|
|
96
|
+
return auth;
|
|
97
|
+
}
|
|
98
|
+
if (!auth.active || row.state !== 'active' || row.app_state !== 'active' || !row.installer_active || !['owner','admin'].includes(row.installer_role) || row.team_id !== auth.team_id || row.principal_id !== auth.principal_id) return null;
|
|
99
|
+
const revoked = await api.store.one(`WITH RECURSIVE lineage(id,parent_id) AS (SELECT credential_id,parent_id FROM app_credentials WHERE credential_id=? UNION ALL SELECT ac.credential_id,ac.parent_id FROM app_credentials ac JOIN lineage l ON ac.credential_id=l.parent_id)
|
|
100
|
+
SELECT 1 FROM lineage l JOIN credentials c ON c.id=l.id WHERE c.revoked_at IS NOT NULL OR (c.expires_at IS NOT NULL AND c.expires_at<=?) LIMIT 1`, [auth.credential_id, api.clock.nowMs()]);
|
|
101
|
+
if (revoked) return null;
|
|
102
|
+
const ranks=['viewer','member','admin','owner'];auth.role=ranks[Math.min(ranks.indexOf(auth.role),ranks.indexOf(row.role))];
|
|
103
|
+
return Object.assign(auth, { installation_id: row.installation_id, app_actions: parse(row.actions_json), act: parse(row.actor_json) });
|
|
104
|
+
}
|
|
105
|
+
/** Exact issuer trust is an active, consented installation, scoped to its organization. */
|
|
106
|
+
export async function installedIssuer(api, issuer, action, teamId = null) {
|
|
107
|
+
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
|
|
108
|
+
WHERE a.issuer=? AND a.state='active' AND i.state='active' AND m.active=1 AND installer.active=1 AND installer.role IN ('owner','admin') AND (? IS NULL OR i.team_id=?) AND EXISTS (SELECT 1 FROM json_each(i.actions_json) WHERE value=?) LIMIT 1`, [issuer, teamId, teamId, action]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Fan-out is durable and transactionally committed with the source mutation. C2 adds resource events. */
|
|
112
|
+
export async function appEventStatements(api, teamId, kind, data, addedDestination = null) {
|
|
113
|
+
const now = api.clock.nowMs(), eventId = api.ids.next('event');
|
|
114
|
+
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]);
|
|
115
|
+
if(addedDestination&&!destinations.some(row=>row.id===addedDestination))destinations.push({id:addedDestination});
|
|
116
|
+
const body = JSON.stringify({ id: eventId, type: kind, team_id: teamId, created_at: now, data });
|
|
117
|
+
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));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function appPreAuth({ api, req, res, path, requestId, parseJson, send }) {
|
|
121
|
+
if (req.method !== 'POST' || path !== '/api/v1/apps/token') return false;
|
|
122
|
+
const body = await parseJson(req); exact(body, ['app_id', 'app_secret', 'code', 'redirect_url']);
|
|
123
|
+
for (const field of ['app_id','app_secret','code','redirect_url']) text(body[field], field, 2048);
|
|
124
|
+
const now = api.clock.nowMs();
|
|
125
|
+
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
|
|
126
|
+
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]);
|
|
127
|
+
if (!code) error(401, 'authentication_required', 'invalid or consumed app installation code');
|
|
128
|
+
const signingSecret=unseal(api,code.signing_secret,code.installation_id);
|
|
129
|
+
const cid = api.ids.next('credential'), token = makeToken(cid), expiresAt = now + TOKEN_MS;
|
|
130
|
+
await api.store.guardedTransaction([
|
|
131
|
+
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
|
|
132
|
+
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)]),
|
|
133
|
+
], [statement('UPDATE app_install_codes SET consumed_at=? WHERE code_hash=?', now, hashToken(body.code)),
|
|
134
|
+
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),
|
|
135
|
+
statement('INSERT INTO app_credentials(credential_id,installation_id,actions_json,actor_json) VALUES (?,?,?,?)', cid, code.installation_id, code.actions_json, '[]')]);
|
|
136
|
+
// No bearer or signing secret enters a replay record, audit payload or URL.
|
|
137
|
+
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);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function appRoute(ctx) {
|
|
142
|
+
const { api, req, res, auth, sub, requestId, parseJson, send, result, guardedMutation } = ctx;
|
|
143
|
+
if (!sub.startsWith('/apps') && !sub.startsWith('/app-installations')) return false;
|
|
144
|
+
// Attenuation is the one app-owned credential door; it grants no admin power.
|
|
145
|
+
const delegation = /^\/app-installations\/([^/]+)\/delegations$/.exec(sub);
|
|
146
|
+
if (delegation && req.method === 'POST') {
|
|
147
|
+
if (auth.installation_id !== delegation[1]) error(403, 'forbidden', 'only this installation may delegate its own token');
|
|
148
|
+
const body = await parseJson(req); exact(body, ['actions','actor','expires_in_ms']);
|
|
149
|
+
const actions = list(body.actions, 'actions', auth.app_actions);
|
|
150
|
+
const actor = text(body.actor, 'actor');
|
|
151
|
+
const duration = body.expires_in_ms ?? DELEGATION_MS;
|
|
152
|
+
if (!Number.isSafeInteger(duration) || duration < 1000 || duration > DELEGATION_MS || auth.act.length >= 16) error(400, 'invalid_request', 'delegation is limited to fifteen minutes and sixteen actors');
|
|
153
|
+
const parent = await api.store.one('SELECT expires_at FROM credentials WHERE id=?', [auth.credential_id]);
|
|
154
|
+
const now = api.clock.nowMs(), expiresAt = Math.min(now + duration, parent.expires_at ?? Infinity), cid = api.ids.next('credential'), token = makeToken(cid), act = [...auth.act, actor];
|
|
155
|
+
const response = result(api, auth, { token, kind: 'service', expires_at: expiresAt, actions, subject: auth.principal_id, act });
|
|
156
|
+
await guardedMutation(api, res, auth, appAuthGuards(auth, now), [statement('INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,expires_at,created_at) VALUES (?,?,?,\'service\',?,?,?)', cid, hashToken(token), auth.principal_id, auth.team_id, expiresAt, now), statement('INSERT INTO app_credentials(credential_id,installation_id,parent_id,actions_json,actor_json) VALUES (?,?,?,?,?)', cid, auth.installation_id, auth.credential_id, JSON.stringify(actions), JSON.stringify(act))], { status: 201, body: response, targetId: cid });
|
|
157
|
+
send(res, 201, response, requestId); return true;
|
|
158
|
+
}
|
|
159
|
+
admin(auth);
|
|
160
|
+
if (req.method === 'GET' && sub === '/apps/catalogue') {
|
|
161
|
+
send(res, 200, result(api, auth, { actions: APP_ACTIONS, event_kinds: APP_EVENTS }), requestId); return true;
|
|
162
|
+
}
|
|
163
|
+
if (req.method === 'GET' && sub === '/apps') {
|
|
164
|
+
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;
|
|
165
|
+
}
|
|
166
|
+
let match = /^\/apps\/([^/]+)$/.exec(sub);
|
|
167
|
+
if (match && req.method === 'GET') {
|
|
168
|
+
const app = await api.store.one('SELECT * FROM apps WHERE id=?', [match[1]]);
|
|
169
|
+
if (!app) error(404, 'not_found', 'app not found');
|
|
170
|
+
send(res, 200, result(api, auth, appJson(app)), requestId); return true;
|
|
171
|
+
}
|
|
172
|
+
if (req.method === 'POST' && sub === '/apps') {
|
|
173
|
+
const body = await parseJson(req); exact(body, ['name','description','actions','event_kinds','redirect_urls','webhook_url','issuer']);
|
|
174
|
+
const name = text(body.name, 'name', 80), description = body.description ?? '';
|
|
175
|
+
if (typeof description !== 'string' || description.length > 2000) error(400, 'invalid_request', 'description must be at most 2000 characters');
|
|
176
|
+
const actions = list(body.actions, 'actions', APP_ACTIONS), events = list(body.event_kinds ?? [], 'event_kinds', APP_EVENTS);
|
|
177
|
+
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');
|
|
178
|
+
const redirects = body.redirect_urls.map(value => secureUrl(value, 'redirect_url'));
|
|
179
|
+
const webhook = body.webhook_url == null ? null : secureUrl(body.webhook_url, 'webhook_url');
|
|
180
|
+
if (events.length && !webhook) error(400, 'invalid_request', 'event subscriptions require a webhook URL');
|
|
181
|
+
const issuer = body.issuer == null ? null : secureUrl(body.issuer, 'issuer', true);
|
|
182
|
+
if (actions.some(action => action.startsWith('delegation.') || action === 'identity.login') && !issuer) error(400, 'invalid_request', 'identity and delegation actions require an issuer');
|
|
183
|
+
const now = api.clock.nowMs(), id = api.ids.next('app'), principal = api.ids.next('service'), secret = `sca_${randomBytes(32).toString('base64url')}`;
|
|
184
|
+
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 };
|
|
185
|
+
const response = result(api, auth, { ...appJson(app), secret });
|
|
186
|
+
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 });
|
|
187
|
+
send(res, 201, response, requestId); return true;
|
|
188
|
+
}
|
|
189
|
+
match = /^\/apps\/([^/]+)\/secret$/.exec(sub);
|
|
190
|
+
if(match&&req.method==='POST') {
|
|
191
|
+
const body=await parseJson(req);exact(body,[]);
|
|
192
|
+
const now=api.clock.nowMs(),secret=`sca_${randomBytes(32).toString('base64url')}`;
|
|
193
|
+
const response=result(api,auth,{id:match[1],secret});
|
|
194
|
+
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]});
|
|
195
|
+
send(res,200,response,requestId);return true;
|
|
196
|
+
}
|
|
197
|
+
match = /^\/apps\/([^/]+)\/installations$/.exec(sub);
|
|
198
|
+
if (match && req.method === 'POST') {
|
|
199
|
+
const body = await parseJson(req); exact(body, ['redirect_url','state','actions']);
|
|
200
|
+
const app = await api.store.one("SELECT * FROM apps WHERE id=? AND state='active'", [match[1]]);
|
|
201
|
+
if (!app) error(404, 'not_found', 'app not found');
|
|
202
|
+
if (!parse(app.redirect_urls_json).includes(body.redirect_url)) error(400, 'invalid_request', 'redirect_url must exactly match a registered URL');
|
|
203
|
+
const state = text(body.state, 'state', 512), actions = list(body.actions ?? parse(app.actions_json), 'actions', parse(app.actions_json));
|
|
204
|
+
const existing = await api.store.one('SELECT * FROM app_installations WHERE app_id=? AND team_id=?', [app.id, auth.team_id]);
|
|
205
|
+
if (existing?.state === 'active') error(409, 'conflict', 'app is already installed; uninstall before re-consenting');
|
|
206
|
+
const now = api.clock.nowMs(), id = existing?.id ?? api.ids.next('installation'), principal = existing?.principal_id ?? api.ids.next('service');
|
|
207
|
+
const signingSecret = `whsec_${randomBytes(32).toString('base64')}`, sealed = seal(api, signingSecret, id), code = `sci_${randomBytes(32).toString('base64url')}`;
|
|
208
|
+
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';
|
|
209
|
+
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 };
|
|
210
|
+
const response = result(api, auth, { installation: installJson(install), code, state, redirect_url: body.redirect_url, expires_at: now + CODE_MS });
|
|
211
|
+
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)];
|
|
212
|
+
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 });
|
|
213
|
+
send(res, 201, response, requestId); return true;
|
|
214
|
+
}
|
|
215
|
+
if (sub === '/app-installations' && req.method === 'GET') {
|
|
216
|
+
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;
|
|
217
|
+
}
|
|
218
|
+
match = /^\/app-installations\/([^/]+)$/.exec(sub);
|
|
219
|
+
if (match && (req.method === 'GET' || req.method === 'DELETE')) {
|
|
220
|
+
const install = await api.store.one('SELECT * FROM app_installations WHERE id=? AND team_id=?', [match[1], auth.team_id]);
|
|
221
|
+
if (!install) error(404, 'not_found', 'installation not found');
|
|
222
|
+
if (req.method === 'GET') { send(res, 200, result(api, auth, installJson(install)), requestId); return true; }
|
|
223
|
+
const now = api.clock.nowMs();
|
|
224
|
+
const app=await api.store.one('SELECT issuer FROM apps WHERE id=?',[install.app_id]);
|
|
225
|
+
const sessionPrefix=app.issuer?`delegated:${new URL(app.issuer).host}:session:`:null;
|
|
226
|
+
const delegatedGrants=sessionPrefix?await api.store.query('SELECT id,machine_id FROM machine_grants WHERE team_id=? AND substr(issuer_id,1,?)=?',[auth.team_id,sessionPrefix.length,sessionPrefix]):[];
|
|
227
|
+
await guardedMutation(api, res, auth, adminGuards(auth, now), [statement("UPDATE app_installations SET state='uninstalled',uninstalled_at=? WHERE id=?", now, install.id), statement('UPDATE credentials SET revoked_at=? WHERE id IN (SELECT credential_id FROM app_credentials WHERE installation_id=? UNION SELECT credential_id FROM identity_app_credentials WHERE installation_id=?)', now, install.id, install.id), statement('UPDATE memberships SET active=0,revision=revision+1 WHERE team_id=? AND principal_id=?', auth.team_id, install.principal_id), statement('DELETE FROM app_install_codes WHERE installation_id=?', install.id), ...(sessionPrefix?[statement('DELETE FROM machine_grants WHERE team_id=? AND substr(issuer_id,1,?)=?',auth.team_id,sessionPrefix.length,sessionPrefix)]:[]), statement("UPDATE app_webhook_deliveries SET state='cancelled' WHERE installation_id=? AND state='pending'", install.id), statement('UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?', auth.team_id), ...await appEventStatements(api, auth.team_id, 'app.uninstalled', installJson({ ...install, state: 'uninstalled', uninstalled_at: now }))], { status: 204, body: null, targetId: install.id });
|
|
228
|
+
closeChannelsFor(api, auth.team_id, { principalId: install.principal_id, reason: 'app_uninstalled' });
|
|
229
|
+
api.events.publish(auth.team_id, { type: 'authorization_changed', principal_id: install.principal_id });
|
|
230
|
+
for(const grant of delegatedGrants)closeMachineChannels(api,auth.team_id,grant.machine_id,{grantId:grant.id,reason:'app_uninstalled'});
|
|
231
|
+
if (app.issuer) {
|
|
232
|
+
const publications = await api.store.query('SELECT id,machine_id FROM machine_publications WHERE team_id=? AND workplace=?', [auth.team_id, app.issuer]);
|
|
233
|
+
for (const publication of publications) closeDelegatedChannels(api, auth.team_id, publication.machine_id, delegatedGrantIssuer(app.issuer, publication.id), 'app_uninstalled');
|
|
234
|
+
}
|
|
235
|
+
send(res, 204, null, requestId); return true;
|
|
236
|
+
}
|
|
237
|
+
error(404, 'not_found', 'app operation not found');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Standard Webhooks: stable delivery id across retries, timestamp refreshed and HMAC over exact body bytes. */
|
|
241
|
+
export async function deliverAppWebhooks(api) {
|
|
242
|
+
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()]);
|
|
243
|
+
for (const { id } of due) {
|
|
244
|
+
const now = api.clock.nowMs(), lease = now + 30000;
|
|
245
|
+
try {
|
|
246
|
+
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)]);
|
|
247
|
+
} catch (e) { if (e.code === 'forbidden') continue; throw e; }
|
|
248
|
+
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
|
|
249
|
+
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]);
|
|
250
|
+
let status = null, delivered = false;
|
|
251
|
+
if (row.state !== 'pending' || row.installation_state !== 'active' || row.app_state !== 'active' || !row.active || !row.installer_active || !['owner','admin'].includes(row.installer_role)) {
|
|
252
|
+
await api.store.transaction([statement("UPDATE app_webhook_deliveries SET state='cancelled',lease_until=NULL WHERE id=?", id)]); continue;
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
const secret = unseal(api, row.signing_secret, row.installation_id), timestamp = String(Math.floor(api.clock.nowMs() / 1000));
|
|
256
|
+
const signature = createHmac('sha256', Buffer.from(secret.slice(6), 'base64')).update(`${id}.${timestamp}.${row.body}`).digest('base64');
|
|
257
|
+
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 });
|
|
258
|
+
status = response.status; delivered = status >= 200 && status < 300;
|
|
259
|
+
await response.body?.cancel();
|
|
260
|
+
} catch { /* transport/custody failures are retryable; never log secrets, response bodies or credential URLs */ }
|
|
261
|
+
const state = delivered ? 'delivered' : row.attempts >= 8 ? 'failed' : 'pending';
|
|
262
|
+
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)]);
|
|
263
|
+
}
|
|
264
|
+
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)]);
|
|
265
|
+
return due.length >= 10;
|
|
266
|
+
}
|
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);
|
|
@@ -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;
|
|
@@ -110,7 +111,7 @@ export async function delegatedSessionOf(api, token) {
|
|
|
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;
|
|
@@ -125,16 +126,18 @@ export async function delegatedRoomNoticeOf(api, token) {
|
|
|
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}`);
|
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.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
|
@@ -157,11 +157,11 @@ export async function authorizeMachineUpgrade(api, { authenticate, url, headers
|
|
|
157
157
|
const presented = bearerFrom(headers);
|
|
158
158
|
// A Room member typing into a session bound to their Room: the session names the machine, the server finds it.
|
|
159
159
|
const session = /^\/api\/v1\/teams\/([^/]+)\/sessions\/([^/]+)\/channel$/.exec(pathname);
|
|
160
|
-
if (session) return
|
|
160
|
+
if (session) return looksDelegated(presented) ? await delegatedSessionChannel(api, session[1], decodeURIComponent(session[2]), presented) : { status: 404, code: 'not_found', message: 'not found' };
|
|
161
161
|
const match = /^\/api\/v1\/teams\/([^/]+)\/machines\/([^/]+)\/(link|channel)$/.exec(pathname);
|
|
162
162
|
if (!match) return { status: 404, code: 'not_found', message: 'not found' };
|
|
163
163
|
const [, teamId, machineId, door] = match;
|
|
164
|
-
if (door === 'channel' &&
|
|
164
|
+
if (door === 'channel' && looksDelegated(presented)) return await delegatedChannel(api, teamId, machineId, presented);
|
|
165
165
|
const auth = await authenticate(api, presented);
|
|
166
166
|
if (!auth) return { status: 401, code: 'authentication_required', message: 'authentication required' };
|
|
167
167
|
if (door === 'link') {
|
|
@@ -176,6 +176,7 @@ export async function authorizeMachineUpgrade(api, { authenticate, url, headers
|
|
|
176
176
|
if (!membership?.active) return { status: 401, code: 'authentication_required', message: 'authentication required' };
|
|
177
177
|
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
178
|
} else if (auth.team_id !== teamId || !auth.active) return { status: 401, code: 'authentication_required', message: 'authentication required' };
|
|
179
|
+
if(auth.installation_id){if(!auth.app_actions.includes('machines.control'))return {status:403,code:'forbidden',message:'app lacks machines.control scope'};}
|
|
179
180
|
if (!await claimFor(api, auth, machineId)) return { status: 404, code: 'not_found', message: 'machine not found' };
|
|
180
181
|
return { door, teamId, machineId, auth };
|
|
181
182
|
}
|
|
@@ -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
|
+
`;
|
|
@@ -11,7 +11,8 @@ 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
|
+
export const MIGRATIONS = [m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013];
|
|
15
16
|
/** A message row's `until_revision` while its session's current version still holds it (schema 9). */
|
|
16
17
|
export const OPEN_REVISION = Number.MAX_SAFE_INTEGER;
|
|
17
18
|
/** 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]);
|