@volter/teams 0.1.90 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/agent-profile.mjs +15 -0
- package/client.mjs +29 -0
- package/context.mjs +34 -0
- package/event-client.mjs +8 -0
- package/http-client.d.ts +149 -0
- package/http-client.mjs +232 -0
- package/identity-record.d.ts +21 -0
- package/lines.mjs +19 -0
- package/machine-door.mjs +36 -0
- package/package.json +16 -4
- package/sender-identity.mjs +55 -0
- package/server/agent-authority.mjs +21 -0
- package/server/agent-sessions.mjs +226 -0
- package/server/agents.mjs +286 -0
- package/server/apps.mjs +35 -3
- package/server/auth.mjs +9 -1
- package/server/core.mjs +4 -3
- package/server/event-catalogue.mjs +13 -0
- package/server/http.mjs +81 -21
- package/server/identity-record.mjs +152 -0
- package/server/identity.mjs +2 -1
- package/server/machine-events.mjs +1 -1
- package/server/machine-link.mjs +107 -11
- package/server/member-mirror.mjs +147 -0
- package/server/migrations/015.mjs +72 -0
- package/server/migrations/016.mjs +28 -0
- package/server/migrations/017.mjs +60 -0
- package/server/migrations/018.mjs +82 -0
- package/server/migrations/019.mjs +20 -0
- package/server/migrations/020.mjs +9 -0
- package/server/migrations/index.mjs +7 -1
- package/server/permissions.mjs +121 -0
- package/server/published-events.mjs +145 -0
- package/server/session-snapshot.mjs +28 -0
- package/viewer/session-view.css +1 -102
- package/server/identity-orgs.mjs +0 -54
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// A linked membership source names people by their identity issuer and subject. A subject nobody has signed in with
|
|
2
|
+
// is kept as the team's pending seat until that person's own verified sign-in claims it: a tenant's assertion never
|
|
3
|
+
// writes the server-wide issuer/subject link (principal_identities).
|
|
4
|
+
export default `
|
|
5
|
+
CREATE TABLE source_seats (
|
|
6
|
+
team_id TEXT NOT NULL REFERENCES teams(id), issuer TEXT NOT NULL, subject TEXT NOT NULL, role TEXT NOT NULL,
|
|
7
|
+
display_name TEXT, observation TEXT, created_at INTEGER NOT NULL, PRIMARY KEY(team_id,issuer,subject)
|
|
8
|
+
) STRICT;
|
|
9
|
+
`;
|
|
@@ -13,7 +13,13 @@ import m011 from './011.mjs';
|
|
|
13
13
|
import m012 from './012.mjs';
|
|
14
14
|
import m013 from './013.mjs';
|
|
15
15
|
import m014 from './014.mjs';
|
|
16
|
-
|
|
16
|
+
import m015 from './015.mjs';
|
|
17
|
+
import m016 from './016.mjs';
|
|
18
|
+
import m017 from './017.mjs';
|
|
19
|
+
import m018 from './018.mjs';
|
|
20
|
+
import m019 from './019.mjs';
|
|
21
|
+
import m020 from './020.mjs';
|
|
22
|
+
export const MIGRATIONS = [m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014, m015, m016, m017, m018, m019, m020];
|
|
17
23
|
/** A message row's `until_revision` while its session's current version still holds it (schema 9). */
|
|
18
24
|
export const OPEN_REVISION = Number.MAX_SAFE_INTEGER;
|
|
19
25
|
/** SQL: message row `m` belongs to the version at revision ?1 (bind the revision twice). */
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// One role-and-binding shape and one evaluator (mirrored ontology C5, the spec's Permission noun). Roles bundle
|
|
2
|
+
// actions; bindings grant a role or actions to a principal or a group on one resource (the Org itself, a session, a
|
|
3
|
+
// machine, …). Membership is a binding of a role on the Org. Teams grants allow: withholding is not granting.
|
|
4
|
+
import { isAdmin } from './auth.mjs';
|
|
5
|
+
import { exact } from './request-json.mjs';
|
|
6
|
+
|
|
7
|
+
const fail = (status, code, message) => { throw Object.assign(new Error(message), { status, code }); };
|
|
8
|
+
const stmt = (sql, ...params) => ({ sql, params });
|
|
9
|
+
const guard = (sql, params, message = 'permission authority changed') => ({ sql, params, status: 403, code: 'forbidden', message });
|
|
10
|
+
const NAME = /^[a-z][a-z0-9_.-]{0,63}$/;
|
|
11
|
+
const ACTION = /^(\*|[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*(\.\*)?)$/;
|
|
12
|
+
const RESOURCE_KINDS = ['org', 'session', 'machine', 'source', 'group', 'app'];
|
|
13
|
+
const allows = (actions, action) => actions.some((a) => a === '*' || a === action || (a.endsWith('.*') && action.startsWith(a.slice(0, -1))));
|
|
14
|
+
const bindingJson = (row) => ({ id: row.id, holder: { kind: row.holder_kind, id: row.holder_id }, role: row.role, actions: row.actions_json ? JSON.parse(row.actions_json) : null,
|
|
15
|
+
resource: { kind: row.resource_kind, id: row.resource_id }, effect: row.effect, conditions: JSON.parse(row.conditions_json), expires_at: row.expires_at, revoked_at: row.revoked_at, revision: row.revision });
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The decision for one actor, action and resource, with its reason and the deciding binding: the actor's Org role
|
|
19
|
+
* (its membership, a binding on the Org) and every live binding on the resource held by the actor or one of its
|
|
20
|
+
* groups. The custodian of a session or machine holds it whole. Nothing is cached; every call reads current state.
|
|
21
|
+
*/
|
|
22
|
+
export async function decide(api, team, principal, action, resource, now = api.clock.nowMs()) {
|
|
23
|
+
const receipt = (allowed, reason, binding = null) => ({ actor: principal, action, resource, at: now, allowed, reason, binding });
|
|
24
|
+
const member = await api.store.one('SELECT role FROM memberships WHERE team_id=? AND principal_id=? AND active=1', [team, principal]);
|
|
25
|
+
if (!member) return receipt(false, 'not a member of this Org');
|
|
26
|
+
const roleActions = async (name) => JSON.parse((await api.store.one('SELECT actions_json FROM roles WHERE team_id=? AND name=?', [team, name]))?.actions_json ?? '[]');
|
|
27
|
+
if (allows(await roleActions(member.role), action) && (resource.kind === 'org' || ['owner', 'admin'].includes(member.role))) return receipt(true, `Org role ${member.role}`, `membership:${principal}`);
|
|
28
|
+
if (resource.kind === 'session' || resource.kind === 'machine') {
|
|
29
|
+
const table = resource.kind === 'session' ? 'sessions' : 'machines';
|
|
30
|
+
const held = await api.store.one(`SELECT * FROM ${table} WHERE team_id=? AND id=?`, [team, resource.id]);
|
|
31
|
+
if (held?.custodian_id === principal) return receipt(true, 'custodian', null);
|
|
32
|
+
if (resource.kind === 'session' && held?.visibility === 'team' && action === 'sessions.read' && allows(await roleActions(member.role), action)) return receipt(true, 'shared with the Org', null);
|
|
33
|
+
}
|
|
34
|
+
const groups = (await api.store.query('SELECT group_id FROM group_members WHERE team_id=? AND principal_id=?', [team, principal])).map((row) => row.group_id);
|
|
35
|
+
const holders = [principal, ...groups];
|
|
36
|
+
const rows = await api.store.query(`SELECT * FROM permission_bindings WHERE team_id=? AND resource_kind=? AND resource_id=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?) AND holder_id IN (${holders.map(() => '?').join(',')}) ORDER BY id`,
|
|
37
|
+
[team, resource.kind, resource.id, now, ...holders]);
|
|
38
|
+
let granted = null;
|
|
39
|
+
for (const row of rows) {
|
|
40
|
+
const actions = row.actions_json ? JSON.parse(row.actions_json) : await roleActions(row.role);
|
|
41
|
+
if (!allows(actions, action)) continue;
|
|
42
|
+
if (row.effect === 'deny') return receipt(false, `denied by binding ${row.id}`, row.id);
|
|
43
|
+
granted ??= row;
|
|
44
|
+
}
|
|
45
|
+
return granted ? receipt(true, `granted by binding ${granted.id}`, granted.id) : receipt(false, 'no binding grants it');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function permissionRoute({ api, req, res, auth, sub, url, requestId, parseJson, send, result, guardedMutation }) {
|
|
49
|
+
if (!sub.startsWith('/permissions/')) return false;
|
|
50
|
+
if (auth.app_grants != null || auth.installation_id) fail(403, 'forbidden', 'permissions are managed by Org members, not apps');
|
|
51
|
+
const now = api.clock.nowMs();
|
|
52
|
+
const live = [guard('SELECT 1 FROM credentials WHERE id=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)', [auth.credential_id, now]),
|
|
53
|
+
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])];
|
|
54
|
+
if (req.method === 'GET' && sub === '/permissions/roles') {
|
|
55
|
+
const rows = await api.store.query('SELECT * FROM roles WHERE team_id=? ORDER BY builtin DESC,name', [auth.team_id]);
|
|
56
|
+
send(res, 200, result(api, auth, { items: rows.map((r) => ({ name: r.name, actions: JSON.parse(r.actions_json), builtin: Boolean(r.builtin) })) }), requestId); return true;
|
|
57
|
+
}
|
|
58
|
+
if (req.method === 'POST' && sub === '/permissions/roles') {
|
|
59
|
+
if (!isAdmin(auth)) fail(403, 'forbidden', 'only an Org admin defines a role');
|
|
60
|
+
const body = await parseJson(req); exact(body, ['name', 'actions']);
|
|
61
|
+
if (typeof body.name !== 'string' || !NAME.test(body.name)) fail(400, 'invalid_request', 'a role name is a lowercase word');
|
|
62
|
+
if (!Array.isArray(body.actions) || !body.actions.length || body.actions.length > 64 || body.actions.some((a) => typeof a !== 'string' || !ACTION.test(a))) fail(400, 'invalid_request', 'actions are 1..64 action patterns');
|
|
63
|
+
const response = result(api, auth, { name: body.name, actions: body.actions, builtin: false });
|
|
64
|
+
await guardedMutation(api, res, auth, [...live, guard('SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM roles WHERE team_id=? AND name=? AND builtin=1)', [auth.team_id, body.name], 'a built-in role is not redefined')],
|
|
65
|
+
[stmt('INSERT INTO roles(team_id,name,actions_json,builtin,created_at) VALUES (?,?,?,0,?) ON CONFLICT(team_id,name) DO UPDATE SET actions_json=excluded.actions_json', auth.team_id, body.name, JSON.stringify(body.actions), now)], { status: 201, body: response, targetId: body.name });
|
|
66
|
+
send(res, 201, response, requestId); return true;
|
|
67
|
+
}
|
|
68
|
+
if (req.method === 'GET' && sub === '/permissions/bindings') {
|
|
69
|
+
const kind = url.searchParams.get('resource_kind'), id = url.searchParams.get('resource_id');
|
|
70
|
+
if (!kind || !id) fail(400, 'invalid_request', 'name the resource_kind and resource_id');
|
|
71
|
+
const decision = await decide(api, auth.team_id, auth.principal_id, `${kind === 'org' ? 'members' : `${kind}s`}.read`, { kind, id }, now);
|
|
72
|
+
if (!decision.allowed) fail(404, 'not_found', 'resource not found');
|
|
73
|
+
const rows = await api.store.query('SELECT * FROM permission_bindings WHERE team_id=? AND resource_kind=? AND resource_id=? ORDER BY id', [auth.team_id, kind, id]);
|
|
74
|
+
send(res, 200, result(api, auth, { items: rows.map(bindingJson) }), requestId); return true;
|
|
75
|
+
}
|
|
76
|
+
if (req.method === 'POST' && sub === '/permissions/bindings') {
|
|
77
|
+
const body = await parseJson(req); exact(body, ['holder', 'role', 'actions', 'resource', 'expires_at', 'conditions']);
|
|
78
|
+
exact(body.holder ?? {}, ['kind', 'id']); exact(body.resource ?? {}, ['kind', 'id']);
|
|
79
|
+
if (!['principal', 'group'].includes(body.holder?.kind) || typeof body.holder.id !== 'string') fail(400, 'invalid_request', 'holder is a principal or a group');
|
|
80
|
+
if (!RESOURCE_KINDS.includes(body.resource?.kind) || typeof body.resource.id !== 'string') fail(400, 'invalid_request', `resource kind is one of ${RESOURCE_KINDS.join(', ')}`);
|
|
81
|
+
if ((body.role == null) === (body.actions == null)) fail(400, 'invalid_request', 'grant a role or explicit actions');
|
|
82
|
+
if (body.actions != null && (!Array.isArray(body.actions) || !body.actions.length || body.actions.some((a) => typeof a !== 'string' || !ACTION.test(a)))) fail(400, 'invalid_request', 'actions are action patterns');
|
|
83
|
+
if (body.expires_at != null && (!Number.isSafeInteger(body.expires_at) || body.expires_at <= now)) fail(400, 'invalid_request', 'expires_at is a future epoch millisecond');
|
|
84
|
+
if (body.role != null && !await api.store.one('SELECT 1 FROM roles WHERE team_id=? AND name=?', [auth.team_id, body.role])) fail(404, 'not_found', 'role not found');
|
|
85
|
+
// a machine binding is what the machine admits: capabilities, their targets and an end (the machine channel's grant)
|
|
86
|
+
if (body.resource.kind === 'machine' && (!body.actions || !Array.isArray(body.conditions?.targets) || !body.conditions.targets.every((t) => typeof t === 'string') || body.expires_at == null))
|
|
87
|
+
fail(400, 'invalid_request', 'a machine binding names its capabilities as actions, conditions.targets and expires_at');
|
|
88
|
+
if (body.resource.kind === 'session' && body.role != null && !['reader', 'manager'].includes(body.role)) fail(400, 'invalid_request', 'a session binding grants reader or manager');
|
|
89
|
+
// Passing permission only narrows: a non-admin grants on a resource only what it may itself manage there.
|
|
90
|
+
const manage = isAdmin(auth) || (await decide(api, auth.team_id, auth.principal_id, `${body.resource.kind}s.access`, body.resource, now)).allowed;
|
|
91
|
+
if (!manage) fail(403, 'forbidden', 'you cannot share this resource');
|
|
92
|
+
const id = api.ids.next('binding');
|
|
93
|
+
const row = { id, holder_kind: body.holder.kind, holder_id: body.holder.id, role: body.role ?? null, actions_json: body.actions ? JSON.stringify(body.actions) : null, resource_kind: body.resource.kind,
|
|
94
|
+
resource_id: body.resource.id, effect: 'allow', conditions_json: JSON.stringify(body.conditions ?? {}), expires_at: body.expires_at ?? null, revoked_at: null, revision: 1 };
|
|
95
|
+
const response = result(api, auth, bindingJson(row));
|
|
96
|
+
await guardedMutation(api, res, auth, [guard('SELECT 1 FROM credentials WHERE id=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)', [auth.credential_id, now]),
|
|
97
|
+
guard('SELECT 1 FROM memberships WHERE team_id=? AND principal_id=? AND active=1', [auth.team_id, auth.principal_id])],
|
|
98
|
+
[stmt('INSERT INTO permission_bindings(team_id,id,holder_kind,holder_id,role,actions_json,resource_kind,resource_id,effect,conditions_json,issuer_id,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
|
99
|
+
auth.team_id, id, row.holder_kind, row.holder_id, row.role, row.actions_json, row.resource_kind, row.resource_id, 'allow', row.conditions_json, auth.principal_id, now, row.expires_at),
|
|
100
|
+
stmt('UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?', auth.team_id)], { status: 201, body: response, targetId: id });
|
|
101
|
+
send(res, 201, response, requestId); return true;
|
|
102
|
+
}
|
|
103
|
+
const match = /^\/permissions\/bindings\/([^/]+)$/.exec(sub);
|
|
104
|
+
if (req.method === 'DELETE' && match) {
|
|
105
|
+
const row = await api.store.one('SELECT * FROM permission_bindings WHERE team_id=? AND id=?', [auth.team_id, decodeURIComponent(match[1])]);
|
|
106
|
+
if (!row) fail(404, 'not_found', 'binding not found');
|
|
107
|
+
if (!isAdmin(auth) && row.issuer_id !== auth.principal_id) fail(403, 'forbidden', 'only its issuer or an Org admin revokes a binding');
|
|
108
|
+
await guardedMutation(api, res, auth, [guard('SELECT 1 FROM credentials WHERE id=? AND revoked_at IS NULL', [auth.credential_id])],
|
|
109
|
+
[stmt('UPDATE permission_bindings SET revoked_at=COALESCE(revoked_at,?),revision=revision+1 WHERE team_id=? AND id=?', now, auth.team_id, row.id),
|
|
110
|
+
stmt('UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?', auth.team_id)], { status: 204, body: null, targetId: row.id });
|
|
111
|
+
send(res, 204, null, requestId); return true;
|
|
112
|
+
}
|
|
113
|
+
if (req.method === 'POST' && sub === '/permissions/decide') {
|
|
114
|
+
const body = await parseJson(req); exact(body, ['principal', 'action', 'resource']);
|
|
115
|
+
if (body.principal !== auth.principal_id && !isAdmin(auth)) fail(403, 'forbidden', 'only an Org admin asks for another principal');
|
|
116
|
+
if (typeof body.action !== 'string' || !ACTION.test(body.action)) fail(400, 'invalid_request', 'action is an action name');
|
|
117
|
+
exact(body.resource ?? {}, ['kind', 'id']);
|
|
118
|
+
send(res, 200, result(api, auth, await decide(api, auth.team_id, body.principal ?? auth.principal_id, body.action, body.resource, now)), requestId); return true;
|
|
119
|
+
}
|
|
120
|
+
fail(404, 'not_found', 'permission operation not found');
|
|
121
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { appAuthentication } from './apps.mjs';
|
|
3
|
+
import { isAdmin } from './auth.mjs';
|
|
4
|
+
import { exact } from './request-json.mjs';
|
|
5
|
+
import { canonicalJson } from './wire.mjs';
|
|
6
|
+
import { writeSse } from './events.mjs';
|
|
7
|
+
import { APP_EVENTS, PUBLISHABLE_EVENTS, eventReadAction } from './event-catalogue.mjs';
|
|
8
|
+
|
|
9
|
+
const stmt = (sql, ...params) => ({sql, params});
|
|
10
|
+
const fail = (status,code,message) => {throw Object.assign(new Error(message),{status,code});};
|
|
11
|
+
const text = (value,label) => {if(typeof value!=='string'||!value||value.length>256||/[\x00-\x1f]/.test(value))fail(400,'invalid_request',`${label} must be a nonempty string of at most 256 characters`);return value;};
|
|
12
|
+
const eventOf = row => ({id:row.id,org:row.team_id,at:row.created_at,actor:JSON.parse(row.actor_json),kind:row.kind,resource:{kind:row.resource_kind,id:row.resource_id},data:JSON.parse(row.data_json)});
|
|
13
|
+
const authorityGuards=(auth,now)=>[
|
|
14
|
+
{sql:'SELECT 1 FROM credentials WHERE id=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)',params:[auth.credential_id,now],status:401,code:'authentication_required',message:'event credential revoked'},
|
|
15
|
+
{sql:'SELECT 1 FROM memberships WHERE team_id=? AND principal_id=? AND active=1',params:[auth.team_id,auth.principal_id],status:401,code:'authentication_required',message:'event membership revoked'},
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/** Re-evaluate the installation and each correlated session's current ACL before every delivery or replay. */
|
|
19
|
+
export async function mayReadEvent(api,auth,event) {
|
|
20
|
+
const action=eventReadAction(event.resource.kind);
|
|
21
|
+
if(auth.installation_id&&!auth.app_actions?.includes(action))return false;
|
|
22
|
+
if(auth.app_grants!=null&&event.resource.kind!=='session')return false;
|
|
23
|
+
if(event.resource.kind==='session'){
|
|
24
|
+
try {await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref:event.resource.id,now:api.clock.nowMs()});}
|
|
25
|
+
catch(error){if([401,403,404].includes(error.status))return false;throw error;}
|
|
26
|
+
}
|
|
27
|
+
if(event.resource.kind==='machine'){
|
|
28
|
+
const machine=await api.store.one('SELECT 1 FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL',[auth.team_id,event.resource.id]);
|
|
29
|
+
if(!machine)return false;
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function liveAuth(api,auth) {
|
|
35
|
+
const now=api.clock.nowMs();
|
|
36
|
+
const row=await api.store.one(`SELECT c.revoked_at,c.expires_at,m.active,m.role,t.auth_epoch FROM credentials c JOIN memberships m ON m.team_id=? AND m.principal_id=c.principal_id JOIN teams t ON t.id=m.team_id WHERE c.id=?`,[auth.team_id,auth.credential_id]);
|
|
37
|
+
if(!row?.active||row.revoked_at!=null||row.expires_at!=null&&row.expires_at<=now)fail(401,'authentication_required','event subscription revoked');
|
|
38
|
+
const next=await appAuthentication(api,{...auth,role:row.role,active:row.active,now});
|
|
39
|
+
if(!next)fail(401,'authentication_required','event installation revoked');
|
|
40
|
+
return {...next,epoch:row.auth_epoch};
|
|
41
|
+
}
|
|
42
|
+
function cursorCodec(api,auth,query) {
|
|
43
|
+
if(!api.appRootSecret)fail(503,'unavailable','the Teams root secret is required for event cursors');
|
|
44
|
+
const mac=value=>createHmac('sha256',api.appRootSecret).update(`Teams events v1\0${value}`).digest('base64url');
|
|
45
|
+
const audience=canonicalJson({org:auth.team_id,principal:auth.principal_id,credential:auth.credential_id,epoch:auth.epoch,query});
|
|
46
|
+
return {
|
|
47
|
+
encode(sequence){const value=Buffer.from(JSON.stringify({sequence,audience,expires:api.clock.nowMs()+7*86400000})).toString('base64url');return `${value}.${mac(value)}`;},
|
|
48
|
+
decode(token){
|
|
49
|
+
try{const [value,signature,...rest]=token.split('.'),expected=Buffer.from(mac(value)),actual=Buffer.from(signature??'');
|
|
50
|
+
if(rest.length||actual.length!==expected.length||!timingSafeEqual(actual,expected))throw new Error();
|
|
51
|
+
const body=JSON.parse(Buffer.from(value,'base64url').toString());
|
|
52
|
+
if(body.audience!==audience||body.expires<=api.clock.nowMs()||!Number.isSafeInteger(body.sequence)||body.sequence<0)throw new Error();return body.sequence;
|
|
53
|
+
}catch{fail(409,'resync_required','event cursor expired, mismatched, or authorization changed');}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function publishedEventRoute(ctx) {
|
|
59
|
+
const {api,req,res,auth,sub,url,requestId,parseJson,send,result,guardedMutation}=ctx;
|
|
60
|
+
if(sub==='/event-sources'&&req.method==='POST'){
|
|
61
|
+
if(!isAdmin(auth)&&!(auth.installation_id&&auth.app_actions.includes('events.publish')))fail(403,'forbidden','an admin or a consented publisher installation must authorize an event source');
|
|
62
|
+
const body=await parseJson(req);exact(body,['name']);text(body.name,'name');
|
|
63
|
+
const old=await api.store.one('SELECT * FROM event_sources WHERE team_id=? AND principal_id=? AND name=?',[auth.team_id,auth.principal_id,body.name]);
|
|
64
|
+
const id=old?.id??api.ids.next('event_source'),response=result(api,auth,{id,name:body.name});
|
|
65
|
+
await guardedMutation(api,res,auth,authorityGuards(auth,api.clock.nowMs()),old?[]:[stmt('INSERT INTO event_sources VALUES (?,?,?,?)',auth.team_id,id,auth.principal_id,body.name)],{status:201,body:response,targetId:id});
|
|
66
|
+
send(res,201,response,requestId);return true;
|
|
67
|
+
}
|
|
68
|
+
const publication=/^\/event-sources\/([^/]+)\/events$/.exec(sub);
|
|
69
|
+
if(publication&&req.method==='POST'){
|
|
70
|
+
const body=await parseJson(req);exact(body,['id','kind','resource','revision','data','actor']);
|
|
71
|
+
text(body.id,'id');text(body.resource?.id,'resource.id');exact(body.resource,['kind','id']);
|
|
72
|
+
if(!PUBLISHABLE_EVENTS.includes(body.kind)||body.resource.kind!==body.kind.split('.')[0]||!body.resource.id.startsWith(`${publication[1]}:`)||!Number.isSafeInteger(body.revision)||body.revision<1||!body.data||typeof body.data!=='object'||Array.isArray(body.data)||Buffer.byteLength(JSON.stringify(body.data))>262144)fail(400,'invalid_request','invalid source event or snapshot');
|
|
73
|
+
// A source can prove only its own principal. A plain session is evidence, never principal identity.
|
|
74
|
+
const actor={principal:auth.principal_id,session:null,lineage:auth.act??[]};
|
|
75
|
+
if(body.actor!=null){exact(body.actor,['session']);if(body.actor.session!=null){text(body.actor.session,'actor.session');if(!body.actor.session.startsWith('session:'))fail(400,'invalid_request','actor.session must name a session');actor.session=body.actor.session;}}
|
|
76
|
+
const source=await api.store.one('SELECT 1 FROM event_sources WHERE team_id=? AND id=? AND principal_id=?',[auth.team_id,publication[1],auth.principal_id]);
|
|
77
|
+
if(!source)fail(404,'not_found','event source not found');
|
|
78
|
+
const old=await api.store.one('SELECT * FROM org_events WHERE id=?',[body.id]);
|
|
79
|
+
if(old){if(old.team_id!==auth.team_id||old.source_id!==publication[1]||old.kind!==body.kind||old.resource_id!==body.resource.id||old.source_revision!==body.revision||canonicalJson(JSON.parse(old.data_json))!==canonicalJson(body.data)||canonicalJson(JSON.parse(old.actor_json))!==canonicalJson(actor))fail(409,'idempotency_conflict','event id already names another event');send(res,200,result(api,auth,eventOf(old)),requestId);return true;}
|
|
80
|
+
const now=api.clock.nowMs(),guards=[...authorityGuards(auth,now),{sql:'SELECT 1 FROM event_sources WHERE team_id=? AND id=? AND principal_id=?',params:[auth.team_id,publication[1],auth.principal_id],status:403,code:'forbidden',message:'event source authority changed'},
|
|
81
|
+
{sql:'SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM event_resources WHERE team_id=? AND source_id=? AND resource_kind=? AND resource_id=? AND source_revision>=?)',params:[auth.team_id,publication[1],body.resource.kind,body.resource.id,body.revision],status:409,code:'revision_mismatch',message:'resource publication is older than its current snapshot'}];
|
|
82
|
+
const event={id:body.id,org:auth.team_id,at:now,actor,kind:body.kind,resource:body.resource,data:body.data},response=result(api,auth,event);
|
|
83
|
+
await guardedMutation(api,res,auth,guards,[stmt('INSERT INTO org_events(id,team_id,source_id,source_revision,kind,resource_kind,resource_id,actor_json,data_json,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)',body.id,auth.team_id,publication[1],body.revision,body.kind,body.resource.kind,body.resource.id,JSON.stringify(actor),JSON.stringify(body.data),now)],{status:201,body:response,targetId:body.resource.id});
|
|
84
|
+
send(res,201,response,requestId);return true;
|
|
85
|
+
}
|
|
86
|
+
if(sub==='/events/catalogue'&&req.method==='GET'){send(res,200,result(api,auth,{kinds:APP_EVENTS}),requestId);return true;}
|
|
87
|
+
if(sub==='/events'&&req.method==='GET'){await eventStream(ctx);return true;}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function eventStream({api,req,res,auth,url,requestId}){
|
|
92
|
+
const query={source:url.searchParams.get('source'),kinds:url.searchParams.get('kinds')?.split(',').sort()??null};
|
|
93
|
+
if([...url.searchParams.keys()].some(key=>!['after','source','kinds'].includes(key))||query.kinds?.some(kind=>!APP_EVENTS.includes(kind)))fail(400,'invalid_request','invalid event subscription');
|
|
94
|
+
auth=await liveAuth(api,auth);let codec=cursorCodec(api,auth,query);
|
|
95
|
+
const after=url.searchParams.get('after')??req.headers['last-event-id'];let cursor=after==null?null:codec.decode(after);
|
|
96
|
+
const count=api.openStreams.get(auth.credential_id)??0;if(count>=10)fail(429,'rate_limited','too many event subscriptions');
|
|
97
|
+
api.openStreams.set(auth.credential_id,count+1);
|
|
98
|
+
let closed=false,running=null,dirty=false;
|
|
99
|
+
const close=()=>{if(closed)return;closed=true;clearInterval(timer);unsubscribe();req.off('close',close);api.events.off('shutdown',close);api.openStreams.set(auth.credential_id,Math.max(0,(api.openStreams.get(auth.credential_id)??1)-1));res.end();};
|
|
100
|
+
const terminal=(type,reason)=>{if(!closed)writeSse(res,{type,reason,terminal:true});close();};
|
|
101
|
+
const write=event=>{if(closed)return false;if(res.writableLength+Buffer.byteLength(JSON.stringify(event))>1048576){terminal('resync_required','slow_reader');return false;}writeSse(res,event);return true;};
|
|
102
|
+
const readable=async event=>{
|
|
103
|
+
const current=await liveAuth(api,auth);
|
|
104
|
+
if(current.epoch!==auth.epoch)fail(409,'resync_required','authorization changed during event delivery');
|
|
105
|
+
auth=current;return mayReadEvent(api,auth,event);
|
|
106
|
+
};
|
|
107
|
+
const filterKinds=query.kinds==null?null:JSON.stringify(query.kinds);
|
|
108
|
+
// A snapshot is each resource's current state, so it takes a resource whose kind the subscription names (a card whose
|
|
109
|
+
// latest event is a comment is still a card to a subscriber of task changes) as well as one whose latest event it names
|
|
110
|
+
// (a machine's `resource.changed`).
|
|
111
|
+
const filterResources=query.kinds==null?null:JSON.stringify([...new Set(query.kinds.map(kind=>kind.split('.')[0]))]);
|
|
112
|
+
const drain=async()=>{
|
|
113
|
+
const next=await liveAuth(api,auth);if(next.epoch!==auth.epoch){terminal('resync_required','authorization_changed');return;}auth=next;
|
|
114
|
+
const bounds=await api.store.one('SELECT COALESCE((SELECT max(sequence) FROM org_events WHERE team_id=?),0) last,COALESCE((SELECT pruned_through FROM event_retention WHERE team_id=?),0) floor',[auth.team_id,auth.team_id]);
|
|
115
|
+
if(cursor!==null&&(cursor<bounds.floor||cursor>Math.max(bounds.last,bounds.floor))){terminal('resync_required','retention');return;}
|
|
116
|
+
if(cursor===null){
|
|
117
|
+
// Subscribe before reading the atomic snapshot. Replay after this watermark repairs all concurrent writes.
|
|
118
|
+
const [head,resources]=await api.store.snapshot([
|
|
119
|
+
{kind:'one',sql:'SELECT COALESCE((SELECT max(sequence) FROM org_events WHERE team_id=?),(SELECT pruned_through FROM event_retention WHERE team_id=?),0) last',params:[auth.team_id,auth.team_id]},
|
|
120
|
+
{kind:'query',sql:'SELECT r.sequence FROM event_resources r JOIN org_events e ON e.sequence=r.sequence WHERE r.team_id=? AND (? IS NULL OR r.source_id=?) AND (? IS NULL OR r.resource_kind IN (SELECT value FROM json_each(?)) OR e.kind IN (SELECT value FROM json_each(?))) ORDER BY r.source_id,r.resource_kind,r.resource_id',params:[auth.team_id,query.source,query.source,filterResources,filterResources,filterKinds]},
|
|
121
|
+
]);
|
|
122
|
+
cursor=head.last;write({type:'snapshot_begin'});
|
|
123
|
+
for(let at=0;at<resources.length;at+=32){
|
|
124
|
+
const sequences=resources.slice(at,at+32).map(row=>row.sequence);
|
|
125
|
+
const rows=await api.store.query('SELECT * FROM org_events WHERE team_id=? AND sequence IN (SELECT value FROM json_each(?)) ORDER BY sequence',[auth.team_id,JSON.stringify(sequences)]);
|
|
126
|
+
if(rows.length!==sequences.length){terminal('resync_required','snapshot_retention');return;}
|
|
127
|
+
for(const row of rows){const event=eventOf(row);if(await readable(event))if(!write({type:'snapshot_item',sequence:row.sequence,data:event}))return;}
|
|
128
|
+
}
|
|
129
|
+
write({type:'snapshot_end',cursor:codec.encode(cursor)});
|
|
130
|
+
}
|
|
131
|
+
for(;;){
|
|
132
|
+
const rows=await api.store.query('SELECT * FROM org_events WHERE team_id=? AND sequence>? AND (? IS NULL OR source_id=?) AND (? IS NULL OR kind IN (SELECT value FROM json_each(?))) ORDER BY sequence LIMIT 128',[auth.team_id,cursor,query.source,query.source,filterKinds,filterKinds]);
|
|
133
|
+
if(!rows.length){cursor=Math.max(cursor,bounds.last);write({type:'checkpoint',cursor:codec.encode(cursor)});break;}
|
|
134
|
+
for(const row of rows){if(closed)return;cursor=row.sequence;const event=eventOf(row);if(await readable(event))write({type:'event',sequence:row.sequence,cursor:codec.encode(cursor),data:event});}
|
|
135
|
+
// Advance even when the reader was not authorized for a resource; never leak its data.
|
|
136
|
+
write({type:'checkpoint',cursor:codec.encode(cursor)});if(rows.length<128)break;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const enqueue=()=>{dirty=true;if(running)return running;running=(async()=>{while(dirty&&!closed){dirty=false;await drain();}})().catch(error=>terminal([401,403].includes(error.status)?'access_revoked':'resync_required',error.code??'unavailable')).finally(()=>{running=null;});return running;};
|
|
140
|
+
const unsubscribe=api.events.subscribe(`team:${auth.team_id}`,()=>void enqueue());
|
|
141
|
+
const timer=setInterval(()=>void enqueue().then(()=>{if(!closed)res.write(': heartbeat\n\n');}),15000);timer.unref?.();
|
|
142
|
+
req.once('close',close);api.events.once('shutdown',close);
|
|
143
|
+
res.writeHead(200,{'Content-Type':'text/event-stream','Cache-Control':'no-store','X-Request-ID':requestId});await enqueue();
|
|
144
|
+
if(!closed)write({type:'checkpoint',cursor:codec.encode(cursor)});
|
|
145
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// SQL fragments receive only source-owned aliases, never request values.
|
|
2
|
+
export const nativeSessionSnapshot=(team,id)=>`json_object('id',${id},'harness',(SELECT harness FROM sessions WHERE team_id=${team} AND id=${id}),
|
|
3
|
+
'bindings',json(COALESCE((SELECT json_group_array(json(descriptor_json)) FROM bindings WHERE team_id=${team} AND session_id=${id}),'[]')),
|
|
4
|
+
'agent_identity',json((SELECT json_object('session',a.session_id,'agent',CASE WHEN a.agent_id IS NULL THEN NULL ELSE json_object('id',a.agent_id) END,
|
|
5
|
+
'starter',json(a.starter_json),'resolved',json(a.profile_json),'resolved_at',CASE WHEN a.profile_json='null' THEN NULL ELSE a.resolved_at END,
|
|
6
|
+
'main',json(CASE WHEN a.main=1 THEN 'true' ELSE 'false' END),'ended_at',a.ended_at,'end_reason',a.end_reason,'revision',a.revision)
|
|
7
|
+
FROM agent_sessions a WHERE a.team_id=${team} AND a.session_id=${id})))`;
|
|
8
|
+
|
|
9
|
+
export function agentSessionEventTriggers(){
|
|
10
|
+
const now="CAST((julianday('now')-2440587.5)*86400000 AS INTEGER)";
|
|
11
|
+
const insert=(team,id,kind,at)=>`INSERT INTO org_events(id,team_id,kind,resource_kind,resource_id,created_at,actor_json,data_json)
|
|
12
|
+
VALUES ('evt_'||lower(hex(randomblob(16))),${team},${kind},'session',${id},${at},'{"principal":null,"session":null,"lineage":[]}',${nativeSessionSnapshot(team,id)});`;
|
|
13
|
+
const other="EXISTS (SELECT 1 FROM bindings b WHERE b.team_id=NEW.team_id AND b.session_id=NEW.session_id AND b.id!=NEW.id AND json_extract(b.descriptor_json,'$.state')='running')";
|
|
14
|
+
const kind=`CASE WHEN json_extract(NEW.descriptor_json,'$.state')='running' AND NOT ${other} THEN 'session.started' WHEN json_extract(NEW.descriptor_json,'$.state')='stopped' AND NOT ${other} THEN 'session.ended' ELSE 'session.changed' END`;
|
|
15
|
+
return `
|
|
16
|
+
DROP TRIGGER session_event_capture;
|
|
17
|
+
DROP TRIGGER session_event_binding_added;
|
|
18
|
+
DROP TRIGGER session_event_binding_changed;
|
|
19
|
+
DROP TRIGGER session_event_binding_removed;
|
|
20
|
+
CREATE TRIGGER session_event_capture AFTER INSERT ON session_versions BEGIN
|
|
21
|
+
${insert('NEW.team_id','NEW.session_id',"'session.changed'",'NEW.created_at')} END;
|
|
22
|
+
CREATE TRIGGER session_event_binding_added AFTER INSERT ON bindings BEGIN
|
|
23
|
+
${insert('NEW.team_id','NEW.session_id',kind,'NEW.observed_at')} END;
|
|
24
|
+
CREATE TRIGGER session_event_binding_changed AFTER UPDATE ON bindings WHEN OLD.incarnation_id!=NEW.incarnation_id OR json_extract(OLD.descriptor_json,'$.state') IS NOT json_extract(NEW.descriptor_json,'$.state') OR json_extract(OLD.descriptor_json,'$.native_runtime_id') IS NOT json_extract(NEW.descriptor_json,'$.native_runtime_id') BEGIN
|
|
25
|
+
${insert('NEW.team_id','NEW.session_id',kind,'NEW.observed_at')} END;
|
|
26
|
+
CREATE TRIGGER session_event_binding_removed AFTER DELETE ON bindings BEGIN
|
|
27
|
+
${insert('OLD.team_id','OLD.session_id',"'session.changed'",now)} END;`;
|
|
28
|
+
}
|
package/viewer/session-view.css
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* Generated from src/styles/*.css
|
|
1
|
+
/* Generated from src/styles/*.css; orchestration components belong to the full UI bundle. */
|
|
2
2
|
@layer scui.tokens, scui.components;
|
|
3
3
|
@layer scui.tokens {
|
|
4
4
|
/*
|
|
@@ -655,107 +655,6 @@
|
|
|
655
655
|
@media (pointer:coarse) {
|
|
656
656
|
.scui-harness-option,.scui-harness-manage-item { min-height:44px }
|
|
657
657
|
}
|
|
658
|
-
/* Workflow and orchestration components share the consumer theme contract. */
|
|
659
|
-
.scui-domain { background:var(--scui-domain-bg, var(--scui-bg)); border:var(--scui-domain-border, 1px solid var(--scui-border)); border-radius:var(--scui-domain-radius, var(--scui-card-radius, var(--scui-radius-xl))); overflow:hidden; container-type:inline-size; contain-intrinsic-inline-size:var(--scui-intrinsic-width, 620px); }
|
|
660
|
-
.scui-domain-button { padding:calc(7px * var(--scui-scaling)) calc(11px * var(--scui-scaling)); color:inherit; min-height:var(--scui-domain-control-height, calc(36px * var(--scui-scaling))); }
|
|
661
|
-
.scui-domain-select { padding:calc(7px * var(--scui-scaling)) calc(11px * var(--scui-scaling)); border:var(--scui-button-border, 1px solid var(--scui-border-strong)); border-radius:var(--scui-button-radius, var(--scui-radius-sm)); background:var(--scui-button-bg, var(--scui-bg)); color:inherit; cursor:pointer; min-height:var(--scui-domain-control-height, calc(40px * var(--scui-scaling))); max-width:100%; }
|
|
662
|
-
.scui-domain-button:disabled { color:var(--scui-muted); opacity:1; }
|
|
663
|
-
.scui-domain-button:focus-visible,.scui-domain-select:focus-visible,.scui-domain-search-input:focus-visible,.scui-inventory-memory-search-input:focus-visible,.scui-domain-summary:focus-visible,.scui-domain-pre:focus-visible { outline:2px solid var(--scui-accent); outline-offset:3px; }
|
|
664
|
-
/* Headings, lists and panels take focus when they open, for screen readers; a host may quiet their ring. */
|
|
665
|
-
.scui-domain-heading:focus-visible,.scui-domain-collection:focus-visible,.scui-domain-detail:focus-visible { outline:var(--scui-focus-target-outline, 2px solid var(--scui-accent)); outline-offset:3px; }
|
|
666
|
-
.scui-domain-title { font-size:var(--scui-domain-title-size, var(--scui-text-4xl)); line-height:1.25; margin:calc(6px * var(--scui-scaling)) 0; letter-spacing:-.4px; font-family:var(--scui-heading-font, inherit); font-weight:var(--scui-heading-weight, bold); }
|
|
667
|
-
.scui-domain-heading { font-size:var(--scui-domain-heading-size, var(--scui-text-3xl)); line-height:1.35; margin:calc(12px * var(--scui-scaling)) 0; font-family:var(--scui-heading-font, inherit); font-weight:var(--scui-heading-weight, bold); }
|
|
668
|
-
.scui-domain-subheading { font-size:var(--scui-text-base); margin:calc(22px * var(--scui-scaling)) 0 calc(8px * var(--scui-scaling)); }
|
|
669
|
-
.scui-domain-header { padding:var(--scui-domain-header-padding, calc(24px * var(--scui-scaling))); display:var(--scui-domain-header-display, flex); justify-content:space-between; align-items:flex-start; gap:calc(16px * var(--scui-scaling)); }
|
|
670
|
-
.scui-domain-subtitle,.scui-domain-meta,.scui-domain-footnote { color:var(--scui-muted); font-size:var(--scui-domain-meta-size, var(--scui-text-lg)); }
|
|
671
|
-
.scui-domain-eyebrow { font-size:var(--scui-text-sm); letter-spacing:var(--scui-eyebrow-tracking, 1.2px); text-transform:var(--scui-eyebrow-transform, none); color:var(--scui-muted); }
|
|
672
|
-
.scui-domain-badge { display:inline-flex; align-items:center; gap:calc(6px * var(--scui-scaling)); padding:var(--scui-badge-padding, calc(3px * var(--scui-scaling)) calc(8px * var(--scui-scaling))); border:var(--scui-badge-border, 0); border-radius:var(--scui-badge-radius, var(--scui-radius-sm)); background:var(--scui-badge-bg, var(--scui-bg-raised)); font-size:var(--scui-badge-size, var(--scui-text-lg)); font-weight:var(--scui-badge-weight, inherit); text-transform:var(--scui-badge-transform, none); white-space:nowrap; }
|
|
673
|
-
.scui-domain-badge::before { content:''; display:var(--scui-badge-dot-display, block); width:6px; height:6px; border-radius:50%; background:currentColor; }
|
|
674
|
-
.scui-domain-badge[data-tone="positive"] { color:var(--scui-positive); }
|
|
675
|
-
.scui-domain-badge[data-tone="danger"] { color:var(--scui-danger); }
|
|
676
|
-
.scui-domain-badge[data-tone="warning"] { color:var(--scui-fg); }
|
|
677
|
-
.scui-domain-badge[data-tone="warning"]::before { background:var(--scui-warning); }
|
|
678
|
-
.scui-domain-toolbar { padding:var(--scui-domain-toolbar-padding, calc(12px * var(--scui-scaling)) calc(24px * var(--scui-scaling))); border-block:var(--scui-domain-toolbar-border, 1px solid var(--scui-border)); display:var(--scui-domain-toolbar-display, flex); align-items:var(--scui-domain-toolbar-align, flex-end); justify-content:space-between; gap:calc(16px * var(--scui-scaling)); }
|
|
679
|
-
.scui-domain-search { display:grid; grid-template-columns:var(--scui-domain-search-columns, none); align-items:center; gap:calc(5px * var(--scui-scaling)) calc(10px * var(--scui-scaling)); font-size:var(--scui-domain-search-size, var(--scui-text-lg)); }
|
|
680
|
-
.scui-domain-search { flex:1; max-width:350px; }
|
|
681
|
-
.scui-domain-search-input { width:100%; padding:calc(8px * var(--scui-scaling)) calc(10px * var(--scui-scaling)); color:inherit; min-height:var(--scui-domain-control-height, calc(40px * var(--scui-scaling))); }
|
|
682
|
-
.scui-domain-switch { display:var(--scui-domain-switch-display, flex); gap:calc(4px * var(--scui-scaling)); }
|
|
683
|
-
.scui-domain-switch-option[aria-pressed="true"] { background:var(--scui-fill); }
|
|
684
|
-
.scui-domain-workspace { display:grid; min-height:380px; }
|
|
685
|
-
/* `minmax(0,1fr)` stacks the detail below the collection. */
|
|
686
|
-
.scui-domain-workspace[data-detail="true"] { grid-template-columns:var(--scui-domain-detail-columns, minmax(0,1fr) minmax(320px,40%)); }
|
|
687
|
-
.scui-domain-collection { min-width:0; padding:var(--scui-domain-collection-padding, calc(20px * var(--scui-scaling))); overflow:auto; }
|
|
688
|
-
.scui-domain-board { display:grid; grid-template-columns:repeat(4,minmax(190px,1fr)); gap:var(--scui-board-gap, calc(12px * var(--scui-scaling))); }
|
|
689
|
-
.scui-domain-lane { background:var(--scui-lane-bg, var(--scui-bg-raised)); padding:var(--scui-lane-padding, calc(10px * var(--scui-scaling))); border:var(--scui-lane-border, 0); border-radius:var(--scui-lane-radius, var(--scui-card-radius, var(--scui-radius-md))); min-height:var(--scui-lane-min-height, 290px); }
|
|
690
|
-
.scui-domain-lane-title { font-family:var(--scui-lane-title-font, var(--scui-font)); font-weight:var(--scui-lane-title-weight, bold); font-size:var(--scui-lane-title-size, var(--scui-text-lg)); color:var(--scui-lane-title-fg, inherit); text-transform:var(--scui-lane-title-transform, none); letter-spacing:var(--scui-lane-title-tracking, normal); display:flex; justify-content:var(--scui-lane-title-justify, space-between); gap:var(--scui-lane-title-gap, 0); padding:var(--scui-lane-title-padding, 0); margin:0 0 calc(12px * var(--scui-scaling)); }
|
|
691
|
-
.scui-domain-lane-count { padding:var(--scui-lane-count-padding, 0); border-radius:var(--scui-lane-count-radius, 0); background:var(--scui-lane-count-bg, transparent); color:var(--scui-lane-count-fg, var(--scui-muted)); font-size:var(--scui-lane-count-size, inherit); font-weight:var(--scui-lane-count-weight, 400); }
|
|
692
|
-
.scui-domain-task { border-radius:var(--scui-task-radius, var(--scui-card-radius, var(--scui-radius-sm))); display:grid; grid-auto-flow:var(--scui-task-flow, row); grid-template-columns:var(--scui-task-columns, none); justify-content:normal; align-items:var(--scui-task-align, normal); gap:var(--scui-task-gap, calc(9px * var(--scui-scaling))); width:100%; text-align:left; border:var(--scui-task-border, 1px solid var(--scui-border)); background:var(--scui-task-bg, var(--scui-button-bg, var(--scui-bg))); box-shadow:var(--scui-task-shadow, none); padding:var(--scui-task-padding, calc(12px * var(--scui-scaling))); margin:0 0 var(--scui-task-spacing, calc(8px * var(--scui-scaling))); }
|
|
693
|
-
.scui-domain-task-title { order:var(--scui-task-title-order, 0); color:var(--scui-task-title-fg, inherit); font-size:var(--scui-task-title-size, var(--scui-text-base)); font-weight:var(--scui-task-title-weight, 550); }
|
|
694
|
-
.scui-domain-list { display:grid; }
|
|
695
|
-
/* A card's id row and status row; a host whose slots carry that information hides them. */
|
|
696
|
-
.scui-domain-task-meta { display:var(--scui-task-meta-display, block); }
|
|
697
|
-
.scui-domain-task-status { display:var(--scui-task-status-display, block); }
|
|
698
|
-
.scui-domain-detail { border-left:var(--scui-domain-detail-border, 1px solid var(--scui-border)); border-top:var(--scui-domain-detail-border-top, 0); background:var(--scui-domain-detail-bg, transparent); padding:calc(20px * var(--scui-scaling)) calc(24px * var(--scui-scaling)); overflow-wrap:anywhere; }
|
|
699
|
-
.scui-domain-detail-top,.scui-domain-inline { display:flex; align-items:center; justify-content:space-between; gap:calc(10px * var(--scui-scaling)); }
|
|
700
|
-
.scui-domain-inline { justify-content:flex-start; }
|
|
701
|
-
.scui-domain-callout { padding:calc(12px * var(--scui-scaling)) calc(14px * var(--scui-scaling)); margin:calc(20px * var(--scui-scaling)) 0; border-left:3px solid var(--scui-warning); background:var(--scui-bg-raised); }
|
|
702
|
-
.scui-domain-callout-title { margin:0; }
|
|
703
|
-
/* After the callout: a paragraph callout keeps the paragraph margin. */
|
|
704
|
-
.scui-domain-text { margin:calc(8px * var(--scui-scaling)) 0; }
|
|
705
|
-
.scui-domain-related { border-radius:var(--scui-card-radius, var(--scui-radius-sm)); display:grid; justify-content:normal; align-items:normal; text-align:left; width:100%; font-size:var(--scui-text-base); gap:calc(4px * var(--scui-scaling)); }
|
|
706
|
-
.scui-domain-timeline { padding-left:calc(20px * var(--scui-scaling)); }
|
|
707
|
-
.scui-domain-timeline-item { padding:0 0 calc(18px * var(--scui-scaling)) calc(4px * var(--scui-scaling)); }
|
|
708
|
-
.scui-domain-timeline-title { font-size:var(--scui-text-base); }
|
|
709
|
-
.scui-domain-summary { cursor:pointer; color:var(--scui-accent); padding-block:calc(8px * var(--scui-scaling)); }
|
|
710
|
-
.scui-domain-footnote { border-top:1px solid var(--scui-border); padding-top:calc(16px * var(--scui-scaling)); }
|
|
711
|
-
.scui-domain-banner { padding:calc(12px * var(--scui-scaling)) calc(24px * var(--scui-scaling)); background:var(--scui-bg-raised); border-left:3px solid var(--scui-accent); }
|
|
712
|
-
.scui-domain-schedule { display:flex; gap:calc(36px * var(--scui-scaling)); padding:var(--scui-domain-schedule-padding, 0 calc(24px * var(--scui-scaling)) calc(24px * var(--scui-scaling))); flex-wrap:wrap; }
|
|
713
|
-
.scui-domain-filter { display:flex; align-items:center; gap:calc(8px * var(--scui-scaling)); font-size:var(--scui-domain-search-size, inherit); }
|
|
714
|
-
.scui-domain-schedule-item { display:grid; gap:calc(5px * var(--scui-scaling)); }
|
|
715
|
-
.scui-domain-schedule-value { font-size:var(--scui-text-base); font-weight:500; }
|
|
716
|
-
.scui-domain-run { width:100%; display:grid; justify-content:normal; align-items:normal; grid-template-columns:minmax(150px,1fr) 105px 90px; gap:calc(12px * var(--scui-scaling)); text-align:left; border:0; border-bottom:var(--scui-run-border, 1px solid var(--scui-border)); border-radius:0; padding:var(--scui-run-padding, calc(16px * var(--scui-scaling)) calc(8px * var(--scui-scaling))); }
|
|
717
|
-
.scui-domain-run-cell { display:grid; justify-items:start; align-content:center; gap:calc(4px * var(--scui-scaling)); }
|
|
718
|
-
.scui-domain-run-time { font-size:var(--scui-text-base); font-weight:500; }
|
|
719
|
-
.scui-domain-facts { display:grid; grid-template-columns:1fr 1fr; gap:calc(8px * var(--scui-scaling)); font-size:var(--scui-text-lg); }
|
|
720
|
-
.scui-domain-fact-label { color:var(--scui-muted); }
|
|
721
|
-
.scui-domain-fact-value { margin:0; }
|
|
722
|
-
@container (max-width:650px) {
|
|
723
|
-
.scui-domain-header { padding:var(--scui-domain-header-padding, calc(16px * var(--scui-scaling))); }
|
|
724
|
-
.scui-domain-toolbar { padding:var(--scui-domain-toolbar-padding, calc(16px * var(--scui-scaling))); }
|
|
725
|
-
.scui-domain-toolbar { flex-wrap:wrap; align-items:center; }
|
|
726
|
-
.scui-domain-title { font-size:var(--scui-text-3xl); }
|
|
727
|
-
.scui-domain-workspace[data-detail="true"] { grid-template-columns:1fr; }
|
|
728
|
-
.scui-domain-workspace[data-detail="true"] .scui-domain-collection { display:none; }
|
|
729
|
-
.scui-domain-detail { border-left:0; padding:calc(16px * var(--scui-scaling)); }
|
|
730
|
-
.scui-domain-collection { padding:calc(12px * var(--scui-scaling)); }
|
|
731
|
-
.scui-domain-run { grid-template-columns:1fr 1fr; }
|
|
732
|
-
.scui-domain-run-cell:first-child { grid-column:1 / -1; }
|
|
733
|
-
.scui-domain-schedule { gap:calc(16px * var(--scui-scaling)); padding:var(--scui-domain-schedule-padding, 0 calc(16px * var(--scui-scaling)) calc(20px * var(--scui-scaling))); }
|
|
734
|
-
}
|
|
735
|
-
.scui-domain { display:block; width:100%; height:auto; min-width:0; font-size:var(--scui-text-xl); line-height:1.5; overflow-wrap:anywhere; }
|
|
736
|
-
.scui-domain-pre { overflow:auto; max-width:100%; white-space:pre-wrap; overflow-wrap:anywhere; }
|
|
737
|
-
.scui-inventory-nav { display:flex; flex-wrap:wrap; gap:var(--scui-inventory-nav-gap, calc(8px * var(--scui-scaling))); padding:var(--scui-inventory-nav-padding, calc(12px * var(--scui-scaling)) calc(24px * var(--scui-scaling))); border-bottom:var(--scui-inventory-nav-border, 0); background:var(--scui-inventory-nav-bg, transparent); }
|
|
738
|
-
.scui-inventory-actions { display:flex; flex-wrap:wrap; gap:calc(8px * var(--scui-scaling)); padding:calc(12px * var(--scui-scaling)) calc(24px * var(--scui-scaling)); }
|
|
739
|
-
.scui-inventory-nav-item[aria-current="page"] { background:var(--scui-inventory-nav-active-bg, var(--scui-fill)); font-weight:var(--scui-inventory-nav-active-weight, 600); }
|
|
740
|
-
.scui-inventory-rows { display:grid; gap:var(--scui-inventory-row-gap, calc(12px * var(--scui-scaling))); }
|
|
741
|
-
.scui-inventory-row { min-width:0; border:var(--scui-inventory-row-border, 1px solid var(--scui-border)); border-radius:var(--scui-inventory-row-radius, var(--scui-card-radius, var(--scui-radius-md))); padding:var(--scui-inventory-row-padding, calc(16px * var(--scui-scaling))); background:var(--scui-inventory-row-bg, transparent); }
|
|
742
|
-
.scui-inventory-row-title { font-family:var(--scui-font); font-weight:bold; font-size:var(--scui-inventory-row-title-size, var(--scui-text-2xl)); margin:0; }
|
|
743
|
-
.scui-inventory-row-action { text-align:left; max-width:100%; white-space:normal; }
|
|
744
|
-
.scui-source-inspector[data-control-size="large"] .scui-domain-button,.scui-inventory-action { min-height:var(--scui-domain-control-height, calc(44px * var(--scui-scaling))); }
|
|
745
|
-
.scui-source-inspector[data-control-size="large"] .scui-domain-search-input,.scui-source-inspector[data-control-size="large"] .scui-inventory-memory-search-input { font-size:16px; }
|
|
746
|
-
.scui-inventory-memory-search { display:flex; flex-wrap:wrap; align-items:end; gap:calc(8px * var(--scui-scaling)); margin-bottom:calc(16px * var(--scui-scaling)); }
|
|
747
|
-
.scui-inventory-memory-search-label { display:grid; gap:calc(4px * var(--scui-scaling)); max-width:100%; }
|
|
748
|
-
.scui-inventory-memory-search-input { max-width:100%; padding:calc(8px * var(--scui-scaling)); min-height:calc(40px * var(--scui-scaling)); }
|
|
749
|
-
.scui-domain-header-copy { min-width:0; }
|
|
750
|
-
.scui-domain-header { flex-wrap:wrap; }
|
|
751
|
-
.scui-domain-workspace { align-items:start; }
|
|
752
|
-
/* After the pressed and current states, which hover still repaints. */
|
|
753
|
-
.scui-domain-button:hover { background:var(--scui-bg-raised); }
|
|
754
|
-
.scui-domain-task:hover:not(:disabled) { background:var(--scui-task-hover-bg, var(--scui-bg-raised)); }
|
|
755
|
-
@media (pointer:coarse) {
|
|
756
|
-
.scui-domain-button,.scui-source-inspector[data-control-size="large"] .scui-domain-button { min-height:44px; }
|
|
757
|
-
.scui-domain-search-input,.scui-inventory-memory-search-input,.scui-domain-select { min-height:44px; font-size:16px; }
|
|
758
|
-
}
|
|
759
658
|
.scui-agent-configuration { color:var(--scui-fg); font:var(--scui-text-base)/1.45 var(--scui-font); padding:var(--scui-panel-padding, calc(20px * var(--scui-scaling))); width:var(--scui-panel-width, auto); max-width:var(--scui-panel-max-width, 720px); margin:var(--scui-panel-margin, 0 auto); }
|
|
760
659
|
.scui-configuration-header { display:flex; align-items:center; justify-content:space-between; gap:calc(12px * var(--scui-scaling)); }
|
|
761
660
|
.scui-configuration-title { font-size:var(--scui-configuration-title-size, var(--scui-text-3xl)); margin:0; font-family:var(--scui-heading-font, inherit); font-weight:var(--scui-heading-weight, bold); }
|
package/server/identity-orgs.mjs
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
// Joining a team through an identity service's organization (Volter ID ADR-0002): a team may be linked to an
|
|
2
|
-
// organization the identity service keeps for this server, and a person signed in with that service who is a member of
|
|
3
|
-
// it joins the team with no invitation. The identity service's tokens name only the person, so membership is asked
|
|
4
|
-
// through its organizations door with this server's own product credential (a `client_credentials` token for
|
|
5
|
-
// `<issuer>/organizations`, scope `organizations`), which reads only the organizations this product created.
|
|
6
|
-
//
|
|
7
|
-
// Configured by `identityOrganizations: { clientId, clientSecret, teams: { <team id or name>: <organization slug> } }`,
|
|
8
|
-
// from the environment as VOLTER_ORG_CLIENT_ID, VOLTER_ORG_CLIENT_SECRET and VOLTER_TEAM_ORGS (`team=slug,…`).
|
|
9
|
-
|
|
10
|
-
/** The configuration from the environment, or null when a piece is missing. */
|
|
11
|
-
export function identityOrganizationsFromEnv(env) {
|
|
12
|
-
const clientId = env.VOLTER_ORG_CLIENT_ID, clientSecret = env.VOLTER_ORG_CLIENT_SECRET, pairs = env.VOLTER_TEAM_ORGS;
|
|
13
|
-
if (!clientId || !clientSecret || !pairs) return null;
|
|
14
|
-
const teams = {};
|
|
15
|
-
for (const pair of String(pairs).split(',')) {
|
|
16
|
-
const [team, slug] = pair.split('=').map((part) => part?.trim());
|
|
17
|
-
if (team && slug) teams[team] = slug;
|
|
18
|
-
}
|
|
19
|
-
return Object.keys(teams).length ? { clientId, clientSecret, teams } : null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** The organization slug a team is linked to, by its id or its name. */
|
|
23
|
-
export function linkedOrganization(api, team) {
|
|
24
|
-
const teams = api.identityOrganizations?.teams ?? {};
|
|
25
|
-
return teams[team.id] ?? teams[team.name] ?? null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
async function productToken(api) {
|
|
29
|
-
const held = api.identityOrganizationToken;
|
|
30
|
-
if (held && held.expiresAt > api.clock.nowMs() + 60_000) return held.token;
|
|
31
|
-
const discovery = await (await fetch(`${api.identityIssuer}/.well-known/openid-configuration`)).json();
|
|
32
|
-
const { clientId, clientSecret } = api.identityOrganizations;
|
|
33
|
-
const response = await fetch(discovery.token_endpoint, {
|
|
34
|
-
method: 'POST',
|
|
35
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: `Basic ${btoa(`${clientId}:${clientSecret}`)}` },
|
|
36
|
-
body: new URLSearchParams({ grant_type: 'client_credentials', scope: 'organizations', resource: `${api.identityIssuer}/organizations` }),
|
|
37
|
-
});
|
|
38
|
-
if (!response.ok) throw new Error(`the identity service refused this server's product credential (${response.status})`);
|
|
39
|
-
const value = await response.json();
|
|
40
|
-
api.identityOrganizationToken = { token: value.access_token, expiresAt: api.clock.nowMs() + (Number(value.expires_in) || 300) * 1000 };
|
|
41
|
-
return value.access_token;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Whether the identity service's subject is a member of the organization `slug` this product keeps there. */
|
|
45
|
-
export async function inOrganization(api, subject, slug) {
|
|
46
|
-
if (!api.identityOrganizations || !api.identityIssuer) return false;
|
|
47
|
-
const token = await productToken(api);
|
|
48
|
-
const response = await fetch(`${api.identityIssuer}/api/people/${encodeURIComponent(subject)}/organizations`, { headers: { Authorization: `Bearer ${token}` } });
|
|
49
|
-
if (response.status === 404) return false;
|
|
50
|
-
if (!response.ok) throw new Error(`the identity service did not answer for that person's organizations (${response.status})`);
|
|
51
|
-
const value = await response.json();
|
|
52
|
-
const rows = Array.isArray(value) ? value : Array.isArray(value?.organizations) ? value.organizations : [];
|
|
53
|
-
return rows.some((row) => row?.slug === slug);
|
|
54
|
-
}
|