@volter/teams 0.1.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 +21 -0
- package/admission.mjs +62 -0
- package/browser-channel.mjs +157 -0
- package/channel.mjs +319 -0
- package/client.mjs +119 -0
- package/home.mjs +220 -0
- package/identity.mjs +85 -0
- package/index.mjs +8 -0
- package/machine-core.mjs +397 -0
- package/package.json +39 -0
- package/products.mjs +157 -0
- package/server/auth.mjs +13 -0
- package/server/core.mjs +64 -0
- package/server/events.mjs +3 -0
- package/server/http.mjs +211 -0
- package/server/identity.mjs +15 -0
- package/server/machine-link.mjs +228 -0
- package/server/migrations/001.mjs +298 -0
- package/server/migrations/002.mjs +13 -0
- package/server/migrations/003.mjs +11 -0
- package/server/migrations/004.mjs +35 -0
- package/server/migrations/index.mjs +6 -0
- package/server/request-json.mjs +74 -0
- package/server/wire.mjs +10 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Machine doors through the server (design §11).
|
|
2
|
+
//
|
|
3
|
+
// GET /api/v1/teams/{team}/machines/{id}/link the machine's one outbound link
|
|
4
|
+
// GET /api/v1/teams/{team}/machines/{id}/channel a caller's channel to that machine
|
|
5
|
+
// /machines/{id}/access[/{grant}] machine grants: list, issue, revoke
|
|
6
|
+
//
|
|
7
|
+
// A caller's channel is the machine channel (`../channel.mjs`) terminated here.
|
|
8
|
+
// Every stream the caller opens is re-opened on the machine's link carrying a
|
|
9
|
+
// claim, { principal, groups, custodian | grants }, recomputed from the store at
|
|
10
|
+
// that moment; the machine re-derives the admission from its own synced copy
|
|
11
|
+
// of the grants and checks each door against it. A caller with no grant on a
|
|
12
|
+
// machine is refused exactly as a machine that does not exist is.
|
|
13
|
+
import { Channel } from '../channel.mjs';
|
|
14
|
+
import { bearer } from './auth.mjs';
|
|
15
|
+
|
|
16
|
+
const LINK_PROTOCOL = 'supercode-machine-link.v1';
|
|
17
|
+
const CHANNEL_PROTOCOL = 'supercode-machine-channel.v1';
|
|
18
|
+
/** The machine daemon's door vocabulary: operate (operator: harness.v1 and launch) and the participant caps its doors check. */
|
|
19
|
+
const CAPS = new Set(['operate', 'view', 'observe', 'interact', 'control', 'files', 'ports']);
|
|
20
|
+
// A target is `*` or `<kind>:<name>`. The server checks shape only: which kinds a machine
|
|
21
|
+
// serves is the daemon's business, and a grant on a kind it does not serve covers nothing.
|
|
22
|
+
const TARGET = /^(\*|root:(\*|\/[^\x00-\x1f]{0,4095})|(?!root:)[a-z][a-z0-9-]{0,31}:[^\x00-\x1f]{1,4096})$/;
|
|
23
|
+
// `*` is the whole machine: operate it, or list it. Anything more names the kind it reaches.
|
|
24
|
+
const WHOLE_MACHINE_CAPS = new Set(['operate', 'view']);
|
|
25
|
+
const MAX_GRANT_MS = 90 * 24 * 60 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
const links = (api) => api.machineLinks ??= new Map();
|
|
28
|
+
const channels = (api) => api.machineChannels ??= new Set();
|
|
29
|
+
const key = (teamId, machineId) => `${teamId}\0${machineId}`;
|
|
30
|
+
const grantJson = (row) => ({ id: row.id, machine_id: row.machine_id, subject_id: row.subject_id, caps: JSON.parse(row.caps_json), targets: JSON.parse(row.targets_json), issuer_id: row.issuer_id, created_at: row.created_at, expires_at: row.expires_at, revision: row.revision });
|
|
31
|
+
|
|
32
|
+
/** The unexpired grants of one machine, as its configuration carries them to the machine. */
|
|
33
|
+
export async function machineGrantRows(api, teamId, machineId) {
|
|
34
|
+
const rows = await api.store.query('SELECT * FROM machine_grants WHERE team_id=? AND machine_id=? AND expires_at>? ORDER BY id', [teamId, machineId, api.clock.nowMs()]);
|
|
35
|
+
return rows.map(grantJson);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What the caller may claim on the machine right now, or null (indistinguishable from no machine). */
|
|
39
|
+
async function claimFor(api, auth, machineId) {
|
|
40
|
+
const machine = await api.store.one('SELECT custodian_id FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', [auth.team_id, machineId]);
|
|
41
|
+
if (!machine) return null;
|
|
42
|
+
const membership = await api.store.one('SELECT active FROM memberships WHERE team_id=? AND principal_id=?', [auth.team_id, auth.principal_id]);
|
|
43
|
+
if (!membership?.active) return null;
|
|
44
|
+
const groups = auth.group_ids ?? [];
|
|
45
|
+
if (machine.custodian_id === auth.principal_id) return { principal: auth.principal_id, groups, custodian: true };
|
|
46
|
+
const subjects = [auth.principal_id, ...groups];
|
|
47
|
+
const rows = await api.store.query(`SELECT id, revision FROM machine_grants WHERE team_id=? AND machine_id=? AND expires_at>? AND subject_id IN (${subjects.map(() => '?').join(',')}) ORDER BY id`, [auth.team_id, machineId, api.clock.nowMs(), ...subjects]);
|
|
48
|
+
if (!rows.length) return null;
|
|
49
|
+
// Revisions let the machine notice a grant it holds has changed (rewritten, narrowed) and resync.
|
|
50
|
+
return { principal: auth.principal_id, groups, grants: rows.map((row) => row.id), revisions: Object.fromEntries(rows.map((row) => [row.id, row.revision])) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Close every channel a member leaving the team, or a group losing a member or being deleted, leaves without its claimed authority. */
|
|
54
|
+
export function closeChannelsFor(api, teamId, { principalId = null, groupId = null, reason = 'access_revoked' } = {}) {
|
|
55
|
+
let closed = 0;
|
|
56
|
+
for (const entry of channels(api)) {
|
|
57
|
+
if (entry.teamId !== teamId) continue;
|
|
58
|
+
const claim = entry.claim ?? {};
|
|
59
|
+
const hit = groupId ? (claim.groups ?? []).includes(groupId) && (!principalId || entry.principal === principalId) : entry.principal === principalId;
|
|
60
|
+
if (!hit) continue;
|
|
61
|
+
entry.close(reason);
|
|
62
|
+
closed += 1;
|
|
63
|
+
}
|
|
64
|
+
return closed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Close the channels a revocation or machine removal leaves without authority; the next stream would be refused anyway. */
|
|
68
|
+
export function closeMachineChannels(api, teamId, machineId, { grantId = null, reason = 'access_revoked' } = {}) {
|
|
69
|
+
let closed = 0;
|
|
70
|
+
for (const entry of channels(api)) {
|
|
71
|
+
if (entry.teamId !== teamId || entry.machineId !== machineId) continue;
|
|
72
|
+
if (grantId && !entry.claim?.grants?.includes(grantId)) continue;
|
|
73
|
+
entry.close(reason);
|
|
74
|
+
closed += 1;
|
|
75
|
+
}
|
|
76
|
+
if (!grantId) links(api).get(key(teamId, machineId))?.close(reason);
|
|
77
|
+
return closed;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function machineGrantRoute({ api, req, res, auth, machineId, grantId, url, requestId, parseJson, exact, fail, send, result, guardedMutation, requireRevision }) {
|
|
81
|
+
if (!['user', 'service'].includes(auth.kind)) fail(403, 'forbidden', 'user or service credential required');
|
|
82
|
+
const machine = await api.store.one('SELECT * FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', [auth.team_id, machineId]);
|
|
83
|
+
const admin = auth.active && (auth.role === 'owner' || auth.role === 'admin');
|
|
84
|
+
// Team roles grant no execution authority: only the machine's custodian, or an admin, issues its grants.
|
|
85
|
+
if (!machine || (machine.custodian_id !== auth.principal_id && !admin)) fail(404, 'not_found', 'machine not found');
|
|
86
|
+
const context = { server_id: api.serverId, team_id: auth.team_id, principal_id: auth.principal_id };
|
|
87
|
+
if (req.method === 'GET') {
|
|
88
|
+
if (grantId) {
|
|
89
|
+
const row = await api.store.one('SELECT * FROM machine_grants WHERE team_id=? AND machine_id=? AND id=?', [auth.team_id, machineId, grantId]);
|
|
90
|
+
if (!row) fail(404, 'not_found', 'grant not found');
|
|
91
|
+
return send(res, 200, result(api, auth, grantJson(row)), requestId, { ETag: `"${row.revision}"` });
|
|
92
|
+
}
|
|
93
|
+
if ([...url.searchParams].length) fail(400, 'invalid_request', 'machine grants take no query parameters');
|
|
94
|
+
return send(res, 200, { version: 1, context, items: await machineGrantRows(api, auth.team_id, machineId), next_cursor: null }, requestId);
|
|
95
|
+
}
|
|
96
|
+
if (req.method === 'POST' && !grantId) {
|
|
97
|
+
const body = await parseJson(req); exact(body, ['subject_id', 'caps', 'targets', 'expires_at']);
|
|
98
|
+
if (!Array.isArray(body.caps) || !body.caps.length || new Set(body.caps).size !== body.caps.length || !body.caps.every((cap) => CAPS.has(cap))) fail(400, 'invalid_request', `caps must be a non-empty set of ${[...CAPS].join(', ')}`);
|
|
99
|
+
if (!Array.isArray(body.targets) || !body.targets.length || body.targets.length > 64 || !body.targets.every((target) => typeof target === 'string' && TARGET.test(target))) fail(400, 'invalid_request', 'targets must name * or <kind>:<name> (for example pane:<key>, root:/path, port:<pane>/<n>, pane:*)');
|
|
100
|
+
if (body.caps.includes('operate') && !(body.targets.length === 1 && body.targets[0] === '*')) fail(400, 'invalid_request', 'operate is the whole machine: its only target is *');
|
|
101
|
+
if (body.targets.includes('*') && !body.caps.every((cap) => WHOLE_MACHINE_CAPS.has(cap))) fail(400, 'invalid_request', 'only operate and view may target *; name the kind instead (e.g. pane:*, port:*)');
|
|
102
|
+
const now = api.clock.nowMs();
|
|
103
|
+
if (!Number.isSafeInteger(body.expires_at) || body.expires_at <= now || body.expires_at > now + MAX_GRANT_MS) fail(400, 'invalid_request', 'machine grant expiry must be within 90 days');
|
|
104
|
+
const subject = typeof body.subject_id === 'string' ? await api.store.one(`SELECT 'member' kind FROM memberships WHERE team_id=? AND principal_id=? AND active=1 UNION ALL SELECT 'group' kind FROM groups_table WHERE team_id=? AND id=?`, [auth.team_id, body.subject_id, auth.team_id, body.subject_id]) : null;
|
|
105
|
+
if (!subject) fail(400, 'invalid_request', 'subject must be an active member, service account or group of this team');
|
|
106
|
+
const id = api.ids.next('grant');
|
|
107
|
+
const row = { id, machine_id: machineId, subject_id: body.subject_id, caps: body.caps, targets: body.targets, issuer_id: auth.principal_id, created_at: now, expires_at: body.expires_at, revision: 1 };
|
|
108
|
+
const response = result(api, auth, row);
|
|
109
|
+
await guardedMutation(api, res, auth, [{ sql: 'SELECT 1 ok FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', params: [auth.team_id, machineId], code: 'not_found', status: 404, message: 'machine not found' }], [
|
|
110
|
+
{ sql: 'INSERT INTO machine_grants(team_id,id,machine_id,subject_id,caps_json,targets_json,issuer_id,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?)', params: [auth.team_id, id, machineId, body.subject_id, JSON.stringify(body.caps), JSON.stringify(body.targets), auth.principal_id, now, body.expires_at] },
|
|
111
|
+
], { status: 201, body: response, targetId: id });
|
|
112
|
+
return send(res, 201, response, requestId, { ETag: '"1"' });
|
|
113
|
+
}
|
|
114
|
+
if (req.method === 'DELETE' && grantId) {
|
|
115
|
+
const row = await api.store.one('SELECT * FROM machine_grants WHERE team_id=? AND machine_id=? AND id=?', [auth.team_id, machineId, grantId]);
|
|
116
|
+
if (!row) fail(404, 'not_found', 'grant not found');
|
|
117
|
+
requireRevision(req, row.revision, 'machine grant');
|
|
118
|
+
await guardedMutation(api, res, auth, [{ sql: 'SELECT 1 ok FROM machine_grants WHERE team_id=? AND id=? AND revision=?', params: [auth.team_id, grantId, row.revision], code: 'revision_mismatch', status: 409, message: 'machine grant revision changed' }], [
|
|
119
|
+
{ sql: 'DELETE FROM machine_grants WHERE team_id=? AND id=?', params: [auth.team_id, grantId] },
|
|
120
|
+
], { status: 204, body: null, targetId: grantId });
|
|
121
|
+
closeMachineChannels(api, auth.team_id, machineId, { grantId });
|
|
122
|
+
return send(res, 204, null, requestId);
|
|
123
|
+
}
|
|
124
|
+
fail(405, 'not_found', 'method not allowed');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Decide an upgrade to one of the two machine sockets from its path and bearer, before any
|
|
129
|
+
* WebSocket exists. Returns { status, code, message } to refuse, or { door, teamId, machineId, auth }.
|
|
130
|
+
*/
|
|
131
|
+
export async function authorizeMachineUpgrade(api, { authenticate, url, headers }) {
|
|
132
|
+
if (api.publicOrigin && headers.host !== new URL(api.publicOrigin).host) return { status: 400, code: 'invalid_request', message: 'request Host does not match configured public origin' };
|
|
133
|
+
const match = /^\/api\/v1\/teams\/([^/]+)\/machines\/([^/]+)\/(link|channel)$/.exec(new URL(url, 'http://local').pathname);
|
|
134
|
+
if (!match) return { status: 404, code: 'not_found', message: 'not found' };
|
|
135
|
+
const [, teamId, machineId, door] = match;
|
|
136
|
+
const auth = await authenticate(api, bearerFrom(headers));
|
|
137
|
+
if (!auth) return { status: 401, code: 'authentication_required', message: 'authentication required' };
|
|
138
|
+
if (door === 'link') {
|
|
139
|
+
if (auth.kind !== 'machine' || auth.team_id !== teamId || auth.machine_id !== machineId) return { status: 403, code: 'forbidden', message: 'exact machine credential required' };
|
|
140
|
+
const machine = await api.store.one('SELECT incarnation_id FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', [teamId, machineId]);
|
|
141
|
+
if (!machine) return { status: 404, code: 'not_found', message: 'not found' };
|
|
142
|
+
return { door, teamId, machineId, auth };
|
|
143
|
+
}
|
|
144
|
+
if (!['user', 'service'].includes(auth.kind)) return { status: 403, code: 'forbidden', message: 'user or service credential required' };
|
|
145
|
+
if (auth.team_id === null && auth.kind === 'user') {
|
|
146
|
+
const membership = await api.store.one('SELECT role,active FROM memberships WHERE team_id=? AND principal_id=?', [teamId, auth.principal_id]);
|
|
147
|
+
if (!membership?.active) return { status: 401, code: 'authentication_required', message: 'authentication required' };
|
|
148
|
+
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) });
|
|
149
|
+
} else if (auth.team_id !== teamId || !auth.active) return { status: 401, code: 'authentication_required', message: 'authentication required' };
|
|
150
|
+
if (!await claimFor(api, auth, machineId)) return { status: 404, code: 'not_found', message: 'machine not found' };
|
|
151
|
+
return { door, teamId, machineId, auth };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A caller's bearer: the Authorization header, or, for a browser (which cannot set headers on
|
|
156
|
+
* a WebSocket), a `bearer.<token>` entry in Sec-WebSocket-Protocol beside the door's protocol.
|
|
157
|
+
*/
|
|
158
|
+
function bearerFrom(headers) {
|
|
159
|
+
const header = bearer(headers.authorization);
|
|
160
|
+
if (header) return header;
|
|
161
|
+
const offered = String(headers['sec-websocket-protocol'] ?? '').split(',').map((value) => value.trim());
|
|
162
|
+
const entry = offered.find((value) => value.startsWith('bearer.'));
|
|
163
|
+
return entry ? entry.slice('bearer.'.length) : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Hand an accepted socket, as a duplex byte stream, to its door. `close(reason)` ends the socket. */
|
|
167
|
+
export function acceptMachineSocket(api, decision, duplex, close) {
|
|
168
|
+
if (decision.door === 'link') return acceptLink(api, decision.teamId, decision.machineId, duplex, close);
|
|
169
|
+
return acceptChannel(api, decision.auth, decision.machineId, duplex, close);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export { LINK_PROTOCOL, CHANNEL_PROTOCOL };
|
|
173
|
+
|
|
174
|
+
/** The machine dialled in: its link replaces any older one for the same machine. */
|
|
175
|
+
async function acceptLink(api, teamId, machineId, duplex, closeSocket) {
|
|
176
|
+
let channel;
|
|
177
|
+
try {
|
|
178
|
+
channel = await Channel.accept(duplex, { floor: 2 });
|
|
179
|
+
} catch { closeSocket('refused'); return; }
|
|
180
|
+
const id = key(teamId, machineId);
|
|
181
|
+
const previous = links(api).get(id);
|
|
182
|
+
const link = { channel, close: (reason) => closeSocket(reason) };
|
|
183
|
+
links(api).set(id, link);
|
|
184
|
+
previous?.close('replaced');
|
|
185
|
+
channel.on('error', () => {});
|
|
186
|
+
channel.on('close', () => {
|
|
187
|
+
if (links(api).get(id) === link) links(api).delete(id);
|
|
188
|
+
// A caller's open streams ride this link; they end with it, and the caller reopens.
|
|
189
|
+
for (const entry of channels(api)) if (entry.teamId === teamId && entry.machineId === machineId) entry.dropStreams('machine_offline');
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A caller's channel: each stream it opens is forwarded to the machine with a fresh claim. */
|
|
194
|
+
async function acceptChannel(api, auth, machineId, duplex, closeSocket) {
|
|
195
|
+
let channel;
|
|
196
|
+
try {
|
|
197
|
+
channel = await Channel.accept(duplex, { floor: 2 });
|
|
198
|
+
} catch { closeSocket('refused'); return; }
|
|
199
|
+
const open = new Set();
|
|
200
|
+
const entry = {
|
|
201
|
+
teamId: auth.team_id, machineId, principal: auth.principal_id, claim: null,
|
|
202
|
+
close: (reason) => closeSocket(reason),
|
|
203
|
+
dropStreams: (reason) => { for (const pair of open) pair.close(reason); },
|
|
204
|
+
};
|
|
205
|
+
channels(api).add(entry);
|
|
206
|
+
channel.on('error', () => {});
|
|
207
|
+
channel.on('close', () => { channels(api).delete(entry); entry.dropStreams('caller closed'); });
|
|
208
|
+
channel.on('stream', async (stream, accept, refuse) => {
|
|
209
|
+
const claim = await claimFor(api, auth, machineId).catch(() => null);
|
|
210
|
+
if (!claim) return refuse('no machine grant for this caller');
|
|
211
|
+
entry.claim = claim;
|
|
212
|
+
const link = links(api).get(key(auth.team_id, machineId));
|
|
213
|
+
if (!link) return refuse('machine_offline: the machine is not linked to this server');
|
|
214
|
+
let upstream;
|
|
215
|
+
try {
|
|
216
|
+
upstream = await link.channel.open(stream.name, { ...(stream.params ?? {}), _admitted: claim });
|
|
217
|
+
} catch (error) {
|
|
218
|
+
return refuse(error.message.replace(/^[a-z]+ refused: /, ''));
|
|
219
|
+
}
|
|
220
|
+
accept();
|
|
221
|
+
const pair = { close: (reason) => { if (!stream.closed) stream.close(reason); if (!upstream.closed) upstream.close(reason); } };
|
|
222
|
+
open.add(pair);
|
|
223
|
+
stream.on('data', (data) => { if (!upstream.closed) upstream.write(data); });
|
|
224
|
+
upstream.on('data', (data) => { if (!stream.closed) stream.write(data); });
|
|
225
|
+
stream.on('close', (reason) => { open.delete(pair); if (!upstream.closed) upstream.close(reason); });
|
|
226
|
+
upstream.on('close', (reason) => { open.delete(pair); if (!stream.closed) stream.close(reason); });
|
|
227
|
+
});
|
|
228
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// Schema migration 001 (SQLite DDL), applied in order by store-core migrate().
|
|
2
|
+
export default `-- Normative schema 1. Apply PRAGMAs first, then DDL/user_version in one migration transaction.
|
|
3
|
+
-- No plaintext credentials, native machine paths, or artifact bytes in audit/event payloads.
|
|
4
|
+
PRAGMA foreign_keys = ON;
|
|
5
|
+
PRAGMA journal_mode = WAL;
|
|
6
|
+
PRAGMA synchronous = FULL;
|
|
7
|
+
PRAGMA busy_timeout = 5000;
|
|
8
|
+
|
|
9
|
+
CREATE TABLE server_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
|
|
10
|
+
CREATE TABLE principals (
|
|
11
|
+
id TEXT PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('user','service')),
|
|
12
|
+
display_name TEXT NOT NULL
|
|
13
|
+
) STRICT;
|
|
14
|
+
CREATE TABLE teams (
|
|
15
|
+
id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, revision INTEGER NOT NULL DEFAULT 1,
|
|
16
|
+
auth_epoch INTEGER NOT NULL DEFAULT 1, journal_revision INTEGER NOT NULL DEFAULT 0,
|
|
17
|
+
CHECK(revision >= 1 AND auth_epoch >= 1 AND journal_revision >= 0)
|
|
18
|
+
) STRICT;
|
|
19
|
+
CREATE TABLE memberships (
|
|
20
|
+
team_id TEXT NOT NULL REFERENCES teams(id), principal_id TEXT NOT NULL REFERENCES principals(id),
|
|
21
|
+
role TEXT NOT NULL CHECK(role IN ('owner','admin','member','viewer')),
|
|
22
|
+
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), revision INTEGER NOT NULL DEFAULT 1,
|
|
23
|
+
PRIMARY KEY(team_id,principal_id)
|
|
24
|
+
) STRICT;
|
|
25
|
+
CREATE TABLE groups_table (
|
|
26
|
+
team_id TEXT NOT NULL REFERENCES teams(id), id TEXT NOT NULL, name TEXT NOT NULL,
|
|
27
|
+
revision INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(team_id,id), UNIQUE(team_id,name)
|
|
28
|
+
) STRICT;
|
|
29
|
+
CREATE TABLE group_members (
|
|
30
|
+
team_id TEXT NOT NULL, group_id TEXT NOT NULL, principal_id TEXT NOT NULL,
|
|
31
|
+
PRIMARY KEY(team_id,group_id,principal_id),
|
|
32
|
+
FOREIGN KEY(team_id,group_id) REFERENCES groups_table(team_id,id),
|
|
33
|
+
FOREIGN KEY(team_id,principal_id) REFERENCES memberships(team_id,principal_id)
|
|
34
|
+
) STRICT;
|
|
35
|
+
CREATE TABLE invitations (
|
|
36
|
+
team_id TEXT NOT NULL REFERENCES teams(id), id TEXT PRIMARY KEY,
|
|
37
|
+
principal_id TEXT NOT NULL REFERENCES principals(id), secret_hash TEXT NOT NULL UNIQUE,
|
|
38
|
+
role TEXT NOT NULL CHECK(role IN ('admin','member','viewer')), expires_at INTEGER NOT NULL,
|
|
39
|
+
consumed_at INTEGER, revoked_at INTEGER
|
|
40
|
+
) STRICT;
|
|
41
|
+
CREATE TABLE machines (
|
|
42
|
+
team_id TEXT NOT NULL, id TEXT NOT NULL, name TEXT NOT NULL, custodian_id TEXT NOT NULL,
|
|
43
|
+
enrollment_key TEXT NOT NULL, revoked_at INTEGER, last_seen_at INTEGER,
|
|
44
|
+
incarnation_id TEXT, heartbeat_sequence INTEGER NOT NULL DEFAULT 0, revision INTEGER NOT NULL DEFAULT 1,
|
|
45
|
+
PRIMARY KEY(team_id,id), UNIQUE(team_id,custodian_id,enrollment_key),
|
|
46
|
+
FOREIGN KEY(team_id,custodian_id) REFERENCES memberships(team_id,principal_id)
|
|
47
|
+
) STRICT;
|
|
48
|
+
CREATE TABLE sources (
|
|
49
|
+
team_id TEXT NOT NULL, id TEXT NOT NULL, name TEXT NOT NULL,
|
|
50
|
+
kind TEXT NOT NULL CHECK(kind IN ('connector','otlp')), machine_id TEXT, custodian_id TEXT NOT NULL,
|
|
51
|
+
visibility TEXT NOT NULL CHECK(visibility IN ('team','restricted')),
|
|
52
|
+
state TEXT NOT NULL CHECK(state IN ('active','paused','revoked')), revision INTEGER NOT NULL DEFAULT 1,
|
|
53
|
+
last_received_at INTEGER, decoder TEXT, counters_json TEXT NOT NULL DEFAULT '{}', last_error TEXT,
|
|
54
|
+
PRIMARY KEY(team_id,id), UNIQUE(team_id,name),
|
|
55
|
+
FOREIGN KEY(team_id,custodian_id) REFERENCES memberships(team_id,principal_id),
|
|
56
|
+
FOREIGN KEY(team_id,machine_id) REFERENCES machines(team_id,id), CHECK(json_valid(counters_json))
|
|
57
|
+
) STRICT;
|
|
58
|
+
CREATE TABLE machine_roots (
|
|
59
|
+
team_id TEXT NOT NULL, machine_id TEXT NOT NULL, id TEXT NOT NULL,
|
|
60
|
+
value_json TEXT NOT NULL CHECK(json_valid(value_json)),
|
|
61
|
+
PRIMARY KEY(team_id,id), FOREIGN KEY(team_id,machine_id) REFERENCES machines(team_id,id)
|
|
62
|
+
) STRICT;
|
|
63
|
+
CREATE TABLE credentials (
|
|
64
|
+
id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, principal_id TEXT NOT NULL REFERENCES principals(id),
|
|
65
|
+
kind TEXT NOT NULL CHECK(kind IN ('user','service','machine','ingest')),
|
|
66
|
+
team_id TEXT REFERENCES teams(id), source_id TEXT, machine_id TEXT,
|
|
67
|
+
expires_at INTEGER, revoked_at INTEGER, created_at INTEGER NOT NULL,
|
|
68
|
+
FOREIGN KEY(team_id,source_id) REFERENCES sources(team_id,id),
|
|
69
|
+
FOREIGN KEY(team_id,machine_id) REFERENCES machines(team_id,id),
|
|
70
|
+
CHECK(kind NOT IN ('machine','ingest') OR (team_id IS NOT NULL AND source_id IS NOT NULL)),
|
|
71
|
+
CHECK(kind != 'machine' OR machine_id IS NOT NULL)
|
|
72
|
+
) STRICT;
|
|
73
|
+
CREATE TABLE sync_rules (
|
|
74
|
+
team_id TEXT NOT NULL, id TEXT NOT NULL, source_id TEXT NOT NULL, machine_id TEXT NOT NULL,
|
|
75
|
+
selector_json TEXT NOT NULL, visibility TEXT NOT NULL CHECK(visibility IN ('team','restricted')),
|
|
76
|
+
backfill_json TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('active','paused','removed')),
|
|
77
|
+
capture_after INTEGER NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
|
|
78
|
+
PRIMARY KEY(team_id,id), FOREIGN KEY(team_id,source_id) REFERENCES sources(team_id,id),
|
|
79
|
+
FOREIGN KEY(team_id,machine_id) REFERENCES machines(team_id,id),
|
|
80
|
+
CHECK(json_valid(selector_json) AND json_valid(backfill_json))
|
|
81
|
+
) STRICT;
|
|
82
|
+
CREATE TABLE sessions (
|
|
83
|
+
team_id TEXT NOT NULL REFERENCES teams(id), id TEXT NOT NULL, harness TEXT NOT NULL,
|
|
84
|
+
custodian_id TEXT NOT NULL, visibility TEXT NOT NULL CHECK(visibility IN ('team','restricted')),
|
|
85
|
+
revision INTEGER NOT NULL DEFAULT 0, access_revision INTEGER NOT NULL DEFAULT 1,
|
|
86
|
+
created_at INTEGER NOT NULL, deleted_at INTEGER, canonical_id TEXT,
|
|
87
|
+
PRIMARY KEY(team_id,id), FOREIGN KEY(team_id,custodian_id) REFERENCES memberships(team_id,principal_id),
|
|
88
|
+
FOREIGN KEY(team_id,canonical_id) REFERENCES sessions(team_id,id)
|
|
89
|
+
) STRICT;
|
|
90
|
+
CREATE TABLE session_grants (
|
|
91
|
+
team_id TEXT NOT NULL, id TEXT NOT NULL, session_id TEXT NOT NULL,
|
|
92
|
+
subject_kind TEXT NOT NULL CHECK(subject_kind IN ('principal','group')), subject_id TEXT NOT NULL,
|
|
93
|
+
role TEXT NOT NULL CHECK(role IN ('reader','manager')), expires_at INTEGER,
|
|
94
|
+
PRIMARY KEY(team_id,id), FOREIGN KEY(team_id,session_id) REFERENCES sessions(team_id,id),
|
|
95
|
+
UNIQUE(team_id,session_id,subject_kind,subject_id)
|
|
96
|
+
) STRICT;
|
|
97
|
+
CREATE TABLE origins (
|
|
98
|
+
team_id TEXT NOT NULL, source_id TEXT NOT NULL, harness TEXT NOT NULL, native_id TEXT NOT NULL,
|
|
99
|
+
session_id TEXT NOT NULL, generation INTEGER NOT NULL DEFAULT 0, sequence INTEGER NOT NULL DEFAULT 0,
|
|
100
|
+
digest TEXT, deleted_at INTEGER,
|
|
101
|
+
PRIMARY KEY(team_id,source_id,harness,native_id),
|
|
102
|
+
FOREIGN KEY(team_id,source_id) REFERENCES sources(team_id,id),
|
|
103
|
+
FOREIGN KEY(team_id,session_id) REFERENCES sessions(team_id,id)
|
|
104
|
+
) STRICT;
|
|
105
|
+
CREATE TABLE captures (
|
|
106
|
+
team_id TEXT NOT NULL, id TEXT NOT NULL, source_id TEXT NOT NULL, harness TEXT NOT NULL,
|
|
107
|
+
native_id TEXT NOT NULL, generation INTEGER NOT NULL, sequence INTEGER NOT NULL,
|
|
108
|
+
previous_digest TEXT, state TEXT NOT NULL CHECK(state IN ('staging','committed','aborted')),
|
|
109
|
+
begin_json TEXT NOT NULL, commit_json TEXT, receipt_json TEXT, created_at INTEGER NOT NULL,
|
|
110
|
+
PRIMARY KEY(team_id,id), UNIQUE(team_id,source_id,harness,native_id,generation,sequence),
|
|
111
|
+
FOREIGN KEY(team_id,source_id) REFERENCES sources(team_id,id),
|
|
112
|
+
CHECK(json_valid(begin_json)), CHECK(commit_json IS NULL OR json_valid(commit_json)),
|
|
113
|
+
CHECK(receipt_json IS NULL OR json_valid(receipt_json))
|
|
114
|
+
) STRICT;
|
|
115
|
+
CREATE TABLE capture_parts (
|
|
116
|
+
team_id TEXT NOT NULL, capture_id TEXT NOT NULL, part_index INTEGER NOT NULL CHECK(part_index >= 0),
|
|
117
|
+
digest TEXT NOT NULL, payload_json TEXT NOT NULL CHECK(json_valid(payload_json)),
|
|
118
|
+
PRIMARY KEY(team_id,capture_id,part_index),
|
|
119
|
+
FOREIGN KEY(team_id,capture_id) REFERENCES captures(team_id,id) ON DELETE CASCADE
|
|
120
|
+
) STRICT;
|
|
121
|
+
CREATE TABLE capture_expiries (
|
|
122
|
+
team_id TEXT NOT NULL, source_id TEXT NOT NULL, id TEXT NOT NULL, expired_at INTEGER NOT NULL,
|
|
123
|
+
PRIMARY KEY(team_id,id), FOREIGN KEY(team_id,source_id) REFERENCES sources(team_id,id)
|
|
124
|
+
) STRICT;
|
|
125
|
+
CREATE TABLE uploads (
|
|
126
|
+
team_id TEXT NOT NULL, id TEXT NOT NULL, source_id TEXT NOT NULL,
|
|
127
|
+
manifest_json TEXT, created_at INTEGER NOT NULL, committed INTEGER NOT NULL DEFAULT 0 CHECK(committed IN (0,1)),
|
|
128
|
+
PRIMARY KEY(team_id,id), FOREIGN KEY(team_id,source_id) REFERENCES sources(team_id,id),
|
|
129
|
+
CHECK(manifest_json IS NULL OR json_valid(manifest_json))
|
|
130
|
+
) STRICT;
|
|
131
|
+
CREATE TABLE artifact_chunks (
|
|
132
|
+
team_id TEXT NOT NULL, upload_id TEXT NOT NULL, chunk_index INTEGER NOT NULL CHECK(chunk_index >= 0),
|
|
133
|
+
sha256 TEXT NOT NULL, bytes BLOB NOT NULL,
|
|
134
|
+
PRIMARY KEY(team_id,upload_id,chunk_index),
|
|
135
|
+
FOREIGN KEY(team_id,upload_id) REFERENCES uploads(team_id,id) ON DELETE CASCADE,
|
|
136
|
+
CHECK(length(bytes) <= 1048576)
|
|
137
|
+
) STRICT;
|
|
138
|
+
CREATE TABLE session_artifacts (
|
|
139
|
+
team_id TEXT NOT NULL, session_id TEXT NOT NULL, revision INTEGER NOT NULL,
|
|
140
|
+
bundle_id TEXT NOT NULL, upload_id TEXT NOT NULL, format TEXT NOT NULL,
|
|
141
|
+
provenance TEXT NOT NULL CHECK(provenance IN ('capture','export')),
|
|
142
|
+
file_role TEXT NOT NULL CHECK(file_role IN ('primary','subagent','bundle','source_recovery')),
|
|
143
|
+
fidelity TEXT NOT NULL CHECK(fidelity IN ('byte_lossless','value_lossless','semantic')),
|
|
144
|
+
residue_json TEXT NOT NULL CHECK(json_valid(residue_json)),
|
|
145
|
+
PRIMARY KEY(team_id,session_id,revision,bundle_id,upload_id),
|
|
146
|
+
FOREIGN KEY(team_id,session_id,revision) REFERENCES session_versions(team_id,session_id,revision) ON DELETE CASCADE,
|
|
147
|
+
FOREIGN KEY(team_id,upload_id) REFERENCES uploads(team_id,id)
|
|
148
|
+
) STRICT;
|
|
149
|
+
CREATE INDEX session_artifacts_bundle ON session_artifacts(team_id,session_id,revision,provenance,bundle_id);
|
|
150
|
+
CREATE TABLE session_versions (
|
|
151
|
+
team_id TEXT NOT NULL, session_id TEXT NOT NULL, revision INTEGER NOT NULL,
|
|
152
|
+
descriptor_json TEXT NOT NULL CHECK(json_valid(descriptor_json)),
|
|
153
|
+
source_projection_json TEXT NOT NULL CHECK(json_valid(source_projection_json)),
|
|
154
|
+
search_title TEXT NOT NULL, sort_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
|
|
155
|
+
PRIMARY KEY(team_id,session_id,revision),
|
|
156
|
+
FOREIGN KEY(team_id,session_id) REFERENCES sessions(team_id,id)
|
|
157
|
+
) STRICT;
|
|
158
|
+
CREATE TABLE messages (
|
|
159
|
+
team_id TEXT NOT NULL, session_id TEXT NOT NULL, revision INTEGER NOT NULL,
|
|
160
|
+
ordinal INTEGER NOT NULL CHECK(ordinal >= 0), id TEXT NOT NULL, source_id TEXT NOT NULL,
|
|
161
|
+
native_key TEXT NOT NULL, recorded_at INTEGER, value_json TEXT NOT NULL CHECK(json_valid(value_json)),
|
|
162
|
+
search_text TEXT NOT NULL,
|
|
163
|
+
PRIMARY KEY(team_id,session_id,revision,ordinal), UNIQUE(team_id,session_id,revision,id),
|
|
164
|
+
FOREIGN KEY(team_id,session_id,revision) REFERENCES session_versions(team_id,session_id,revision) ON DELETE CASCADE,
|
|
165
|
+
FOREIGN KEY(team_id,source_id) REFERENCES sources(team_id,id)
|
|
166
|
+
) STRICT;
|
|
167
|
+
CREATE TABLE message_ids (
|
|
168
|
+
team_id TEXT NOT NULL, source_id TEXT NOT NULL, harness TEXT NOT NULL, native_id TEXT NOT NULL,
|
|
169
|
+
native_key TEXT NOT NULL, id TEXT NOT NULL,
|
|
170
|
+
PRIMARY KEY(team_id,source_id,harness,native_id,native_key), UNIQUE(team_id,id),
|
|
171
|
+
FOREIGN KEY(team_id,source_id) REFERENCES sources(team_id,id)
|
|
172
|
+
) STRICT;
|
|
173
|
+
CREATE TABLE journal (
|
|
174
|
+
team_id TEXT NOT NULL REFERENCES teams(id), revision INTEGER NOT NULL,
|
|
175
|
+
session_id TEXT, kind TEXT NOT NULL, created_at INTEGER NOT NULL,
|
|
176
|
+
PRIMARY KEY(team_id,revision), FOREIGN KEY(team_id,session_id) REFERENCES sessions(team_id,id)
|
|
177
|
+
) STRICT;
|
|
178
|
+
CREATE TABLE cursors (
|
|
179
|
+
id TEXT PRIMARY KEY, team_id TEXT NOT NULL REFERENCES teams(id), principal_id TEXT NOT NULL,
|
|
180
|
+
kind TEXT NOT NULL CHECK(kind IN ('list','search','history','session_events','catalog_events')),
|
|
181
|
+
query_json TEXT NOT NULL CHECK(json_valid(query_json)), auth_epoch INTEGER NOT NULL,
|
|
182
|
+
revision INTEGER NOT NULL, position INTEGER NOT NULL DEFAULT 0,
|
|
183
|
+
snapshot_json TEXT CHECK(snapshot_json IS NULL OR json_valid(snapshot_json)),
|
|
184
|
+
expires_at INTEGER NOT NULL, FOREIGN KEY(team_id,principal_id) REFERENCES memberships(team_id,principal_id)
|
|
185
|
+
) STRICT;
|
|
186
|
+
CREATE TABLE correlations (
|
|
187
|
+
team_id TEXT NOT NULL, id TEXT NOT NULL, left_session_id TEXT NOT NULL, right_session_id TEXT NOT NULL,
|
|
188
|
+
canonical_id TEXT NOT NULL, policy_json TEXT NOT NULL CHECK(json_valid(policy_json)),
|
|
189
|
+
evidence_json TEXT NOT NULL CHECK(json_valid(evidence_json)), revision INTEGER NOT NULL,
|
|
190
|
+
PRIMARY KEY(team_id,id), FOREIGN KEY(team_id,left_session_id) REFERENCES sessions(team_id,id),
|
|
191
|
+
FOREIGN KEY(team_id,right_session_id) REFERENCES sessions(team_id,id),
|
|
192
|
+
FOREIGN KEY(team_id,canonical_id) REFERENCES sessions(team_id,id)
|
|
193
|
+
) STRICT;
|
|
194
|
+
CREATE TABLE bindings (
|
|
195
|
+
team_id TEXT NOT NULL, id TEXT NOT NULL, machine_id TEXT NOT NULL, session_id TEXT NOT NULL,
|
|
196
|
+
incarnation_id TEXT NOT NULL, descriptor_json TEXT NOT NULL CHECK(json_valid(descriptor_json)),
|
|
197
|
+
observed_at INTEGER NOT NULL, PRIMARY KEY(team_id,id),
|
|
198
|
+
FOREIGN KEY(team_id,machine_id) REFERENCES machines(team_id,id),
|
|
199
|
+
FOREIGN KEY(team_id,session_id) REFERENCES sessions(team_id,id)
|
|
200
|
+
) STRICT;
|
|
201
|
+
CREATE TABLE live_grants (
|
|
202
|
+
team_id TEXT NOT NULL, id TEXT NOT NULL, machine_id TEXT NOT NULL, session_id TEXT NOT NULL,
|
|
203
|
+
binding_id TEXT NOT NULL, subject_id TEXT NOT NULL, actions_json TEXT NOT NULL CHECK(json_valid(actions_json)),
|
|
204
|
+
expires_at INTEGER NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
|
|
205
|
+
PRIMARY KEY(team_id,id), FOREIGN KEY(team_id,machine_id) REFERENCES machines(team_id,id),
|
|
206
|
+
FOREIGN KEY(team_id,session_id) REFERENCES sessions(team_id,id),
|
|
207
|
+
FOREIGN KEY(team_id,binding_id) REFERENCES bindings(team_id,id),
|
|
208
|
+
FOREIGN KEY(team_id,subject_id) REFERENCES memberships(team_id,principal_id)
|
|
209
|
+
) STRICT;
|
|
210
|
+
CREATE TABLE attachments (
|
|
211
|
+
team_id TEXT NOT NULL, id TEXT NOT NULL, requester_id TEXT NOT NULL,
|
|
212
|
+
issuer_credential_id TEXT NOT NULL REFERENCES credentials(id),
|
|
213
|
+
machine_id TEXT NOT NULL, binding_id TEXT NOT NULL, session_id TEXT NOT NULL,
|
|
214
|
+
incarnation_id TEXT NOT NULL, mode TEXT NOT NULL CHECK(mode IN ('observe','control')),
|
|
215
|
+
access_revision INTEGER NOT NULL, grant_id TEXT NOT NULL,
|
|
216
|
+
effective_actions_json TEXT NOT NULL CHECK(json_valid(effective_actions_json)),
|
|
217
|
+
stream_token_hash TEXT NOT NULL UNIQUE, stream_token_expires_at INTEGER NOT NULL,
|
|
218
|
+
stream_token_consumed_at INTEGER, grant_expires_at INTEGER NOT NULL,
|
|
219
|
+
last_sequence INTEGER NOT NULL DEFAULT -1 CHECK(last_sequence >= -1),
|
|
220
|
+
state TEXT NOT NULL CHECK(state IN ('pending','active','detached','revoked','closed')),
|
|
221
|
+
created_at INTEGER NOT NULL, closed_at INTEGER,
|
|
222
|
+
PRIMARY KEY(team_id,id),
|
|
223
|
+
FOREIGN KEY(team_id,requester_id) REFERENCES memberships(team_id,principal_id),
|
|
224
|
+
FOREIGN KEY(team_id,machine_id) REFERENCES machines(team_id,id),
|
|
225
|
+
FOREIGN KEY(team_id,binding_id) REFERENCES bindings(team_id,id),
|
|
226
|
+
FOREIGN KEY(team_id,session_id) REFERENCES sessions(team_id,id),
|
|
227
|
+
FOREIGN KEY(team_id,grant_id) REFERENCES live_grants(team_id,id)
|
|
228
|
+
) STRICT;
|
|
229
|
+
CREATE INDEX attachments_binding_state ON attachments(team_id,binding_id,state);
|
|
230
|
+
CREATE TABLE continuations (
|
|
231
|
+
team_id TEXT NOT NULL, id TEXT NOT NULL, requester_id TEXT NOT NULL, source_session_id TEXT NOT NULL,
|
|
232
|
+
state TEXT NOT NULL, value_json TEXT NOT NULL CHECK(json_valid(value_json)), expires_at INTEGER NOT NULL,
|
|
233
|
+
PRIMARY KEY(team_id,id), FOREIGN KEY(team_id,requester_id) REFERENCES memberships(team_id,principal_id),
|
|
234
|
+
FOREIGN KEY(team_id,source_session_id) REFERENCES sessions(team_id,id)
|
|
235
|
+
) STRICT;
|
|
236
|
+
CREATE TABLE operations (
|
|
237
|
+
team_id TEXT NOT NULL, id TEXT NOT NULL, requester_id TEXT NOT NULL, session_id TEXT NOT NULL,
|
|
238
|
+
state TEXT NOT NULL CHECK(state IN ('pending','running','succeeded','failed','unknown')),
|
|
239
|
+
value_json TEXT NOT NULL CHECK(json_valid(value_json)), created_at INTEGER NOT NULL,
|
|
240
|
+
PRIMARY KEY(team_id,id), FOREIGN KEY(team_id,requester_id) REFERENCES memberships(team_id,principal_id),
|
|
241
|
+
FOREIGN KEY(team_id,session_id) REFERENCES sessions(team_id,id)
|
|
242
|
+
) STRICT;
|
|
243
|
+
CREATE TABLE native_effects (
|
|
244
|
+
team_id TEXT NOT NULL, id TEXT NOT NULL, operation_id TEXT NOT NULL, principal_id TEXT NOT NULL,
|
|
245
|
+
issuer_credential_id TEXT NOT NULL REFERENCES credentials(id),
|
|
246
|
+
machine_id TEXT NOT NULL, binding_id TEXT, incarnation_id TEXT NOT NULL, session_id TEXT NOT NULL,
|
|
247
|
+
access_revision INTEGER NOT NULL, grant_id TEXT, expires_at INTEGER NOT NULL,
|
|
248
|
+
kind TEXT NOT NULL CHECK(kind IN ('attach','input','approve','interrupt','terminate','continue')),
|
|
249
|
+
payload_json TEXT NOT NULL CHECK(json_valid(payload_json)),
|
|
250
|
+
state TEXT NOT NULL CHECK(state IN ('pending','running','succeeded','failed','unknown')),
|
|
251
|
+
result_json TEXT CHECK(result_json IS NULL OR json_valid(result_json)), claimed_at INTEGER,
|
|
252
|
+
PRIMARY KEY(team_id,id), UNIQUE(team_id,operation_id),
|
|
253
|
+
CHECK(kind='continue' OR (grant_id IS NOT NULL AND binding_id IS NOT NULL)),
|
|
254
|
+
FOREIGN KEY(team_id,principal_id) REFERENCES memberships(team_id,principal_id),
|
|
255
|
+
FOREIGN KEY(team_id,operation_id) REFERENCES operations(team_id,id),
|
|
256
|
+
FOREIGN KEY(team_id,machine_id) REFERENCES machines(team_id,id),
|
|
257
|
+
FOREIGN KEY(team_id,binding_id) REFERENCES bindings(team_id,id),
|
|
258
|
+
FOREIGN KEY(team_id,grant_id) REFERENCES live_grants(team_id,id),
|
|
259
|
+
FOREIGN KEY(team_id,session_id) REFERENCES sessions(team_id,id)
|
|
260
|
+
) STRICT;
|
|
261
|
+
CREATE INDEX native_effects_machine_state ON native_effects(team_id,machine_id,state,id);
|
|
262
|
+
CREATE TABLE idempotency (
|
|
263
|
+
team_id TEXT NOT NULL, principal_id TEXT NOT NULL, route TEXT NOT NULL, key TEXT NOT NULL,
|
|
264
|
+
request_digest TEXT NOT NULL, response_status INTEGER NOT NULL, response_json TEXT NOT NULL,
|
|
265
|
+
expires_at INTEGER NOT NULL, PRIMARY KEY(team_id,principal_id,route,key),
|
|
266
|
+
FOREIGN KEY(team_id,principal_id) REFERENCES memberships(team_id,principal_id)
|
|
267
|
+
) STRICT;
|
|
268
|
+
CREATE TABLE global_idempotency (
|
|
269
|
+
principal_id TEXT NOT NULL REFERENCES principals(id), route TEXT NOT NULL, key TEXT NOT NULL,
|
|
270
|
+
request_digest TEXT NOT NULL, response_status INTEGER NOT NULL, response_json TEXT NOT NULL,
|
|
271
|
+
expires_at INTEGER NOT NULL, PRIMARY KEY(principal_id,route,key)
|
|
272
|
+
) STRICT;
|
|
273
|
+
CREATE TABLE global_cursors (
|
|
274
|
+
id TEXT PRIMARY KEY, principal_id TEXT NOT NULL REFERENCES principals(id),
|
|
275
|
+
credential_team_id TEXT REFERENCES teams(id), kind TEXT NOT NULL,
|
|
276
|
+
query_json TEXT NOT NULL CHECK(json_valid(query_json)),
|
|
277
|
+
snapshot_json TEXT NOT NULL CHECK(json_valid(snapshot_json)), expires_at INTEGER NOT NULL
|
|
278
|
+
) STRICT;
|
|
279
|
+
CREATE INDEX global_cursors_expiry ON global_cursors(expires_at);
|
|
280
|
+
CREATE TABLE evidence (
|
|
281
|
+
team_id TEXT NOT NULL, source_id TEXT NOT NULL, id TEXT NOT NULL, native_event_key TEXT,
|
|
282
|
+
signal TEXT NOT NULL CHECK(signal IN ('logs','traces','metrics')), raw_json TEXT NOT NULL CHECK(json_valid(raw_json)),
|
|
283
|
+
received_at INTEGER NOT NULL, decode_state TEXT NOT NULL,
|
|
284
|
+
PRIMARY KEY(team_id,id), UNIQUE(team_id,source_id,native_event_key),
|
|
285
|
+
FOREIGN KEY(team_id,source_id) REFERENCES sources(team_id,id)
|
|
286
|
+
) STRICT;
|
|
287
|
+
CREATE TABLE audit (
|
|
288
|
+
team_id TEXT NOT NULL REFERENCES teams(id), id TEXT NOT NULL, actor_id TEXT NOT NULL,
|
|
289
|
+
operation TEXT NOT NULL, target_id TEXT, outcome TEXT NOT NULL, revision INTEGER, created_at INTEGER NOT NULL,
|
|
290
|
+
PRIMARY KEY(team_id,id)
|
|
291
|
+
) STRICT;
|
|
292
|
+
CREATE INDEX messages_search_scope ON messages(team_id,session_id,revision);
|
|
293
|
+
CREATE INDEX versions_sort ON session_versions(team_id,sort_at DESC,session_id);
|
|
294
|
+
CREATE INDEX journal_replay ON journal(team_id,session_id,revision);
|
|
295
|
+
CREATE INDEX cursors_expiry ON cursors(expires_at);
|
|
296
|
+
CREATE INDEX credentials_scope ON credentials(team_id,source_id,machine_id);
|
|
297
|
+
PRAGMA user_version = 1;
|
|
298
|
+
`;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Schema migration 002 (SQLite DDL), applied in order by store-core migrate().
|
|
2
|
+
export default `-- Schema 2: machine grants (design §11). A subject (member, service account or
|
|
3
|
+
-- group) holds capabilities on one machine's doors until an expiry. The
|
|
4
|
+
-- machine re-derives admission from its own synced copy of these rows.
|
|
5
|
+
CREATE TABLE machine_grants (
|
|
6
|
+
team_id TEXT NOT NULL, id TEXT NOT NULL, machine_id TEXT NOT NULL, subject_id TEXT NOT NULL,
|
|
7
|
+
caps_json TEXT NOT NULL CHECK(json_valid(caps_json)), targets_json TEXT NOT NULL CHECK(json_valid(targets_json)),
|
|
8
|
+
issuer_id TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
|
|
9
|
+
PRIMARY KEY(team_id,id), FOREIGN KEY(team_id,machine_id) REFERENCES machines(team_id,id)
|
|
10
|
+
) STRICT;
|
|
11
|
+
CREATE INDEX machine_grants_subject ON machine_grants(team_id,machine_id,subject_id,expires_at);
|
|
12
|
+
ALTER TABLE machines ADD COLUMN host_key_id TEXT;
|
|
13
|
+
`;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Schema migration 003 (SQLite DDL), applied in order by store-core migrate().
|
|
2
|
+
export default `-- Schema 3: a principal's linked identities (protocol P02, federation). A person
|
|
3
|
+
-- signed in with an identity service (Volter, id.volter.ai) is the principal
|
|
4
|
+
-- whose row names that issuer and subject; the principal id never changes, so
|
|
5
|
+
-- federation keeps every id minted before it.
|
|
6
|
+
CREATE TABLE principal_identities (
|
|
7
|
+
issuer TEXT NOT NULL, subject TEXT NOT NULL,
|
|
8
|
+
principal_id TEXT NOT NULL REFERENCES principals(id), linked_at INTEGER NOT NULL,
|
|
9
|
+
PRIMARY KEY(issuer,subject), UNIQUE(principal_id,issuer)
|
|
10
|
+
) STRICT;
|
|
11
|
+
`;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Schema migration 004 (SQLite DDL), applied in order by store-core migrate().
|
|
2
|
+
export default `-- Schema 4: grants name the kind they reach (RFC 0008). \`*\` keeps only operate and
|
|
3
|
+
-- view. A grant holding other capabilities on \`*\` is split: view (if it had it) stays on
|
|
4
|
+
-- \`*\` in the original row, and the other capabilities move to a new row naming the
|
|
5
|
+
-- harness kinds they covered (pane:*, runtime:*, root:/, port:*), so they never reach a
|
|
6
|
+
-- kind added later. A \`browser:<pane>\` target only ever carried ports: it becomes
|
|
7
|
+
-- \`pane:<pane>\` when ports is its only capability and is dropped otherwise. Rows that
|
|
8
|
+
-- change get a new revision; the daemon resyncs when a claim's revision differs.
|
|
9
|
+
INSERT INTO machine_grants(team_id,id,machine_id,subject_id,caps_json,targets_json,issuer_id,created_at,expires_at)
|
|
10
|
+
SELECT team_id, id || '-kinds', machine_id, subject_id,
|
|
11
|
+
(SELECT json_group_array(value) FROM json_each(g.caps_json) WHERE value NOT IN ('operate','view')),
|
|
12
|
+
'["pane:*","runtime:*","root:/","port:*"]', issuer_id, created_at, expires_at
|
|
13
|
+
FROM machine_grants g
|
|
14
|
+
WHERE EXISTS (SELECT 1 FROM json_each(g.targets_json) WHERE value = '*')
|
|
15
|
+
AND NOT EXISTS (SELECT 1 FROM json_each(g.caps_json) WHERE value = 'operate')
|
|
16
|
+
AND EXISTS (SELECT 1 FROM json_each(g.caps_json) WHERE value NOT IN ('operate','view'));
|
|
17
|
+
UPDATE machine_grants SET
|
|
18
|
+
caps_json = CASE WHEN EXISTS (SELECT 1 FROM json_each(machine_grants.caps_json) WHERE value = 'view') THEN '["view"]' ELSE caps_json END,
|
|
19
|
+
targets_json = CASE WHEN EXISTS (SELECT 1 FROM json_each(machine_grants.caps_json) WHERE value = 'view')
|
|
20
|
+
THEN (SELECT json_group_array(value) FROM json_each(machine_grants.targets_json))
|
|
21
|
+
ELSE (SELECT json_group_array(value) FROM json_each(machine_grants.targets_json) WHERE value <> '*') END,
|
|
22
|
+
revision = revision + 1
|
|
23
|
+
WHERE id NOT LIKE '%-kinds'
|
|
24
|
+
AND EXISTS (SELECT 1 FROM json_each(machine_grants.targets_json) WHERE value = '*')
|
|
25
|
+
AND NOT EXISTS (SELECT 1 FROM json_each(machine_grants.caps_json) WHERE value = 'operate')
|
|
26
|
+
AND EXISTS (SELECT 1 FROM json_each(machine_grants.caps_json) WHERE value NOT IN ('operate','view'));
|
|
27
|
+
DELETE FROM machine_grants WHERE targets_json = '[]';
|
|
28
|
+
UPDATE machine_grants SET
|
|
29
|
+
targets_json = (SELECT json_group_array(CASE WHEN value LIKE 'browser:%' THEN 'pane:' || substr(value, 9) ELSE value END)
|
|
30
|
+
FROM json_each(machine_grants.targets_json)
|
|
31
|
+
WHERE value NOT LIKE 'browser:%' OR machine_grants.caps_json = '["ports"]'),
|
|
32
|
+
revision = revision + 1
|
|
33
|
+
WHERE EXISTS (SELECT 1 FROM json_each(machine_grants.targets_json) WHERE value LIKE 'browser:%');
|
|
34
|
+
DELETE FROM machine_grants WHERE targets_json = '[]';
|
|
35
|
+
`;
|