@dsh-enhanced/assistant-actions 0.1.30
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/LICENSE +21 -0
- package/README.md +247 -0
- package/cordis.patch.yml +3 -0
- package/lib/broker-cli.d.ts +70 -0
- package/lib/broker-cli.d.ts.map +1 -0
- package/lib/broker-cli.js +255 -0
- package/lib/broker-cli.js.map +1 -0
- package/lib/broker-client.d.ts +54 -0
- package/lib/broker-client.d.ts.map +1 -0
- package/lib/broker-client.js +266 -0
- package/lib/broker-client.js.map +1 -0
- package/lib/broker-core.d.ts +51 -0
- package/lib/broker-core.d.ts.map +1 -0
- package/lib/broker-core.js +441 -0
- package/lib/broker-core.js.map +1 -0
- package/lib/broker-ledger.d.ts +131 -0
- package/lib/broker-ledger.d.ts.map +1 -0
- package/lib/broker-ledger.js +925 -0
- package/lib/broker-ledger.js.map +1 -0
- package/lib/broker-peer-linux.d.ts +23 -0
- package/lib/broker-peer-linux.d.ts.map +1 -0
- package/lib/broker-peer-linux.js +68 -0
- package/lib/broker-peer-linux.js.map +1 -0
- package/lib/broker-protocol.d.ts +419 -0
- package/lib/broker-protocol.d.ts.map +1 -0
- package/lib/broker-protocol.js +786 -0
- package/lib/broker-protocol.js.map +1 -0
- package/lib/broker-server.d.ts +77 -0
- package/lib/broker-server.d.ts.map +1 -0
- package/lib/broker-server.js +467 -0
- package/lib/broker-server.js.map +1 -0
- package/lib/cli.d.ts +3 -0
- package/lib/cli.d.ts.map +1 -0
- package/lib/cli.js +36 -0
- package/lib/cli.js.map +1 -0
- package/lib/config.d.ts +54 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +165 -0
- package/lib/config.js.map +1 -0
- package/lib/external-delivery-receipt.d.ts +11 -0
- package/lib/external-delivery-receipt.d.ts.map +1 -0
- package/lib/external-delivery-receipt.js +25 -0
- package/lib/external-delivery-receipt.js.map +1 -0
- package/lib/github.d.ts +78 -0
- package/lib/github.d.ts.map +1 -0
- package/lib/github.js +611 -0
- package/lib/github.js.map +1 -0
- package/lib/index.d.ts +19 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +23 -0
- package/lib/index.js.map +1 -0
- package/lib/ledger.d.ts +78 -0
- package/lib/ledger.d.ts.map +1 -0
- package/lib/ledger.js +1064 -0
- package/lib/ledger.js.map +1 -0
- package/lib/repository-readback.d.ts +34 -0
- package/lib/repository-readback.d.ts.map +1 -0
- package/lib/repository-readback.js +193 -0
- package/lib/repository-readback.js.map +1 -0
- package/lib/service.d.ts +86 -0
- package/lib/service.d.ts.map +1 -0
- package/lib/service.js +1259 -0
- package/lib/service.js.map +1 -0
- package/lib/types.d.ts +174 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/verified-delivery.d.ts +104 -0
- package/lib/verified-delivery.d.ts.map +1 -0
- package/lib/verified-delivery.js +262 -0
- package/lib/verified-delivery.js.map +1 -0
- package/lib/version.d.ts +2 -0
- package/lib/version.d.ts.map +1 -0
- package/lib/version.js +2 -0
- package/lib/version.js.map +1 -0
- package/package.json +123 -0
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmodSync, closeSync, constants, fstatSync, lstatSync, openSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, isAbsolute, normalize } from 'node:path';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import { brokerGrantAuthorityDigest, brokerRequestDigest, canonicalBrokerJson, createBrokerGrantProjection, normalizeBrokerGrantAuthority, } from './broker-protocol.js';
|
|
6
|
+
const SCHEMA_VERSION = 2;
|
|
7
|
+
const MAX_RECORDS = 10_000;
|
|
8
|
+
const MAX_GRANTS = 1_000;
|
|
9
|
+
const DIGEST = /^[0-9a-f]{64}$/u;
|
|
10
|
+
export class BrokerLedgerError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code) {
|
|
13
|
+
super('assistant-actions external broker ledger rejected: ' + code);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.name = 'BrokerLedgerError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function fail(code) { throw new BrokerLedgerError(code); }
|
|
19
|
+
function plain(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype && Object.getOwnPropertySymbols(value).length === 0; }
|
|
20
|
+
function exact(value, keys) {
|
|
21
|
+
if (!plain(value) || Object.keys(value).length !== keys.length || Object.keys(value).some(key => !keys.includes(key)) || keys.some(key => !Object.hasOwn(value, key)))
|
|
22
|
+
fail('invalid-input');
|
|
23
|
+
for (const descriptor of Object.values(Object.getOwnPropertyDescriptors(value)))
|
|
24
|
+
if (!('value' in descriptor) || !descriptor.enumerable)
|
|
25
|
+
fail('invalid-input');
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
function text(value, maximum = 4_096) { if (typeof value !== 'string' || value.length < 1 || value.length > maximum || /[\p{Cc}]/u.test(value))
|
|
29
|
+
fail('invalid-input'); return value; }
|
|
30
|
+
function integer(value, minimum = 0, maximum = Number.MAX_SAFE_INTEGER) { if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum || value > maximum)
|
|
31
|
+
fail('invalid-input'); return value; }
|
|
32
|
+
function digest(value) { const output = text(value, 64); if (!DIGEST.test(output))
|
|
33
|
+
fail('invalid-input'); return output; }
|
|
34
|
+
function json(value) { try {
|
|
35
|
+
return JSON.parse(value);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return fail('schema');
|
|
39
|
+
} }
|
|
40
|
+
function equal(left, right) { return canonicalBrokerJson(left) === canonicalBrokerJson(right); }
|
|
41
|
+
function owner(value) {
|
|
42
|
+
const input = exact(value, ['principalDigest', 'principalRecordId', 'principalVersion', 'workspace', 'preset', 'bindingId', 'bindingVersion', 'bindingGeneration']);
|
|
43
|
+
const workspace = text(input.workspace);
|
|
44
|
+
if (!isAbsolute(workspace) || normalize(workspace) !== workspace)
|
|
45
|
+
fail('invalid-input');
|
|
46
|
+
return Object.freeze({ principalDigest: digest(input.principalDigest), principalRecordId: text(input.principalRecordId), principalVersion: integer(input.principalVersion, 1), workspace, preset: text(input.preset), bindingId: text(input.bindingId), bindingVersion: integer(input.bindingVersion, 1), bindingGeneration: integer(input.bindingGeneration, 1) });
|
|
47
|
+
}
|
|
48
|
+
function normalizeGrantUnsigned(value) {
|
|
49
|
+
try {
|
|
50
|
+
const grant = normalizeBrokerGrantAuthority(value);
|
|
51
|
+
return grant;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return fail('invalid-input');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function externalGrantMirror(grant) {
|
|
58
|
+
return createBrokerGrantProjection(normalizeGrantUnsigned(grant));
|
|
59
|
+
}
|
|
60
|
+
export function brokerGrantDigest(grant) { return brokerGrantAuthorityDigest(normalizeGrantUnsigned(grant)); }
|
|
61
|
+
export function withBrokerGrantDigest(grant) { const normalized = normalizeGrantUnsigned(grant); return Object.freeze({ ...normalized, digest: brokerGrantDigest(normalized) }); }
|
|
62
|
+
export function normalizeBrokerGrant(value) {
|
|
63
|
+
const input = exact(value, ['protocol', 'id', 'revision', 'digest', 'clientKeyId', 'owner', 'sessionId', 'destination', 'credentialId', 'expiresAt', 'maxActions', 'maxTotalBytes', 'maxCostUnits', 'allowedOperations', 'allowedInspectKinds', 'client', 'source', 'policyEpoch', 'emergencyEpoch', ...(plain(value) && Object.hasOwn(value, 'verifiedDelivery') ? ['verifiedDelivery'] : [])]);
|
|
64
|
+
const { digest: claimed, ...unsigned } = input;
|
|
65
|
+
const normalized = normalizeGrantUnsigned(unsigned);
|
|
66
|
+
const expected = brokerGrantDigest(normalized);
|
|
67
|
+
if (digest(claimed) !== expected)
|
|
68
|
+
fail('invalid-input');
|
|
69
|
+
return Object.freeze({ ...normalized, digest: expected });
|
|
70
|
+
}
|
|
71
|
+
function sameNode(left, right) { return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.gid === right.gid && left.mode === right.mode && left.nlink === right.nlink; }
|
|
72
|
+
function safeAncestorChain(value, uid, gid) {
|
|
73
|
+
let cursor = value, leaf = true;
|
|
74
|
+
for (;;) {
|
|
75
|
+
const stat = lstatSync(cursor, { bigint: true }), mode = Number(stat.mode & 4095n), owner = Number(stat.uid), group = Number(stat.gid);
|
|
76
|
+
const stickyRoot = owner === 0 && (mode & 0o1000) !== 0 && (mode & 0o022) !== 0;
|
|
77
|
+
if (realpathSync(cursor) !== cursor || stat.isSymbolicLink() || !stat.isDirectory() || !stickyRoot && (mode & 0o022) !== 0
|
|
78
|
+
|| leaf && (owner !== uid || group !== gid || mode !== 0o700))
|
|
79
|
+
fail('unsafe-file');
|
|
80
|
+
const parent = dirname(cursor);
|
|
81
|
+
if (parent === cursor)
|
|
82
|
+
return;
|
|
83
|
+
cursor = parent;
|
|
84
|
+
leaf = false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function privateFile(value) { const stat = lstatSync(value, { bigint: true }); if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1n || Number(stat.mode & 4095n) !== 0o600 || (process.geteuid?.() !== undefined && stat.uid !== BigInt(process.geteuid())))
|
|
88
|
+
fail('unsafe-file'); }
|
|
89
|
+
function exists(value) { try {
|
|
90
|
+
lstatSync(value);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (error.code === 'ENOENT')
|
|
95
|
+
return false;
|
|
96
|
+
throw error;
|
|
97
|
+
} }
|
|
98
|
+
function prepareDatabasePath(value) {
|
|
99
|
+
if (!isAbsolute(value) || normalize(value) !== value)
|
|
100
|
+
fail('unsafe-file');
|
|
101
|
+
const uid = process.geteuid?.(), gid = process.getegid?.();
|
|
102
|
+
if (process.platform !== 'linux' || uid === undefined || gid === undefined)
|
|
103
|
+
fail('unsafe-file');
|
|
104
|
+
const parentPath = dirname(value);
|
|
105
|
+
safeAncestorChain(parentPath, uid, gid);
|
|
106
|
+
let parentDescriptor, fileDescriptor;
|
|
107
|
+
try {
|
|
108
|
+
parentDescriptor = openSync(parentPath, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
109
|
+
const parent = fstatSync(parentDescriptor, { bigint: true }), visibleParent = lstatSync(parentPath, { bigint: true });
|
|
110
|
+
if (!parent.isDirectory() || !sameNode(parent, visibleParent))
|
|
111
|
+
fail('unsafe-file');
|
|
112
|
+
const anchored = "/proc/self/fd/" + parentDescriptor + "/" + basename(value);
|
|
113
|
+
if (!exists(value)) {
|
|
114
|
+
const created = openSync(anchored, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
|
|
115
|
+
closeSync(created);
|
|
116
|
+
}
|
|
117
|
+
fileDescriptor = openSync(anchored, constants.O_RDWR | constants.O_NOFOLLOW);
|
|
118
|
+
const file = fstatSync(fileDescriptor, { bigint: true }), visibleFile = lstatSync(value, { bigint: true });
|
|
119
|
+
if (!file.isFile() || file.nlink !== 1n || Number(file.mode & 4095n) !== 0o600 || file.uid !== BigInt(uid) || !sameNode(file, visibleFile))
|
|
120
|
+
fail('unsafe-file');
|
|
121
|
+
return { sqlitePath: anchored, parentDescriptor, fileDescriptor, parent, file };
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (fileDescriptor !== undefined)
|
|
125
|
+
closeSync(fileDescriptor);
|
|
126
|
+
if (parentDescriptor !== undefined)
|
|
127
|
+
closeSync(parentDescriptor);
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function pinnedPathMatches(databasePath, pinned) {
|
|
132
|
+
try {
|
|
133
|
+
const parent = fstatSync(pinned.parentDescriptor, { bigint: true }), visibleParent = lstatSync(dirname(databasePath), { bigint: true });
|
|
134
|
+
const file = fstatSync(pinned.fileDescriptor, { bigint: true }), visibleFile = lstatSync(databasePath, { bigint: true });
|
|
135
|
+
return sameNode(parent, pinned.parent) && sameNode(parent, visibleParent) && sameNode(file, pinned.file) && sameNode(file, visibleFile);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function openDatabase(databasePath, instanceId, beforeOpen) {
|
|
142
|
+
const pinned = databasePath === ':memory:' ? undefined : prepareDatabasePath(databasePath);
|
|
143
|
+
let database;
|
|
144
|
+
try {
|
|
145
|
+
beforeOpen?.();
|
|
146
|
+
database = new DatabaseSync(pinned?.sqlitePath ?? databasePath, { enableForeignKeyConstraints: true });
|
|
147
|
+
if (pinned && !pinnedPathMatches(databasePath, pinned))
|
|
148
|
+
fail('unsafe-file');
|
|
149
|
+
database.exec('PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;');
|
|
150
|
+
const version = database.prepare('PRAGMA user_version').get().user_version;
|
|
151
|
+
if (version === 0) {
|
|
152
|
+
database.exec([
|
|
153
|
+
'BEGIN IMMEDIATE',
|
|
154
|
+
'CREATE TABLE meta (singleton INTEGER PRIMARY KEY CHECK(singleton=1), instance_id TEXT NOT NULL, generation INTEGER NOT NULL CHECK(generation>=0), controller_fence INTEGER NOT NULL CHECK(controller_fence>=0), control_version INTEGER NOT NULL CHECK(control_version>=0), policy_epoch INTEGER NOT NULL CHECK(policy_epoch>=0), emergency_epoch INTEGER NOT NULL CHECK(emergency_epoch>=0), stopped INTEGER NOT NULL CHECK(stopped IN (0,1)), draining INTEGER NOT NULL CHECK(draining IN (0,1))) STRICT',
|
|
155
|
+
'CREATE TABLE controller (singleton INTEGER PRIMARY KEY CHECK(singleton=1), owner_id TEXT NOT NULL, fence INTEGER NOT NULL CHECK(fence>=1), generation INTEGER NOT NULL CHECK(generation>=1), expires_at INTEGER NOT NULL CHECK(expires_at>=0)) STRICT',
|
|
156
|
+
'CREATE TABLE grants (id TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>=1), digest TEXT NOT NULL, grant_json TEXT NOT NULL, revoked INTEGER NOT NULL CHECK(revoked IN (0,1)), created_at INTEGER NOT NULL CHECK(created_at>=0), PRIMARY KEY(id,revision), UNIQUE(digest)) STRICT, WITHOUT ROWID',
|
|
157
|
+
'CREATE TABLE grant_heads (id TEXT PRIMARY KEY, revision INTEGER NOT NULL, revoked INTEGER NOT NULL CHECK(revoked IN (0,1)), FOREIGN KEY(id,revision) REFERENCES grants(id,revision)) STRICT',
|
|
158
|
+
"CREATE TABLE requests (action_id TEXT PRIMARY KEY, request_id TEXT NOT NULL, client_key_id TEXT NOT NULL, owner_json TEXT NOT NULL, session_id TEXT NOT NULL, agent_id TEXT NOT NULL, root_call_id TEXT NOT NULL, call_id TEXT NOT NULL, grant_id TEXT NOT NULL, grant_revision INTEGER NOT NULL, grant_digest TEXT NOT NULL, request_digest TEXT NOT NULL, operation TEXT NOT NULL CHECK(operation IN ('commit','pull-request','inspect')), repository TEXT NOT NULL, branch TEXT NOT NULL, expected_head_oid TEXT, payload_digest TEXT NOT NULL, budget_reservation_id TEXT NOT NULL, bytes INTEGER NOT NULL CHECK(bytes>=0), cost_units INTEGER NOT NULL CHECK(cost_units>=0), deadline INTEGER NOT NULL CHECK(deadline>=1), policy_epoch INTEGER NOT NULL CHECK(policy_epoch>=0), emergency_epoch INTEGER NOT NULL CHECK(emergency_epoch>=0), generation INTEGER NOT NULL CHECK(generation>=1), status TEXT NOT NULL CHECK(status IN ('prepared','dispatched','succeeded','failed','unknown')), version INTEGER NOT NULL CHECK(version>=1), dispatched_at INTEGER, result_json TEXT, UNIQUE(client_key_id,request_id), UNIQUE(client_key_id,action_id), UNIQUE(client_key_id,budget_reservation_id), FOREIGN KEY(grant_id,grant_revision) REFERENCES grants(id,revision), CHECK((status IN ('prepared','dispatched') AND result_json IS NULL) OR (status IN ('succeeded','failed','unknown') AND result_json IS NOT NULL)), CHECK((status='prepared' AND dispatched_at IS NULL) OR status!='prepared'), CHECK((operation IN ('commit','pull-request'))=(expected_head_oid IS NOT NULL))) STRICT",
|
|
159
|
+
"CREATE TABLE credential_leases (action_id TEXT PRIMARY KEY, credential_id TEXT NOT NULL, purpose TEXT NOT NULL CHECK(purpose IN ('github.commit','github.pull-request','github.inspect')), status TEXT NOT NULL CHECK(status IN ('active','completed','failed','revoked')), issued_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, settled_at INTEGER, version INTEGER NOT NULL, FOREIGN KEY(action_id) REFERENCES requests(action_id), CHECK((status='active' AND settled_at IS NULL) OR (status!='active' AND settled_at IS NOT NULL))) STRICT",
|
|
160
|
+
'CREATE TABLE admin_nonces (admin_key_id TEXT NOT NULL, nonce_digest TEXT NOT NULL, request_digest TEXT NOT NULL, expires_at INTEGER NOT NULL CHECK(expires_at>=1), PRIMARY KEY(admin_key_id,nonce_digest)) STRICT, WITHOUT ROWID',
|
|
161
|
+
'CREATE TABLE audit (sequence INTEGER PRIMARY KEY, kind TEXT NOT NULL, subject_id TEXT NOT NULL, revision INTEGER, recorded_at INTEGER NOT NULL, previous_digest TEXT NOT NULL, digest TEXT NOT NULL UNIQUE) STRICT',
|
|
162
|
+
'CREATE INDEX requests_grant_budget ON requests(grant_id,dispatched_at,status)',
|
|
163
|
+
'CREATE INDEX requests_status ON requests(status)',
|
|
164
|
+
"CREATE INDEX requests_pr_occupancy ON requests(repository,branch,expected_head_oid,operation,status)",
|
|
165
|
+
].join(';'));
|
|
166
|
+
database.prepare('INSERT INTO meta VALUES(1,?,0,0,0,0,0,0,0)').run(instanceId);
|
|
167
|
+
database.exec('PRAGMA user_version=2; COMMIT');
|
|
168
|
+
}
|
|
169
|
+
if (version === 1) {
|
|
170
|
+
try {
|
|
171
|
+
database.exec("BEGIN IMMEDIATE; ALTER TABLE credential_leases RENAME TO credential_leases_v1; ALTER TABLE requests RENAME TO requests_v1; CREATE TABLE requests (action_id TEXT PRIMARY KEY, request_id TEXT NOT NULL, client_key_id TEXT NOT NULL, owner_json TEXT NOT NULL, session_id TEXT NOT NULL, agent_id TEXT NOT NULL, root_call_id TEXT NOT NULL, call_id TEXT NOT NULL, grant_id TEXT NOT NULL, grant_revision INTEGER NOT NULL, grant_digest TEXT NOT NULL, request_digest TEXT NOT NULL, operation TEXT NOT NULL CHECK(operation IN ('commit','pull-request','inspect')), repository TEXT NOT NULL, branch TEXT NOT NULL, expected_head_oid TEXT, payload_digest TEXT NOT NULL, budget_reservation_id TEXT NOT NULL, bytes INTEGER NOT NULL CHECK(bytes>=0), cost_units INTEGER NOT NULL CHECK(cost_units>=0), deadline INTEGER NOT NULL CHECK(deadline>=1), policy_epoch INTEGER NOT NULL CHECK(policy_epoch>=0), emergency_epoch INTEGER NOT NULL CHECK(emergency_epoch>=0), generation INTEGER NOT NULL CHECK(generation>=1), status TEXT NOT NULL CHECK(status IN ('prepared','dispatched','succeeded','failed','unknown')), version INTEGER NOT NULL CHECK(version>=1), dispatched_at INTEGER, result_json TEXT, UNIQUE(client_key_id,request_id), UNIQUE(client_key_id,action_id), UNIQUE(client_key_id,budget_reservation_id), FOREIGN KEY(grant_id,grant_revision) REFERENCES grants(id,revision), CHECK((status IN ('prepared','dispatched') AND result_json IS NULL) OR (status IN ('succeeded','failed','unknown') AND result_json IS NOT NULL)), CHECK((status='prepared' AND dispatched_at IS NULL) OR status!='prepared'), CHECK((operation IN ('commit','pull-request'))=(expected_head_oid IS NOT NULL))) STRICT; INSERT INTO requests SELECT * FROM requests_v1; CREATE TABLE credential_leases (action_id TEXT PRIMARY KEY, credential_id TEXT NOT NULL, purpose TEXT NOT NULL CHECK(purpose IN ('github.commit','github.pull-request','github.inspect')), status TEXT NOT NULL CHECK(status IN ('active','completed','failed','revoked')), issued_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, settled_at INTEGER, version INTEGER NOT NULL, FOREIGN KEY(action_id) REFERENCES requests(action_id), CHECK((status='active' AND settled_at IS NULL) OR (status!='active' AND settled_at IS NOT NULL))) STRICT; INSERT INTO credential_leases SELECT * FROM credential_leases_v1; DROP TABLE credential_leases_v1; DROP TABLE requests_v1; CREATE INDEX requests_grant_budget ON requests(grant_id,dispatched_at,status); CREATE INDEX requests_status ON requests(status); CREATE INDEX requests_pr_occupancy ON requests(repository,branch,expected_head_oid,operation,status); PRAGMA user_version=2; COMMIT");
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
try {
|
|
175
|
+
database.exec('ROLLBACK');
|
|
176
|
+
}
|
|
177
|
+
catch { }
|
|
178
|
+
fail('schema');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (version !== 0 && version !== 1 && version !== SCHEMA_VERSION)
|
|
182
|
+
fail('schema');
|
|
183
|
+
const tables = database.prepare("SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all().map(row => row.name);
|
|
184
|
+
if (!equal(tables, ['admin_nonces', 'audit', 'controller', 'credential_leases', 'grant_heads', 'grants', 'meta', 'requests']))
|
|
185
|
+
fail('schema');
|
|
186
|
+
const schemas = database.prepare("SELECT sql FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%'").all();
|
|
187
|
+
if (!schemas.every(row => /\bSTRICT\b/u.test(row.sql)))
|
|
188
|
+
fail('schema');
|
|
189
|
+
const expectedColumns = {
|
|
190
|
+
admin_nonces: ['admin_key_id', 'nonce_digest', 'request_digest', 'expires_at'],
|
|
191
|
+
audit: ['sequence', 'kind', 'subject_id', 'revision', 'recorded_at', 'previous_digest', 'digest'],
|
|
192
|
+
controller: ['singleton', 'owner_id', 'fence', 'generation', 'expires_at'],
|
|
193
|
+
credential_leases: ['action_id', 'credential_id', 'purpose', 'status', 'issued_at', 'expires_at', 'settled_at', 'version'],
|
|
194
|
+
grant_heads: ['id', 'revision', 'revoked'], grants: ['id', 'revision', 'digest', 'grant_json', 'revoked', 'created_at'],
|
|
195
|
+
meta: ['singleton', 'instance_id', 'generation', 'controller_fence', 'control_version', 'policy_epoch', 'emergency_epoch', 'stopped', 'draining'],
|
|
196
|
+
requests: ['action_id', 'request_id', 'client_key_id', 'owner_json', 'session_id', 'agent_id', 'root_call_id', 'call_id', 'grant_id', 'grant_revision', 'grant_digest', 'request_digest', 'operation', 'repository', 'branch', 'expected_head_oid', 'payload_digest', 'budget_reservation_id', 'bytes', 'cost_units', 'deadline', 'policy_epoch', 'emergency_epoch', 'generation', 'status', 'version', 'dispatched_at', 'result_json'],
|
|
197
|
+
};
|
|
198
|
+
for (const [table, expected] of Object.entries(expectedColumns)) {
|
|
199
|
+
const columns = database.prepare("SELECT name FROM pragma_table_info(?) ORDER BY cid").all(table).map(row => row.name);
|
|
200
|
+
if (!equal(columns, expected))
|
|
201
|
+
fail('schema');
|
|
202
|
+
}
|
|
203
|
+
const indexes = database.prepare("SELECT name FROM sqlite_schema WHERE type='index' AND name NOT LIKE 'sqlite_%' ORDER BY name").all().map(row => row.name);
|
|
204
|
+
if (!indexes.includes('requests_grant_budget') || !indexes.includes('requests_status') || !indexes.includes('requests_pr_occupancy'))
|
|
205
|
+
fail('schema');
|
|
206
|
+
if (database.prepare('PRAGMA quick_check').get().quick_check !== 'ok')
|
|
207
|
+
fail('schema');
|
|
208
|
+
if (databasePath !== ':memory:') {
|
|
209
|
+
if (!pinned || !pinnedPathMatches(databasePath, pinned))
|
|
210
|
+
fail('unsafe-file');
|
|
211
|
+
privateFile(databasePath);
|
|
212
|
+
for (const suffix of ['-wal', '-shm'])
|
|
213
|
+
if (exists(databasePath + suffix)) {
|
|
214
|
+
const anchored = pinned.sqlitePath + suffix;
|
|
215
|
+
chmodSync(anchored, 0o600);
|
|
216
|
+
privateFile(databasePath + suffix);
|
|
217
|
+
const anchoredStat = lstatSync(anchored, { bigint: true }), visibleStat = lstatSync(databasePath + suffix, { bigint: true });
|
|
218
|
+
if (!sameNode(anchoredStat, visibleStat))
|
|
219
|
+
fail('unsafe-file');
|
|
220
|
+
}
|
|
221
|
+
safeAncestorChain(dirname(databasePath), Number(pinned.parent.uid), Number(pinned.parent.gid));
|
|
222
|
+
if (!pinnedPathMatches(databasePath, pinned))
|
|
223
|
+
fail('unsafe-file');
|
|
224
|
+
closeSync(pinned.fileDescriptor);
|
|
225
|
+
}
|
|
226
|
+
return { database, ...(pinned ? { parentDescriptor: pinned.parentDescriptor } : {}) };
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
database?.close();
|
|
230
|
+
if (pinned) {
|
|
231
|
+
try {
|
|
232
|
+
closeSync(pinned.fileDescriptor);
|
|
233
|
+
}
|
|
234
|
+
catch { }
|
|
235
|
+
;
|
|
236
|
+
try {
|
|
237
|
+
closeSync(pinned.parentDescriptor);
|
|
238
|
+
}
|
|
239
|
+
catch { }
|
|
240
|
+
}
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
export function brokerPayloadBytes(request) { return Buffer.byteLength(canonicalBrokerJson(request.payload), 'utf8'); }
|
|
245
|
+
export const brokerLedgerRequestDigest = brokerRequestDigest;
|
|
246
|
+
function outcome(value, record) {
|
|
247
|
+
const input = exact(value, ['status', 'dispatched', 'result', 'error', 'completedAt']);
|
|
248
|
+
if (!['succeeded', 'failed', 'unknown'].includes(String(input.status)) || typeof input.dispatched !== 'boolean')
|
|
249
|
+
fail('invalid-input');
|
|
250
|
+
const status = input.status;
|
|
251
|
+
if (status === 'succeeded') {
|
|
252
|
+
if (!input.dispatched || input.error !== null || !plain(input.result))
|
|
253
|
+
fail('invalid-input');
|
|
254
|
+
if (record && (input.result.operation !== record.operation || input.result.repository !== record.repository || input.result.branch !== record.branch || record.operation === 'commit' && input.result.parentOid !== record.expectedHeadOid || record.operation === 'pull-request' && input.result.expectedHeadOid !== record.expectedHeadOid))
|
|
255
|
+
fail('state');
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
if (input.result !== null || !plain(input.error) || Object.keys(input.error).length !== 1 || typeof input.error.code !== 'string' || !/^[a-z0-9][a-z0-9-]{0,127}$/u.test(input.error.code))
|
|
259
|
+
fail('invalid-input');
|
|
260
|
+
if (status === 'unknown' && !input.dispatched)
|
|
261
|
+
fail('invalid-input');
|
|
262
|
+
}
|
|
263
|
+
return Object.freeze({ status, dispatched: input.dispatched, result: input.result, error: input.error, completedAt: integer(input.completedAt, 1) });
|
|
264
|
+
}
|
|
265
|
+
export class ExternalBrokerLedger {
|
|
266
|
+
#database;
|
|
267
|
+
#parentDescriptor;
|
|
268
|
+
#now;
|
|
269
|
+
#closed = false;
|
|
270
|
+
constructor(databasePath, instanceId, options = {}) {
|
|
271
|
+
text(instanceId, 128);
|
|
272
|
+
if (!plain(options) || Object.keys(options).some(key => !['now', 'beforeDatabaseOpen'].includes(key)) || options.now !== undefined && typeof options.now !== 'function' || options.beforeDatabaseOpen !== undefined && typeof options.beforeDatabaseOpen !== 'function')
|
|
273
|
+
fail('invalid-input');
|
|
274
|
+
this.#now = options.now ?? Date.now;
|
|
275
|
+
const opened = openDatabase(databasePath, instanceId, options.beforeDatabaseOpen);
|
|
276
|
+
this.#database = opened.database;
|
|
277
|
+
this.#parentDescriptor = opened.parentDescriptor;
|
|
278
|
+
try {
|
|
279
|
+
this.#validateStored(instanceId);
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
this.#database.close();
|
|
283
|
+
if (this.#parentDescriptor !== undefined)
|
|
284
|
+
closeSync(this.#parentDescriptor);
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
#time() { return integer(this.#now()); }
|
|
289
|
+
#meta() { const row = this.#database.prepare('SELECT * FROM meta WHERE singleton=1').get(); if (!row)
|
|
290
|
+
fail('schema'); this.#validateMeta(row); return row; }
|
|
291
|
+
#validateMeta(row) { text(row.instance_id, 128); integer(row.generation); integer(row.controller_fence); integer(row.control_version); integer(row.policy_epoch); integer(row.emergency_epoch); if (![0, 1].includes(row.stopped) || ![0, 1].includes(row.draining))
|
|
292
|
+
fail('schema'); }
|
|
293
|
+
#authority(value, now = this.#time()) {
|
|
294
|
+
if (!plain(value) || Object.keys(value).length !== 3)
|
|
295
|
+
fail('invalid-input');
|
|
296
|
+
const ownerId = text(value.ownerId);
|
|
297
|
+
const fence = integer(value.fence, 1);
|
|
298
|
+
const generation = integer(value.generation, 1);
|
|
299
|
+
const row = this.#database.prepare('SELECT owner_id,fence,generation,expires_at FROM controller WHERE singleton=1').get();
|
|
300
|
+
if (!row || row.owner_id !== ownerId || row.fence !== fence || row.generation !== generation || row.expires_at <= now || this.#meta().generation !== generation)
|
|
301
|
+
fail('controller');
|
|
302
|
+
return Object.freeze({ ownerId, fence, generation });
|
|
303
|
+
}
|
|
304
|
+
#grant(row) { try {
|
|
305
|
+
const grant = normalizeBrokerGrant(json(row.grant_json));
|
|
306
|
+
if (grant.id !== row.id || grant.revision !== row.revision || grant.digest !== row.digest || ![0, 1].includes(row.revoked) || !Number.isSafeInteger(row.created_at))
|
|
307
|
+
fail('schema');
|
|
308
|
+
return grant;
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return fail('schema');
|
|
312
|
+
} }
|
|
313
|
+
#head(id) { const row = this.#database.prepare('SELECT g.*,h.revoked AS head_revoked FROM grant_heads h JOIN grants g ON g.id=h.id AND g.revision=h.revision WHERE h.id=?').get(id); if (row && row.revoked !== row.head_revoked)
|
|
314
|
+
fail('schema'); return row; }
|
|
315
|
+
#row(row) {
|
|
316
|
+
try {
|
|
317
|
+
const status = row.status;
|
|
318
|
+
if (!['prepared', 'dispatched', 'succeeded', 'failed', 'unknown'].includes(status))
|
|
319
|
+
fail('schema');
|
|
320
|
+
const terminal = ['succeeded', 'failed', 'unknown'].includes(status);
|
|
321
|
+
if (terminal !== (row.result_json !== null) || status === 'prepared' && row.dispatched_at !== null || status === 'dispatched' && row.dispatched_at === null)
|
|
322
|
+
fail('schema');
|
|
323
|
+
const record = { actionId: text(row.action_id), requestId: text(row.request_id), clientKeyId: text(row.client_key_id), owner: owner(json(row.owner_json)), sessionId: text(row.session_id), agentId: text(row.agent_id), rootCallId: text(row.root_call_id), callId: text(row.call_id), grantId: text(row.grant_id), grantRevision: integer(row.grant_revision, 1), grantDigest: digest(row.grant_digest), requestDigest: digest(row.request_digest), operation: row.operation, repository: text(row.repository), branch: text(row.branch), expectedHeadOid: row.expected_head_oid, payloadDigest: digest(row.payload_digest), budgetReservationId: text(row.budget_reservation_id), bytes: integer(row.bytes), costUnits: integer(row.cost_units), deadline: integer(row.deadline, 1), policyEpoch: integer(row.policy_epoch), emergencyEpoch: integer(row.emergency_epoch), generation: integer(row.generation, 1), status, version: integer(row.version, 1) };
|
|
324
|
+
if (!['commit', 'pull-request', 'inspect'].includes(record.operation) || (['commit', 'pull-request'].includes(record.operation)) !== (record.expectedHeadOid !== null) || record.expectedHeadOid !== null && !/^[0-9a-f]{40,128}$/u.test(record.expectedHeadOid))
|
|
325
|
+
fail('schema');
|
|
326
|
+
const grantRow = this.#database.prepare('SELECT * FROM grants WHERE id=? AND revision=?').get(record.grantId, record.grantRevision);
|
|
327
|
+
if (!grantRow)
|
|
328
|
+
fail('schema');
|
|
329
|
+
const grant = this.#grant(grantRow);
|
|
330
|
+
if (grant.digest !== record.grantDigest || grant.clientKeyId !== record.clientKeyId || !equal(grant.owner, record.owner) || grant.sessionId !== record.sessionId || grant.destination.repository !== record.repository || grant.destination.branch !== record.branch || grant.policyEpoch !== record.policyEpoch || grant.emergencyEpoch !== record.emergencyEpoch)
|
|
331
|
+
fail('schema');
|
|
332
|
+
if (terminal) {
|
|
333
|
+
record.outcome = outcome(json(row.result_json), record);
|
|
334
|
+
if (record.outcome.dispatched !== (row.dispatched_at !== null))
|
|
335
|
+
fail('schema');
|
|
336
|
+
}
|
|
337
|
+
return Object.freeze(record);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return fail('schema');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
#audit(kind, subjectId, revision, now) {
|
|
344
|
+
const previous = this.#database.prepare('SELECT digest FROM audit ORDER BY sequence DESC LIMIT 1').get()?.digest ?? '0'.repeat(64);
|
|
345
|
+
const sequence = this.#database.prepare('SELECT COALESCE(MAX(sequence),0)+1 AS value FROM audit').get().value;
|
|
346
|
+
const next = createHash('sha256').update(canonicalBrokerJson({ sequence, kind, subjectId, revision, recordedAt: now, previous })).digest('hex');
|
|
347
|
+
this.#database.prepare('INSERT INTO audit(sequence,kind,subject_id,revision,recorded_at,previous_digest,digest) VALUES(?,?,?,?,?,?,?)').run(sequence, kind, subjectId, revision, now, previous, next);
|
|
348
|
+
}
|
|
349
|
+
#validateStored(instanceId) {
|
|
350
|
+
try {
|
|
351
|
+
const meta = this.#meta();
|
|
352
|
+
if (meta.instance_id !== instanceId)
|
|
353
|
+
fail('schema');
|
|
354
|
+
const grants = this.#database.prepare('SELECT * FROM grants').all();
|
|
355
|
+
if (grants.length > MAX_GRANTS)
|
|
356
|
+
fail('schema');
|
|
357
|
+
for (const grant of grants)
|
|
358
|
+
this.#grant(grant);
|
|
359
|
+
const heads = this.#database.prepare('SELECT COUNT(*) AS count FROM grant_heads').get().count;
|
|
360
|
+
const joined = this.#database.prepare('SELECT g.*,h.revoked AS head_revoked FROM grant_heads h JOIN grants g ON g.id=h.id AND g.revision=h.revision').all();
|
|
361
|
+
if (heads !== joined.length || joined.some(row => row.revoked !== row.head_revoked))
|
|
362
|
+
fail('schema');
|
|
363
|
+
const rows = this.#database.prepare('SELECT * FROM requests LIMIT 10001').all();
|
|
364
|
+
if (rows.length > MAX_RECORDS)
|
|
365
|
+
fail('schema');
|
|
366
|
+
for (const row of rows)
|
|
367
|
+
this.#row(row);
|
|
368
|
+
const controllers = this.#database.prepare('SELECT owner_id,fence,generation,expires_at FROM controller').all();
|
|
369
|
+
if (controllers.length > 1)
|
|
370
|
+
fail('schema');
|
|
371
|
+
for (const controller of controllers) {
|
|
372
|
+
text(controller.owner_id);
|
|
373
|
+
integer(controller.fence, 1);
|
|
374
|
+
integer(controller.generation, 1);
|
|
375
|
+
integer(controller.expires_at);
|
|
376
|
+
}
|
|
377
|
+
const leases = this.#database.prepare('SELECT * FROM credential_leases').all();
|
|
378
|
+
for (const lease of leases) {
|
|
379
|
+
const action = rows.find(row => row.action_id === text(lease.action_id));
|
|
380
|
+
if (!action || text(lease.credential_id, 128) !== this.#grant(this.#database.prepare('SELECT * FROM grants WHERE id=? AND revision=?').get(action.grant_id, action.grant_revision)).credentialId)
|
|
381
|
+
fail('schema');
|
|
382
|
+
if (!['github.commit', 'github.pull-request', 'github.inspect'].includes(lease.purpose) || lease.purpose !== (action.operation === 'commit' ? 'github.commit' : action.operation === 'pull-request' ? 'github.pull-request' : 'github.inspect') || !['active', 'completed', 'failed', 'revoked'].includes(lease.status))
|
|
383
|
+
fail('schema');
|
|
384
|
+
const issuedAt = integer(lease.issued_at);
|
|
385
|
+
const expiresAt = integer(lease.expires_at, 1);
|
|
386
|
+
integer(lease.version, 1);
|
|
387
|
+
if (expiresAt <= issuedAt || (lease.status === 'active') !== (lease.settled_at === null) || lease.status === 'active' && action.status !== 'dispatched' || lease.status === 'completed' && action.status !== 'succeeded')
|
|
388
|
+
fail('schema');
|
|
389
|
+
if (lease.settled_at !== null)
|
|
390
|
+
integer(lease.settled_at);
|
|
391
|
+
}
|
|
392
|
+
const nonces = this.#database.prepare('SELECT * FROM admin_nonces').all();
|
|
393
|
+
for (const nonce of nonces) {
|
|
394
|
+
text(nonce.admin_key_id, 128);
|
|
395
|
+
digest(nonce.nonce_digest);
|
|
396
|
+
digest(nonce.request_digest);
|
|
397
|
+
integer(nonce.expires_at, 1);
|
|
398
|
+
}
|
|
399
|
+
const audits = this.#database.prepare('SELECT * FROM audit ORDER BY sequence').all();
|
|
400
|
+
let previous = '0'.repeat(64);
|
|
401
|
+
for (let index = 0; index < audits.length; index++) {
|
|
402
|
+
const item = audits[index];
|
|
403
|
+
const expected = createHash('sha256').update(canonicalBrokerJson({ sequence: item.sequence, kind: item.kind, subjectId: item.subject_id, revision: item.revision, recordedAt: item.recorded_at, previous })).digest('hex');
|
|
404
|
+
if (item.sequence !== index + 1 || item.previous_digest !== previous || item.digest !== expected)
|
|
405
|
+
fail('schema');
|
|
406
|
+
previous = item.digest;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
fail('schema');
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
#snapshot(meta = this.#meta()) { return Object.freeze({ instanceId: meta.instance_id, generation: meta.generation, controlVersion: meta.control_version, policyEpoch: meta.policy_epoch, emergencyEpoch: meta.emergency_epoch, stopped: meta.stopped === 1, draining: meta.draining === 1 }); }
|
|
414
|
+
claimController(ownerId, ttlMs = 30_000) {
|
|
415
|
+
const ownerIdValue = text(ownerId);
|
|
416
|
+
const ttl = integer(ttlMs, 1, 300_000);
|
|
417
|
+
const now = this.#time();
|
|
418
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
419
|
+
try {
|
|
420
|
+
const current = this.#database.prepare('SELECT * FROM controller WHERE singleton=1').get();
|
|
421
|
+
if (current && current.expires_at > now)
|
|
422
|
+
fail('controller');
|
|
423
|
+
const meta = this.#meta();
|
|
424
|
+
const fence = integer(meta.controller_fence + 1, 1);
|
|
425
|
+
const generation = integer(meta.generation + 1, 1);
|
|
426
|
+
this.#database.prepare('UPDATE meta SET generation=?,controller_fence=?,draining=0 WHERE singleton=1').run(generation, fence);
|
|
427
|
+
this.#database.prepare('INSERT INTO controller VALUES(1,?,?,?,?) ON CONFLICT(singleton) DO UPDATE SET owner_id=excluded.owner_id,fence=excluded.fence,generation=excluded.generation,expires_at=excluded.expires_at').run(ownerIdValue, fence, generation, now + ttl);
|
|
428
|
+
this.#recoverRows(now);
|
|
429
|
+
this.#audit('controller-claimed', ownerIdValue, fence, now);
|
|
430
|
+
this.#database.exec('COMMIT');
|
|
431
|
+
return Object.freeze({ ownerId: ownerIdValue, fence, generation });
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
try {
|
|
435
|
+
this.#database.exec('ROLLBACK');
|
|
436
|
+
}
|
|
437
|
+
catch { }
|
|
438
|
+
throw error;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
renewController(authority, ttlMs = 30_000) { const now = this.#time(); const current = this.#authority(authority, now); const changed = this.#database.prepare('UPDATE controller SET expires_at=? WHERE singleton=1 AND owner_id=? AND fence=? AND generation=? AND expires_at>?').run(now + integer(ttlMs, 1, 300_000), current.ownerId, current.fence, current.generation, now); if (changed.changes !== 1)
|
|
442
|
+
fail('controller'); }
|
|
443
|
+
hasController(authority) { try {
|
|
444
|
+
this.#authority(authority);
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
return false;
|
|
449
|
+
} }
|
|
450
|
+
releaseController(authority) { const current = this.#authority(authority); this.#database.prepare('DELETE FROM controller WHERE singleton=1 AND owner_id=? AND fence=? AND generation=?').run(current.ownerId, current.fence, current.generation); }
|
|
451
|
+
#recoverRows(now) {
|
|
452
|
+
const rows = this.#database.prepare("SELECT * FROM requests WHERE status IN ('prepared','dispatched')").all();
|
|
453
|
+
for (const row of rows)
|
|
454
|
+
this.#terminalize(row, now, row.status === 'dispatched' ? 'restart-after-dispatch' : 'restart-before-dispatch');
|
|
455
|
+
this.#database.prepare("UPDATE credential_leases SET status='revoked',settled_at=?,version=version+1 WHERE status='active'").run(now);
|
|
456
|
+
}
|
|
457
|
+
syncGrants(values, policyEpoch, authority) {
|
|
458
|
+
if (!Array.isArray(values) || values.length > MAX_GRANTS)
|
|
459
|
+
fail('invalid-input');
|
|
460
|
+
const grants = values.map(normalizeBrokerGrant);
|
|
461
|
+
if (new Set(grants.map(grant => grant.id)).size !== grants.length)
|
|
462
|
+
fail('invalid-input');
|
|
463
|
+
const nextEpoch = integer(policyEpoch);
|
|
464
|
+
const now = this.#time();
|
|
465
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
466
|
+
try {
|
|
467
|
+
this.#authority(authority, now);
|
|
468
|
+
const meta = this.#meta();
|
|
469
|
+
if (nextEpoch < meta.policy_epoch || grants.some(grant => grant.policyEpoch !== nextEpoch || grant.emergencyEpoch !== meta.emergency_epoch))
|
|
470
|
+
fail('grant');
|
|
471
|
+
const supplied = new Set(grants.map(grant => grant.id));
|
|
472
|
+
let changed = nextEpoch !== meta.policy_epoch;
|
|
473
|
+
const abort = new Set();
|
|
474
|
+
for (const grant of grants) {
|
|
475
|
+
const head = this.#head(grant.id);
|
|
476
|
+
if (head && grant.revision < head.revision)
|
|
477
|
+
fail('conflict');
|
|
478
|
+
if (head && grant.revision === head.revision) {
|
|
479
|
+
if (head.digest !== grant.digest || head.revoked || head.head_revoked)
|
|
480
|
+
fail('conflict');
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
this.#database.prepare('INSERT INTO grants VALUES(?,?,?,?,0,?)').run(grant.id, grant.revision, grant.digest, canonicalBrokerJson(grant), now);
|
|
484
|
+
this.#database.prepare('INSERT INTO grant_heads VALUES(?,?,0) ON CONFLICT(id) DO UPDATE SET revision=excluded.revision,revoked=0').run(grant.id, grant.revision);
|
|
485
|
+
changed = true;
|
|
486
|
+
this.#audit('grant-synced', grant.id, grant.revision, now);
|
|
487
|
+
for (const row of this.#terminalizeGrant(grant.id, now, 'grant-replaced'))
|
|
488
|
+
if (row.status === 'unknown')
|
|
489
|
+
abort.add(row.actionId);
|
|
490
|
+
}
|
|
491
|
+
const heads = this.#database.prepare('SELECT id,revision,revoked FROM grant_heads').all();
|
|
492
|
+
for (const head of heads)
|
|
493
|
+
if (!supplied.has(head.id) && head.revoked === 0) {
|
|
494
|
+
this.#database.prepare('UPDATE grant_heads SET revoked=1 WHERE id=? AND revision=?').run(head.id, head.revision);
|
|
495
|
+
this.#database.prepare('UPDATE grants SET revoked=1 WHERE id=? AND revision=?').run(head.id, head.revision);
|
|
496
|
+
changed = true;
|
|
497
|
+
this.#audit('grant-removed', head.id, head.revision, now);
|
|
498
|
+
for (const row of this.#terminalizeGrant(head.id, now, 'grant-removed'))
|
|
499
|
+
if (row.status === 'unknown')
|
|
500
|
+
abort.add(row.actionId);
|
|
501
|
+
}
|
|
502
|
+
if (changed)
|
|
503
|
+
this.#database.prepare('UPDATE meta SET policy_epoch=?,control_version=control_version+1 WHERE singleton=1').run(nextEpoch);
|
|
504
|
+
const snapshot = this.#snapshot();
|
|
505
|
+
this.#database.exec('COMMIT');
|
|
506
|
+
return Object.freeze({ ...snapshot, abortActionIds: Object.freeze([...abort]) });
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
try {
|
|
510
|
+
this.#database.exec('ROLLBACK');
|
|
511
|
+
}
|
|
512
|
+
catch { }
|
|
513
|
+
throw error;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
grant(id) { const row = this.#head(text(id)); return row && !row.head_revoked ? this.#grant(row) : undefined; }
|
|
517
|
+
#checkRequest(request, grant, meta, now) {
|
|
518
|
+
if (meta.stopped || meta.draining)
|
|
519
|
+
fail('stopped');
|
|
520
|
+
if (request.clientKeyId !== grant.clientKeyId || request.grantRevision !== grant.revision || request.grantDigest !== grant.digest || !equal(request.owner, grant.owner) || request.sessionId !== grant.sessionId || !equal(request.source, grant.source)
|
|
521
|
+
|| !equal(request.client, grant.client)
|
|
522
|
+
|| request.broker.kind !== 'github-broker' || request.broker.instanceId !== meta.instance_id || request.broker.generation !== meta.generation
|
|
523
|
+
|| request.destination.classification !== 'github-repository' || request.destination.repository !== grant.destination.repository || request.destination.branch !== grant.destination.branch || request.destination.baseBranch !== grant.destination.baseBranch
|
|
524
|
+
|| request.policyEpoch !== grant.policyEpoch || request.policyEpoch !== meta.policy_epoch || request.emergencyEpoch !== grant.emergencyEpoch || request.emergencyEpoch !== meta.emergency_epoch
|
|
525
|
+
|| request.deadline <= now || request.deadline > grant.expiresAt || grant.expiresAt <= now || !grant.allowedOperations.includes(request.operation))
|
|
526
|
+
fail('grant');
|
|
527
|
+
if (request.operation === 'commit') {
|
|
528
|
+
if (!('files' in request.payload) || request.payload.files.some(file => !grant.destination.paths.includes(file.path)))
|
|
529
|
+
fail('grant');
|
|
530
|
+
}
|
|
531
|
+
else if (request.operation === 'pull-request') {
|
|
532
|
+
if (!grant.destination.baseBranch || grant.destination.baseBranch === grant.destination.branch || !('expectedHeadOid' in request.payload))
|
|
533
|
+
fail('grant');
|
|
534
|
+
}
|
|
535
|
+
else if (!('kind' in request.payload) || !grant.allowedInspectKinds.includes(request.payload.kind) || request.payload.kind === 'file' && (!request.payload.path || !grant.destination.paths.includes(request.payload.path)))
|
|
536
|
+
fail('grant');
|
|
537
|
+
const bytes = brokerPayloadBytes(request);
|
|
538
|
+
const costUnits = request.operation === 'inspect' && 'kind' in request.payload && ['checks', 'reviews'].includes(request.payload.kind) ? 2 : 1;
|
|
539
|
+
if (request.budget.actions !== 1 || request.budget.bytes !== bytes || request.budget.costMetric !== 'github-api-units' || request.budget.maxCostUnits !== costUnits || bytes > grant.maxTotalBytes || costUnits > grant.maxCostUnits)
|
|
540
|
+
fail('limit');
|
|
541
|
+
return bytes;
|
|
542
|
+
}
|
|
543
|
+
prepare(request, authority) {
|
|
544
|
+
const now = this.#time();
|
|
545
|
+
const requestDigest = brokerLedgerRequestDigest(request);
|
|
546
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
547
|
+
try {
|
|
548
|
+
const existing = this.#database.prepare('SELECT * FROM requests WHERE action_id=?').get(request.actionId);
|
|
549
|
+
if (existing) {
|
|
550
|
+
const record = this.#row(existing);
|
|
551
|
+
if (record.requestDigest !== requestDigest || record.clientKeyId !== request.clientKeyId)
|
|
552
|
+
fail('conflict');
|
|
553
|
+
// Exact terminal history is immutable readback and remains available
|
|
554
|
+
// after grant expiry/revocation or controller handover. A nonterminal
|
|
555
|
+
// row can still advance, so it must pass the current fence and grant.
|
|
556
|
+
if (!record.outcome) {
|
|
557
|
+
this.#authority(authority, now);
|
|
558
|
+
const current = this.#head(request.grantId);
|
|
559
|
+
if (!current || current.revoked || current.head_revoked)
|
|
560
|
+
fail('grant');
|
|
561
|
+
this.#checkRequest(request, this.#grant(current), this.#meta(), now);
|
|
562
|
+
}
|
|
563
|
+
this.#database.exec('COMMIT');
|
|
564
|
+
return { record, created: false };
|
|
565
|
+
}
|
|
566
|
+
this.#authority(authority, now);
|
|
567
|
+
const meta = this.#meta();
|
|
568
|
+
const head = this.#head(request.grantId);
|
|
569
|
+
if (!head || head.revoked || head.head_revoked)
|
|
570
|
+
fail('grant');
|
|
571
|
+
const grant = this.#grant(head);
|
|
572
|
+
const bytes = this.#checkRequest(request, grant, meta, now);
|
|
573
|
+
const collision = this.#database.prepare('SELECT request_digest FROM requests WHERE client_key_id=? AND (request_id=? OR budget_reservation_id=?)').get(request.clientKeyId, request.requestId, request.budget.reservationId);
|
|
574
|
+
if (collision)
|
|
575
|
+
fail('conflict');
|
|
576
|
+
const count = this.#database.prepare('SELECT COUNT(*) AS count FROM requests').get().count;
|
|
577
|
+
if (count >= MAX_RECORDS)
|
|
578
|
+
fail('limit');
|
|
579
|
+
if (request.operation === 'pull-request') {
|
|
580
|
+
const occupied = this.#database.prepare("SELECT 1 FROM requests r JOIN grants g ON g.id=r.grant_id AND g.revision=r.grant_revision WHERE r.repository=? AND r.branch=? AND r.operation='pull-request' AND json_extract(g.grant_json,'$.destination.baseBranch')=? AND (r.status IN ('prepared','dispatched','unknown') OR (r.status='succeeded' AND r.expected_head_oid=?)) LIMIT 1").get(grant.destination.repository, grant.destination.branch, grant.destination.baseBranch, 'expectedHeadOid' in request.payload ? request.payload.expectedHeadOid : '');
|
|
581
|
+
if (occupied)
|
|
582
|
+
fail('conflict');
|
|
583
|
+
}
|
|
584
|
+
if (request.operation === 'commit' && 'expectedHeadOid' in request.payload
|
|
585
|
+
&& this.#database.prepare("SELECT 1 FROM requests WHERE operation='commit' AND repository=? AND branch=? AND expected_head_oid=? AND status IN ('dispatched','unknown') LIMIT 1").get(grant.destination.repository, grant.destination.branch, request.payload.expectedHeadOid))
|
|
586
|
+
fail('conflict');
|
|
587
|
+
const used = this.#database.prepare("SELECT COUNT(*) AS actions,COALESCE(SUM(bytes),0) AS bytes,COALESCE(SUM(cost_units),0) AS cost FROM requests WHERE grant_id=? AND (status='prepared' OR dispatched_at IS NOT NULL)").get(grant.id);
|
|
588
|
+
const costUnits = request.budget.maxCostUnits;
|
|
589
|
+
if (used.actions + 1 > grant.maxActions || used.bytes + bytes > grant.maxTotalBytes || used.cost + costUnits > grant.maxCostUnits)
|
|
590
|
+
fail('limit');
|
|
591
|
+
this.#database.prepare('INSERT INTO requests(action_id,request_id,client_key_id,owner_json,session_id,agent_id,root_call_id,call_id,grant_id,grant_revision,grant_digest,request_digest,operation,repository,branch,expected_head_oid,payload_digest,budget_reservation_id,bytes,cost_units,deadline,policy_epoch,emergency_epoch,generation,status,version,dispatched_at,result_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,NULL,NULL)').run(request.actionId, request.requestId, request.clientKeyId, canonicalBrokerJson(request.owner), request.sessionId, request.agentId, request.rootCallId, request.callId, grant.id, grant.revision, grant.digest, requestDigest, request.operation, grant.destination.repository, grant.destination.branch, (request.operation === 'commit' || request.operation === 'pull-request') && 'expectedHeadOid' in request.payload ? request.payload.expectedHeadOid : null, request.payloadDigest, request.budget.reservationId, bytes, costUnits, request.deadline, request.policyEpoch, request.emergencyEpoch, meta.generation, 'prepared');
|
|
592
|
+
this.#audit('request-prepared', request.actionId, 1, now);
|
|
593
|
+
const row = this.#database.prepare('SELECT * FROM requests WHERE action_id=?').get(request.actionId);
|
|
594
|
+
this.#database.exec('COMMIT');
|
|
595
|
+
return { record: this.#row(row), created: true };
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
try {
|
|
599
|
+
this.#database.exec('ROLLBACK');
|
|
600
|
+
}
|
|
601
|
+
catch { }
|
|
602
|
+
throw error;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
dispatch(actionId, version, requestDigestValue, credentialId, leaseExpiresAt, authority) {
|
|
606
|
+
const id = text(actionId);
|
|
607
|
+
const expected = integer(version, 1);
|
|
608
|
+
const expectedDigest = digest(requestDigestValue);
|
|
609
|
+
const now = this.#time();
|
|
610
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
611
|
+
try {
|
|
612
|
+
this.#authority(authority, now);
|
|
613
|
+
const meta = this.#meta();
|
|
614
|
+
if (meta.stopped || meta.draining)
|
|
615
|
+
fail('stopped');
|
|
616
|
+
const row = this.#database.prepare('SELECT * FROM requests WHERE action_id=?').get(id);
|
|
617
|
+
if (!row)
|
|
618
|
+
fail('state');
|
|
619
|
+
const record = this.#row(row);
|
|
620
|
+
if (record.status !== 'prepared' || record.version !== expected || record.requestDigest !== expectedDigest || record.deadline <= now || record.generation !== meta.generation || record.policyEpoch !== meta.policy_epoch || record.emergencyEpoch !== meta.emergency_epoch)
|
|
621
|
+
fail('state');
|
|
622
|
+
const head = this.#head(record.grantId);
|
|
623
|
+
if (!head || head.revoked || head.head_revoked || head.revision !== record.grantRevision || head.digest !== record.grantDigest)
|
|
624
|
+
fail('grant');
|
|
625
|
+
const grant = this.#grant(head);
|
|
626
|
+
if (grant.expiresAt <= now || grant.credentialId !== credentialId || leaseExpiresAt <= now || leaseExpiresAt > record.deadline || leaseExpiresAt > grant.expiresAt)
|
|
627
|
+
fail('grant');
|
|
628
|
+
if (record.operation === 'commit' && this.#database.prepare("SELECT 1 FROM requests WHERE action_id!=? AND operation='commit' AND repository=? AND branch=? AND expected_head_oid=? AND status IN ('dispatched','unknown') LIMIT 1").get(id, record.repository, record.branch, record.expectedHeadOid))
|
|
629
|
+
fail('conflict');
|
|
630
|
+
const changed = this.#database.prepare("UPDATE requests SET status='dispatched',version=version+1,dispatched_at=? WHERE action_id=? AND status='prepared' AND version=? AND request_digest=?").run(now, id, expected, expectedDigest);
|
|
631
|
+
if (changed.changes !== 1)
|
|
632
|
+
fail('state');
|
|
633
|
+
this.#database.prepare("INSERT INTO credential_leases VALUES(?,?,?,'active',?,?,NULL,1)").run(id, credentialId, record.operation === 'commit' ? 'github.commit' : record.operation === 'pull-request' ? 'github.pull-request' : 'github.inspect', now, leaseExpiresAt);
|
|
634
|
+
this.#audit('request-dispatched', id, expected + 1, now);
|
|
635
|
+
const updated = this.#database.prepare('SELECT * FROM requests WHERE action_id=?').get(id);
|
|
636
|
+
this.#database.exec('COMMIT');
|
|
637
|
+
return this.#row(updated);
|
|
638
|
+
}
|
|
639
|
+
catch (error) {
|
|
640
|
+
try {
|
|
641
|
+
this.#database.exec('ROLLBACK');
|
|
642
|
+
}
|
|
643
|
+
catch { }
|
|
644
|
+
throw error;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
settle(actionId, version, requestDigestValue, value, authority) {
|
|
648
|
+
const id = text(actionId);
|
|
649
|
+
const expected = integer(version, 1);
|
|
650
|
+
const expectedDigest = digest(requestDigestValue);
|
|
651
|
+
const now = this.#time();
|
|
652
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
653
|
+
try {
|
|
654
|
+
this.#authority(authority, now);
|
|
655
|
+
const row = this.#database.prepare('SELECT * FROM requests WHERE action_id=?').get(id);
|
|
656
|
+
if (!row)
|
|
657
|
+
fail('state');
|
|
658
|
+
const record = this.#row(row);
|
|
659
|
+
if (record.requestDigest !== expectedDigest)
|
|
660
|
+
fail('conflict');
|
|
661
|
+
if (record.outcome) {
|
|
662
|
+
this.#database.exec('COMMIT');
|
|
663
|
+
return record;
|
|
664
|
+
}
|
|
665
|
+
if (record.version !== expected || !['prepared', 'dispatched'].includes(record.status))
|
|
666
|
+
fail('state');
|
|
667
|
+
const normalized = outcome(value, record);
|
|
668
|
+
if (record.status === 'prepared' && normalized.dispatched || record.status === 'dispatched' && !normalized.dispatched)
|
|
669
|
+
fail('state');
|
|
670
|
+
const changed = this.#database.prepare('UPDATE requests SET status=?,version=version+1,result_json=? WHERE action_id=? AND version=? AND status=?').run(normalized.status, canonicalBrokerJson(normalized), id, expected, record.status);
|
|
671
|
+
if (changed.changes !== 1)
|
|
672
|
+
fail('state');
|
|
673
|
+
if (record.status === 'dispatched')
|
|
674
|
+
this.#database.prepare("UPDATE credential_leases SET status=?,settled_at=?,version=version+1 WHERE action_id=? AND status='active'").run(normalized.status === 'succeeded' ? 'completed' : 'failed', now, id);
|
|
675
|
+
this.#audit('request-settled', id, expected + 1, now);
|
|
676
|
+
const updated = this.#database.prepare('SELECT * FROM requests WHERE action_id=?').get(id);
|
|
677
|
+
this.#database.exec('COMMIT');
|
|
678
|
+
return this.#row(updated);
|
|
679
|
+
}
|
|
680
|
+
catch (error) {
|
|
681
|
+
try {
|
|
682
|
+
this.#database.exec('ROLLBACK');
|
|
683
|
+
}
|
|
684
|
+
catch { }
|
|
685
|
+
throw error;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
failPrepared(actionId, version, requestDigestValue, code, authority) { return this.settle(actionId, version, requestDigestValue, { status: 'failed', dispatched: false, result: null, error: { code }, completedAt: Math.max(1, this.#time()) }, authority); }
|
|
689
|
+
status(clientKeyId, actionId, requestDigestValue) { const row = this.#database.prepare('SELECT * FROM requests WHERE client_key_id=? AND action_id=?').get(text(clientKeyId), text(actionId)); if (!row)
|
|
690
|
+
return undefined; const record = this.#row(row); if (record.requestDigest !== digest(requestDigestValue))
|
|
691
|
+
fail('conflict'); return record; }
|
|
692
|
+
terminalizeActive(reason, authority) {
|
|
693
|
+
const code = text(reason, 128);
|
|
694
|
+
const now = this.#time();
|
|
695
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
696
|
+
try {
|
|
697
|
+
this.#authority(authority, now);
|
|
698
|
+
const rows = this.#database.prepare("SELECT * FROM requests WHERE status IN ('prepared','dispatched')").all();
|
|
699
|
+
const abort = [];
|
|
700
|
+
for (const row of rows) {
|
|
701
|
+
const result = this.#terminalize(row, now, code);
|
|
702
|
+
if (result.status === 'unknown')
|
|
703
|
+
abort.push(result.actionId);
|
|
704
|
+
}
|
|
705
|
+
const snapshot = this.#snapshot();
|
|
706
|
+
this.#database.exec('COMMIT');
|
|
707
|
+
return Object.freeze({ ...snapshot, abortActionIds: Object.freeze(abort) });
|
|
708
|
+
}
|
|
709
|
+
catch (error) {
|
|
710
|
+
try {
|
|
711
|
+
this.#database.exec('ROLLBACK');
|
|
712
|
+
}
|
|
713
|
+
catch { }
|
|
714
|
+
throw error;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
consumeAdminNonce(adminKeyId, nonce, requestDigestValue, expiresAt, authority) {
|
|
718
|
+
const key = text(adminKeyId, 128);
|
|
719
|
+
const nonceDigest = createHash('sha256').update(text(nonce, 128)).digest('hex');
|
|
720
|
+
const requestDigest = digest(requestDigestValue);
|
|
721
|
+
const expiry = integer(expiresAt, 1);
|
|
722
|
+
const now = this.#time();
|
|
723
|
+
if (expiry <= now)
|
|
724
|
+
fail('invalid-input');
|
|
725
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
726
|
+
try {
|
|
727
|
+
this.#authority(authority, now);
|
|
728
|
+
this.#database.prepare('DELETE FROM admin_nonces WHERE expires_at<=?').run(now);
|
|
729
|
+
const existing = this.#database.prepare('SELECT request_digest FROM admin_nonces WHERE admin_key_id=? AND nonce_digest=?').get(key, nonceDigest);
|
|
730
|
+
if (existing)
|
|
731
|
+
fail('conflict');
|
|
732
|
+
this.#database.prepare('INSERT INTO admin_nonces VALUES(?,?,?,?)').run(key, nonceDigest, requestDigest, expiry);
|
|
733
|
+
this.#audit('admin-nonce-consumed', key, null, now);
|
|
734
|
+
this.#database.exec('COMMIT');
|
|
735
|
+
}
|
|
736
|
+
catch (error) {
|
|
737
|
+
try {
|
|
738
|
+
this.#database.exec('ROLLBACK');
|
|
739
|
+
}
|
|
740
|
+
catch { }
|
|
741
|
+
throw error;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
applyAdminMutation(input) {
|
|
745
|
+
if (!plain(input) || Object.keys(input).length !== 6 || !plain(input.mutation))
|
|
746
|
+
fail('invalid-input');
|
|
747
|
+
const key = text(input.adminKeyId, 128);
|
|
748
|
+
const nonceDigest = createHash('sha256').update(text(input.nonce, 128)).digest('hex');
|
|
749
|
+
const requestDigest = digest(input.requestDigest);
|
|
750
|
+
const expiresAt = integer(input.expiresAt, 1);
|
|
751
|
+
const now = this.#time();
|
|
752
|
+
if (expiresAt <= now)
|
|
753
|
+
fail('invalid-input');
|
|
754
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
755
|
+
try {
|
|
756
|
+
this.#authority(input.authority, now);
|
|
757
|
+
this.#database.prepare('DELETE FROM admin_nonces WHERE expires_at<=?').run(now);
|
|
758
|
+
if (this.#database.prepare('SELECT 1 FROM admin_nonces WHERE admin_key_id=? AND nonce_digest=?').get(key, nonceDigest))
|
|
759
|
+
fail('conflict');
|
|
760
|
+
const meta = this.#meta();
|
|
761
|
+
const expected = integer(input.mutation.expectedControlVersion);
|
|
762
|
+
if (meta.control_version !== expected)
|
|
763
|
+
fail('conflict');
|
|
764
|
+
const abort = [];
|
|
765
|
+
if (input.mutation.operation === 'stop' || input.mutation.operation === 'resume') {
|
|
766
|
+
if (input.mutation.operation === 'resume' && integer(input.mutation.expectedGeneration, 1) !== meta.generation)
|
|
767
|
+
fail('controller');
|
|
768
|
+
text(input.mutation.reason, 256);
|
|
769
|
+
this.#database.prepare('UPDATE meta SET control_version=control_version+1,emergency_epoch=emergency_epoch+1,stopped=? WHERE singleton=1').run(input.mutation.operation === 'stop' ? 1 : 0);
|
|
770
|
+
const rows = this.#database.prepare("SELECT * FROM requests WHERE status IN ('prepared','dispatched')").all();
|
|
771
|
+
for (const row of rows) {
|
|
772
|
+
const result = this.#terminalize(row, now, input.mutation.operation === 'stop' ? 'emergency-stop' : 'emergency-epoch-advanced');
|
|
773
|
+
if (result.status === 'unknown')
|
|
774
|
+
abort.push(result.actionId);
|
|
775
|
+
}
|
|
776
|
+
this.#audit(input.mutation.operation === 'stop' ? 'emergency-stopped' : 'emergency-resumed', input.mutation.reason, expected + 1, now);
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
text(input.mutation.reason, 256);
|
|
780
|
+
const id = text(input.mutation.grantId);
|
|
781
|
+
const revision = integer(input.mutation.grantRevision, 1);
|
|
782
|
+
const head = this.#head(id);
|
|
783
|
+
if (!head || head.revision !== revision || head.digest !== digest(input.mutation.grantDigest) || meta.policy_epoch !== integer(input.mutation.policyEpoch) || meta.emergency_epoch !== integer(input.mutation.emergencyEpoch))
|
|
784
|
+
fail('grant');
|
|
785
|
+
this.#database.prepare('UPDATE grant_heads SET revoked=1 WHERE id=? AND revision=?').run(id, revision);
|
|
786
|
+
this.#database.prepare('UPDATE grants SET revoked=1 WHERE id=? AND revision=?').run(id, revision);
|
|
787
|
+
for (const row of this.#terminalizeGrant(id, now, 'grant-revoked'))
|
|
788
|
+
if (row.status === 'unknown')
|
|
789
|
+
abort.push(row.actionId);
|
|
790
|
+
this.#database.prepare('UPDATE meta SET control_version=control_version+1 WHERE singleton=1').run();
|
|
791
|
+
this.#audit('grant-revoked', id, revision, now);
|
|
792
|
+
}
|
|
793
|
+
this.#database.prepare('INSERT INTO admin_nonces VALUES(?,?,?,?)').run(key, nonceDigest, requestDigest, expiresAt);
|
|
794
|
+
this.#audit('admin-nonce-consumed', key, null, now);
|
|
795
|
+
const snapshot = this.#snapshot();
|
|
796
|
+
this.#database.exec('COMMIT');
|
|
797
|
+
return Object.freeze({ ...snapshot, abortActionIds: Object.freeze(abort) });
|
|
798
|
+
}
|
|
799
|
+
catch (error) {
|
|
800
|
+
try {
|
|
801
|
+
this.#database.exec('ROLLBACK');
|
|
802
|
+
}
|
|
803
|
+
catch { }
|
|
804
|
+
throw error;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
#terminalize(row, now, code) {
|
|
808
|
+
const record = this.#row(row);
|
|
809
|
+
const dispatched = record.status === 'dispatched';
|
|
810
|
+
const normalized = { status: dispatched ? 'unknown' : 'failed', dispatched, result: null, error: { code }, completedAt: Math.max(1, now) };
|
|
811
|
+
const changed = this.#database.prepare('UPDATE requests SET status=?,version=version+1,result_json=? WHERE action_id=? AND version=? AND status=?').run(normalized.status, canonicalBrokerJson(normalized), record.actionId, record.version, record.status);
|
|
812
|
+
if (changed.changes !== 1)
|
|
813
|
+
fail('state');
|
|
814
|
+
this.#database.prepare("UPDATE credential_leases SET status='revoked',settled_at=?,version=version+1 WHERE action_id=? AND status='active'").run(now, record.actionId);
|
|
815
|
+
this.#audit('request-invalidated', record.actionId, record.version + 1, now);
|
|
816
|
+
return this.#row(this.#database.prepare('SELECT * FROM requests WHERE action_id=?').get(record.actionId));
|
|
817
|
+
}
|
|
818
|
+
#terminalizeGrant(grantId, now, code) { const rows = this.#database.prepare("SELECT * FROM requests WHERE grant_id=? AND status IN ('prepared','dispatched')").all(grantId); return rows.map(row => this.#terminalize(row, now, code)); }
|
|
819
|
+
#control(expectedControlVersion, stopped, reason, authority) {
|
|
820
|
+
const expected = integer(expectedControlVersion);
|
|
821
|
+
text(reason, 256);
|
|
822
|
+
const now = this.#time();
|
|
823
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
824
|
+
try {
|
|
825
|
+
this.#authority(authority, now);
|
|
826
|
+
const meta = this.#meta();
|
|
827
|
+
if (meta.control_version !== expected)
|
|
828
|
+
fail('conflict');
|
|
829
|
+
this.#database.prepare('UPDATE meta SET control_version=control_version+1,emergency_epoch=emergency_epoch+1,stopped=? WHERE singleton=1').run(stopped ? 1 : 0);
|
|
830
|
+
const rows = this.#database.prepare("SELECT * FROM requests WHERE status IN ('prepared','dispatched')").all();
|
|
831
|
+
const abort = [];
|
|
832
|
+
for (const row of rows) {
|
|
833
|
+
const result = this.#terminalize(row, now, stopped ? 'emergency-stop' : 'emergency-epoch-advanced');
|
|
834
|
+
if (result.status === 'unknown')
|
|
835
|
+
abort.push(result.actionId);
|
|
836
|
+
}
|
|
837
|
+
this.#audit(stopped ? 'emergency-stopped' : 'emergency-resumed', reason, expected + 1, now);
|
|
838
|
+
const snapshot = this.#snapshot();
|
|
839
|
+
this.#database.exec('COMMIT');
|
|
840
|
+
return Object.freeze({ ...snapshot, abortActionIds: Object.freeze(abort) });
|
|
841
|
+
}
|
|
842
|
+
catch (error) {
|
|
843
|
+
try {
|
|
844
|
+
this.#database.exec('ROLLBACK');
|
|
845
|
+
}
|
|
846
|
+
catch { }
|
|
847
|
+
throw error;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
stop(expectedControlVersion, reason, authority) { return this.#control(expectedControlVersion, true, reason, authority); }
|
|
851
|
+
resume(expectedControlVersion, reason, authority) { return this.#control(expectedControlVersion, false, reason, authority); }
|
|
852
|
+
beginDrain(expectedControlVersion, reason, authority) {
|
|
853
|
+
const expected = integer(expectedControlVersion);
|
|
854
|
+
text(reason, 256);
|
|
855
|
+
const now = this.#time();
|
|
856
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
857
|
+
try {
|
|
858
|
+
this.#authority(authority, now);
|
|
859
|
+
const meta = this.#meta();
|
|
860
|
+
if (meta.draining === 1) {
|
|
861
|
+
this.#database.exec('COMMIT');
|
|
862
|
+
return Object.freeze({ ...this.#snapshot(meta), abortActionIds: Object.freeze([]) });
|
|
863
|
+
}
|
|
864
|
+
if (meta.control_version !== expected)
|
|
865
|
+
fail('conflict');
|
|
866
|
+
this.#database.prepare('UPDATE meta SET control_version=control_version+1,draining=1 WHERE singleton=1').run();
|
|
867
|
+
this.#audit('drain-started', reason, expected + 1, now);
|
|
868
|
+
const snapshot = this.#snapshot();
|
|
869
|
+
this.#database.exec('COMMIT');
|
|
870
|
+
return Object.freeze({ ...snapshot, abortActionIds: Object.freeze([]) });
|
|
871
|
+
}
|
|
872
|
+
catch (error) {
|
|
873
|
+
try {
|
|
874
|
+
this.#database.exec('ROLLBACK');
|
|
875
|
+
}
|
|
876
|
+
catch { }
|
|
877
|
+
throw error;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
revoke(grantId, revision, expectedControlVersion, reason, authority) {
|
|
881
|
+
const id = text(grantId);
|
|
882
|
+
const expectedRevision = integer(revision, 1);
|
|
883
|
+
const expectedControl = integer(expectedControlVersion);
|
|
884
|
+
text(reason, 256);
|
|
885
|
+
const now = this.#time();
|
|
886
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
887
|
+
try {
|
|
888
|
+
this.#authority(authority, now);
|
|
889
|
+
const meta = this.#meta();
|
|
890
|
+
if (meta.control_version !== expectedControl)
|
|
891
|
+
fail('conflict');
|
|
892
|
+
const head = this.#head(id);
|
|
893
|
+
if (!head || head.revision !== expectedRevision)
|
|
894
|
+
fail('grant');
|
|
895
|
+
this.#database.prepare('UPDATE grant_heads SET revoked=1 WHERE id=? AND revision=?').run(id, expectedRevision);
|
|
896
|
+
this.#database.prepare('UPDATE grants SET revoked=1 WHERE id=? AND revision=?').run(id, expectedRevision);
|
|
897
|
+
const abort = [];
|
|
898
|
+
for (const row of this.#terminalizeGrant(id, now, 'grant-revoked'))
|
|
899
|
+
if (row.status === 'unknown')
|
|
900
|
+
abort.push(row.actionId);
|
|
901
|
+
this.#database.prepare('UPDATE meta SET control_version=control_version+1 WHERE singleton=1').run();
|
|
902
|
+
this.#audit('grant-revoked', id, expectedRevision, now);
|
|
903
|
+
const snapshot = this.#snapshot();
|
|
904
|
+
this.#database.exec('COMMIT');
|
|
905
|
+
return Object.freeze({ ...snapshot, abortActionIds: Object.freeze(abort) });
|
|
906
|
+
}
|
|
907
|
+
catch (error) {
|
|
908
|
+
try {
|
|
909
|
+
this.#database.exec('ROLLBACK');
|
|
910
|
+
}
|
|
911
|
+
catch { }
|
|
912
|
+
throw error;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
snapshot() { return this.#snapshot(); }
|
|
916
|
+
close() { if (this.#closed)
|
|
917
|
+
return; this.#closed = true; try {
|
|
918
|
+
this.#database.close();
|
|
919
|
+
}
|
|
920
|
+
finally {
|
|
921
|
+
if (this.#parentDescriptor !== undefined)
|
|
922
|
+
closeSync(this.#parentDescriptor);
|
|
923
|
+
} }
|
|
924
|
+
}
|
|
925
|
+
//# sourceMappingURL=broker-ledger.js.map
|