@dsh-enhanced/assistant-policy 0.1.3
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 +136 -0
- package/cordis.patch.yml +13 -0
- package/lib/approval-reviewer.d.ts +39 -0
- package/lib/approval-reviewer.d.ts.map +1 -0
- package/lib/approval-reviewer.js +119 -0
- package/lib/approval-reviewer.js.map +1 -0
- package/lib/auto-review.d.ts +22 -0
- package/lib/auto-review.d.ts.map +1 -0
- package/lib/auto-review.js +442 -0
- package/lib/auto-review.js.map +1 -0
- package/lib/evaluator.d.ts +4 -0
- package/lib/evaluator.d.ts.map +1 -0
- package/lib/evaluator.js +127 -0
- package/lib/evaluator.js.map +1 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +16 -0
- package/lib/index.js.map +1 -0
- package/lib/ledger.d.ts +253 -0
- package/lib/ledger.d.ts.map +1 -0
- package/lib/ledger.js +891 -0
- package/lib/ledger.js.map +1 -0
- package/lib/redaction.d.ts +2 -0
- package/lib/redaction.d.ts.map +1 -0
- package/lib/redaction.js +21 -0
- package/lib/redaction.js.map +1 -0
- package/lib/service.d.ts +124 -0
- package/lib/service.d.ts.map +1 -0
- package/lib/service.js +773 -0
- package/lib/service.js.map +1 -0
- package/lib/session-event-registration.d.ts +23 -0
- package/lib/session-event-registration.d.ts.map +1 -0
- package/lib/session-event-registration.js +384 -0
- package/lib/session-event-registration.js.map +1 -0
- package/lib/settlement.d.ts +30 -0
- package/lib/settlement.d.ts.map +1 -0
- package/lib/settlement.js +54 -0
- package/lib/settlement.js.map +1 -0
- package/lib/sqlite.d.ts +9 -0
- package/lib/sqlite.d.ts.map +1 -0
- package/lib/sqlite.js +276 -0
- package/lib/sqlite.js.map +1 -0
- package/lib/tool-risk.d.ts +10 -0
- package/lib/tool-risk.d.ts.map +1 -0
- package/lib/tool-risk.js +321 -0
- package/lib/tool-risk.js.map +1 -0
- package/lib/types.d.ts +73 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.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 +98 -0
package/lib/ledger.js
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
import { redactAuditValue } from './redaction.js';
|
|
4
|
+
import { legacyApprovalIntentHash, openPolicyDatabase, PolicyDatabaseError } from './sqlite.js';
|
|
5
|
+
export class PolicyLedgerError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(code, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.name = 'PolicyLedgerError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Shared byte contract for Policy, Delivery, and channel approval rendering. */
|
|
14
|
+
export const APPROVAL_DISPLAY_BUDGET = Object.freeze({
|
|
15
|
+
maxTextBytes: 64 * 1_024,
|
|
16
|
+
maxSummaryBytes: 120,
|
|
17
|
+
renderingReserveBytes: 4 * 1_024,
|
|
18
|
+
maxDiffBytes: 60 * 1_024,
|
|
19
|
+
});
|
|
20
|
+
const textLimits = Object.freeze({
|
|
21
|
+
id: 512,
|
|
22
|
+
action: 256,
|
|
23
|
+
resourceId: 4_096,
|
|
24
|
+
workspace: 4_096,
|
|
25
|
+
summary: APPROVAL_DISPLAY_BUDGET.maxSummaryBytes,
|
|
26
|
+
diff: APPROVAL_DISPLAY_BUDGET.maxDiffBytes,
|
|
27
|
+
reason: 2_048,
|
|
28
|
+
});
|
|
29
|
+
function requireText(value, field) {
|
|
30
|
+
if (value.trim() === '')
|
|
31
|
+
throw new PolicyLedgerError('invalid-input', `${field} must not be empty`);
|
|
32
|
+
}
|
|
33
|
+
function requireBoundedText(value, field, maxBytes, options = {}) {
|
|
34
|
+
if (!options.allowEmpty)
|
|
35
|
+
requireText(value, field);
|
|
36
|
+
if (Buffer.byteLength(value, 'utf8') > maxBytes) {
|
|
37
|
+
throw new PolicyLedgerError('invalid-input', `${field} must not exceed ${maxBytes} UTF-8 bytes`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function dispatchPayloadHash(input) {
|
|
41
|
+
const canonicalPayload = JSON.stringify([
|
|
42
|
+
input.proposalId,
|
|
43
|
+
input.route.sourceId,
|
|
44
|
+
input.route.bindingId,
|
|
45
|
+
input.route.workspace,
|
|
46
|
+
input.route.principal,
|
|
47
|
+
input.requester,
|
|
48
|
+
input.action,
|
|
49
|
+
input.resourceKind,
|
|
50
|
+
input.resourceId,
|
|
51
|
+
input.summary,
|
|
52
|
+
input.diff,
|
|
53
|
+
input.diffHash,
|
|
54
|
+
input.expiresAt,
|
|
55
|
+
input.proposalVersion,
|
|
56
|
+
]);
|
|
57
|
+
return createHash('sha256').update(canonicalPayload).digest('hex');
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Canonical identity of an abandoned recovery attempt. The raw diff participates
|
|
61
|
+
* in the digest but is never persisted in a tombstone.
|
|
62
|
+
*/
|
|
63
|
+
function approvalRecoveryIntentHash(input) {
|
|
64
|
+
const dispatch = input.dispatch;
|
|
65
|
+
const canonicalIntent = JSON.stringify([
|
|
66
|
+
'approval-recovery-intent-v1',
|
|
67
|
+
input.idempotencyKey,
|
|
68
|
+
input.notAfter,
|
|
69
|
+
input.requester,
|
|
70
|
+
input.principal,
|
|
71
|
+
input.action,
|
|
72
|
+
input.resource.kind,
|
|
73
|
+
input.resource.id,
|
|
74
|
+
input.diff,
|
|
75
|
+
input.summary,
|
|
76
|
+
dispatch === undefined ? 0 : 1,
|
|
77
|
+
dispatch?.sourceId ?? null,
|
|
78
|
+
dispatch?.bindingId ?? null,
|
|
79
|
+
dispatch?.workspace ?? null,
|
|
80
|
+
dispatch?.principal ?? null,
|
|
81
|
+
]);
|
|
82
|
+
return createHash('sha256').update(canonicalIntent).digest('hex');
|
|
83
|
+
}
|
|
84
|
+
function requirePositive(value, field) {
|
|
85
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
86
|
+
throw new PolicyLedgerError('invalid-input', `${field} must be a positive finite number`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function requirePeriod(value) {
|
|
90
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
91
|
+
throw new PolicyLedgerError('invalid-input', 'periodMs must be a positive safe integer');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export class PolicyLedger {
|
|
95
|
+
#database;
|
|
96
|
+
#now;
|
|
97
|
+
#closed = false;
|
|
98
|
+
constructor(options) {
|
|
99
|
+
this.#now = options.now ?? Date.now;
|
|
100
|
+
try {
|
|
101
|
+
this.#database = openPolicyDatabase(options.path);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error instanceof PolicyDatabaseError) {
|
|
105
|
+
throw new PolicyLedgerError(error.code, error.message);
|
|
106
|
+
}
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
close() {
|
|
111
|
+
if (this.#closed)
|
|
112
|
+
return;
|
|
113
|
+
this.#closed = true;
|
|
114
|
+
this.#database.close();
|
|
115
|
+
}
|
|
116
|
+
#transaction(operation) {
|
|
117
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
118
|
+
try {
|
|
119
|
+
const value = operation();
|
|
120
|
+
this.#database.exec('COMMIT');
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
this.#database.exec('ROLLBACK');
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
#period(row) {
|
|
129
|
+
const period = this.#database.prepare(`
|
|
130
|
+
SELECT limit_amount, reserved_amount, spent_amount
|
|
131
|
+
FROM budget_periods
|
|
132
|
+
WHERE scope = ? AND metric = ? AND period_start = ? AND period_ms = ?
|
|
133
|
+
`).get(row.scope, row.metric, row.period_start, row.period_ms);
|
|
134
|
+
if (period === undefined)
|
|
135
|
+
throw new PolicyLedgerError('invalid-state', 'reservation budget period is missing');
|
|
136
|
+
return period;
|
|
137
|
+
}
|
|
138
|
+
#result(row, replayed) {
|
|
139
|
+
const period = this.#period(row);
|
|
140
|
+
return {
|
|
141
|
+
reservationId: row.id,
|
|
142
|
+
status: row.status,
|
|
143
|
+
periodStart: row.period_start,
|
|
144
|
+
remaining: period.limit_amount - period.reserved_amount - period.spent_amount,
|
|
145
|
+
replayed,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
reserve(input) {
|
|
149
|
+
requireText(input.scope, 'scope');
|
|
150
|
+
requireText(input.metric, 'metric');
|
|
151
|
+
requireText(input.idempotencyKey, 'idempotencyKey');
|
|
152
|
+
requirePositive(input.limit, 'limit');
|
|
153
|
+
requirePositive(input.amount, 'amount');
|
|
154
|
+
requirePeriod(input.periodMs);
|
|
155
|
+
const now = this.#now();
|
|
156
|
+
const periodStart = Math.floor(now / input.periodMs) * input.periodMs;
|
|
157
|
+
return this.#transaction(() => {
|
|
158
|
+
const existing = this.#database.prepare(`
|
|
159
|
+
SELECT id, idempotency_key, scope, metric, period_start, period_ms,
|
|
160
|
+
amount, actual_amount, status
|
|
161
|
+
FROM budget_reservations WHERE idempotency_key = ?
|
|
162
|
+
`).get(input.idempotencyKey);
|
|
163
|
+
if (existing !== undefined) {
|
|
164
|
+
const sameInput = existing.scope === input.scope
|
|
165
|
+
&& existing.metric === input.metric
|
|
166
|
+
&& existing.period_ms === input.periodMs
|
|
167
|
+
&& existing.amount === input.amount;
|
|
168
|
+
if (!sameInput) {
|
|
169
|
+
throw new PolicyLedgerError('idempotency-conflict', 'idempotency key was used for another reservation');
|
|
170
|
+
}
|
|
171
|
+
return this.#result(existing, true);
|
|
172
|
+
}
|
|
173
|
+
this.#database.prepare(`
|
|
174
|
+
INSERT INTO budget_periods(
|
|
175
|
+
scope, metric, period_start, period_ms, limit_amount, reserved_amount, spent_amount
|
|
176
|
+
) VALUES (?, ?, ?, ?, ?, 0, 0)
|
|
177
|
+
ON CONFLICT(scope, metric, period_start, period_ms) DO NOTHING
|
|
178
|
+
`).run(input.scope, input.metric, periodStart, input.periodMs, input.limit);
|
|
179
|
+
const period = this.#database.prepare(`
|
|
180
|
+
SELECT limit_amount, reserved_amount, spent_amount
|
|
181
|
+
FROM budget_periods
|
|
182
|
+
WHERE scope = ? AND metric = ? AND period_start = ? AND period_ms = ?
|
|
183
|
+
`).get(input.scope, input.metric, periodStart, input.periodMs);
|
|
184
|
+
if (period.limit_amount !== input.limit) {
|
|
185
|
+
throw new PolicyLedgerError('idempotency-conflict', 'budget limit changed within an active period');
|
|
186
|
+
}
|
|
187
|
+
if (period.reserved_amount + period.spent_amount + input.amount > period.limit_amount) {
|
|
188
|
+
throw new PolicyLedgerError('budget-exhausted', 'hard budget is exhausted');
|
|
189
|
+
}
|
|
190
|
+
const id = randomUUID();
|
|
191
|
+
this.#database.prepare(`
|
|
192
|
+
INSERT INTO budget_reservations(
|
|
193
|
+
id, idempotency_key, scope, metric, period_start, period_ms,
|
|
194
|
+
amount, actual_amount, status, created_at, updated_at
|
|
195
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'reserved', ?, ?)
|
|
196
|
+
`).run(id, input.idempotencyKey, input.scope, input.metric, periodStart, input.periodMs, input.amount, now, now);
|
|
197
|
+
this.#database.prepare(`
|
|
198
|
+
UPDATE budget_periods
|
|
199
|
+
SET reserved_amount = reserved_amount + ?, version = version + 1
|
|
200
|
+
WHERE scope = ? AND metric = ? AND period_start = ? AND period_ms = ?
|
|
201
|
+
`).run(input.amount, input.scope, input.metric, periodStart, input.periodMs);
|
|
202
|
+
return this.#result({
|
|
203
|
+
id,
|
|
204
|
+
idempotency_key: input.idempotencyKey,
|
|
205
|
+
scope: input.scope,
|
|
206
|
+
metric: input.metric,
|
|
207
|
+
period_start: periodStart,
|
|
208
|
+
period_ms: input.periodMs,
|
|
209
|
+
amount: input.amount,
|
|
210
|
+
actual_amount: null,
|
|
211
|
+
status: 'reserved',
|
|
212
|
+
}, false);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
finalize(reservationId, actualAmount) {
|
|
216
|
+
requireText(reservationId, 'reservationId');
|
|
217
|
+
if (!Number.isFinite(actualAmount) || actualAmount < 0) {
|
|
218
|
+
throw new PolicyLedgerError('invalid-input', 'actualAmount must be a non-negative finite number');
|
|
219
|
+
}
|
|
220
|
+
return this.#transaction(() => {
|
|
221
|
+
const row = this.#reservation(reservationId);
|
|
222
|
+
if (row.status === 'finalized') {
|
|
223
|
+
if (row.actual_amount !== actualAmount) {
|
|
224
|
+
throw new PolicyLedgerError('idempotency-conflict', 'reservation was finalized with another amount');
|
|
225
|
+
}
|
|
226
|
+
return this.#result(row, true);
|
|
227
|
+
}
|
|
228
|
+
if (row.status !== 'reserved') {
|
|
229
|
+
throw new PolicyLedgerError('invalid-state', `cannot finalize a ${row.status} reservation`);
|
|
230
|
+
}
|
|
231
|
+
if (actualAmount > row.amount) {
|
|
232
|
+
throw new PolicyLedgerError('invalid-input', 'actualAmount cannot exceed the reserved amount');
|
|
233
|
+
}
|
|
234
|
+
this.#database.prepare(`
|
|
235
|
+
UPDATE budget_periods
|
|
236
|
+
SET reserved_amount = reserved_amount - ?, spent_amount = spent_amount + ?, version = version + 1
|
|
237
|
+
WHERE scope = ? AND metric = ? AND period_start = ? AND period_ms = ?
|
|
238
|
+
`).run(row.amount, actualAmount, row.scope, row.metric, row.period_start, row.period_ms);
|
|
239
|
+
this.#database.prepare(`
|
|
240
|
+
UPDATE budget_reservations
|
|
241
|
+
SET status = 'finalized', actual_amount = ?, updated_at = ?, version = version + 1
|
|
242
|
+
WHERE id = ?
|
|
243
|
+
`).run(actualAmount, this.#now(), row.id);
|
|
244
|
+
return this.#result({ ...row, status: 'finalized', actual_amount: actualAmount }, false);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
release(reservationId) {
|
|
248
|
+
requireText(reservationId, 'reservationId');
|
|
249
|
+
return this.#transaction(() => {
|
|
250
|
+
const row = this.#reservation(reservationId);
|
|
251
|
+
if (row.status === 'released')
|
|
252
|
+
return this.#result(row, true);
|
|
253
|
+
if (row.status !== 'reserved') {
|
|
254
|
+
throw new PolicyLedgerError('invalid-state', `cannot release a ${row.status} reservation`);
|
|
255
|
+
}
|
|
256
|
+
this.#database.prepare(`
|
|
257
|
+
UPDATE budget_periods
|
|
258
|
+
SET reserved_amount = reserved_amount - ?, version = version + 1
|
|
259
|
+
WHERE scope = ? AND metric = ? AND period_start = ? AND period_ms = ?
|
|
260
|
+
`).run(row.amount, row.scope, row.metric, row.period_start, row.period_ms);
|
|
261
|
+
this.#database.prepare(`
|
|
262
|
+
UPDATE budget_reservations
|
|
263
|
+
SET status = 'released', updated_at = ?, version = version + 1
|
|
264
|
+
WHERE id = ?
|
|
265
|
+
`).run(this.#now(), row.id);
|
|
266
|
+
return this.#result({ ...row, status: 'released' }, false);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
#reservation(id) {
|
|
270
|
+
const row = this.#database.prepare(`
|
|
271
|
+
SELECT id, idempotency_key, scope, metric, period_start, period_ms,
|
|
272
|
+
amount, actual_amount, status
|
|
273
|
+
FROM budget_reservations WHERE id = ?
|
|
274
|
+
`).get(id);
|
|
275
|
+
if (row === undefined)
|
|
276
|
+
throw new PolicyLedgerError('not-found', 'budget reservation was not found');
|
|
277
|
+
return row;
|
|
278
|
+
}
|
|
279
|
+
getEmergencyStop() {
|
|
280
|
+
const row = this.#database.prepare(`
|
|
281
|
+
SELECT enabled, reason, actor, updated_at, version FROM emergency_state WHERE singleton = 1
|
|
282
|
+
`).get();
|
|
283
|
+
if (row === undefined) {
|
|
284
|
+
return { enabled: false, reason: undefined, actor: undefined, updatedAt: undefined, version: 0 };
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
enabled: row.enabled === 1,
|
|
288
|
+
reason: row.reason,
|
|
289
|
+
actor: row.actor,
|
|
290
|
+
updatedAt: row.updated_at,
|
|
291
|
+
version: row.version,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
setEmergencyStop(input) {
|
|
295
|
+
requireText(input.actor, 'actor');
|
|
296
|
+
requireText(input.reason, 'reason');
|
|
297
|
+
const now = this.#now();
|
|
298
|
+
this.#transaction(() => {
|
|
299
|
+
this.#database.prepare(`
|
|
300
|
+
INSERT INTO emergency_state(singleton, enabled, reason, actor, updated_at, version)
|
|
301
|
+
VALUES (1, ?, ?, ?, ?, 1)
|
|
302
|
+
ON CONFLICT(singleton) DO UPDATE SET
|
|
303
|
+
enabled = excluded.enabled,
|
|
304
|
+
reason = excluded.reason,
|
|
305
|
+
actor = excluded.actor,
|
|
306
|
+
updated_at = excluded.updated_at,
|
|
307
|
+
version = emergency_state.version + 1
|
|
308
|
+
`).run(input.enabled ? 1 : 0, input.reason, input.actor, now);
|
|
309
|
+
});
|
|
310
|
+
return this.getEmergencyStop();
|
|
311
|
+
}
|
|
312
|
+
#validateProposalContent(input) {
|
|
313
|
+
requireBoundedText(input.idempotencyKey, 'idempotencyKey', textLimits.id);
|
|
314
|
+
requireBoundedText(input.requester, 'requester', textLimits.id);
|
|
315
|
+
requireBoundedText(input.principal, 'principal', textLimits.id);
|
|
316
|
+
requireBoundedText(input.action, 'action', textLimits.action);
|
|
317
|
+
requireBoundedText(input.resource.kind, 'resource.kind', textLimits.action);
|
|
318
|
+
requireBoundedText(input.resource.id, 'resource.id', textLimits.resourceId);
|
|
319
|
+
requireBoundedText(input.summary, 'summary', textLimits.summary);
|
|
320
|
+
requireBoundedText(input.diff, 'diff', textLimits.diff, { allowEmpty: true });
|
|
321
|
+
if (input.dispatch !== undefined) {
|
|
322
|
+
requireBoundedText(input.dispatch.sourceId, 'dispatch.sourceId', textLimits.id);
|
|
323
|
+
requireBoundedText(input.dispatch.bindingId, 'dispatch.bindingId', textLimits.id);
|
|
324
|
+
requireBoundedText(input.dispatch.workspace, 'dispatch.workspace', textLimits.workspace);
|
|
325
|
+
requireBoundedText(input.dispatch.principal, 'dispatch.principal', textLimits.id);
|
|
326
|
+
if (!isAbsolute(input.dispatch.workspace)) {
|
|
327
|
+
throw new PolicyLedgerError('invalid-path', 'dispatch.workspace must be absolute');
|
|
328
|
+
}
|
|
329
|
+
if (input.dispatch.principal !== input.principal) {
|
|
330
|
+
throw new PolicyLedgerError('unauthorized', 'dispatch route is bound to another principal');
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return createHash('sha256').update(input.diff).digest('hex');
|
|
334
|
+
}
|
|
335
|
+
#proposalByIdempotencyKey(idempotencyKey) {
|
|
336
|
+
return this.#database.prepare(`
|
|
337
|
+
SELECT id, idempotency_key, requester, principal, action, resource_kind,
|
|
338
|
+
resource_id, diff_hash, diff_text, summary, status, created_at, expires_at, decided_by,
|
|
339
|
+
decision_reason, version
|
|
340
|
+
FROM approval_proposals WHERE idempotency_key = ?
|
|
341
|
+
`).get(idempotencyKey);
|
|
342
|
+
}
|
|
343
|
+
#assertExistingProposalMatches(existing, input, diffHash, expiryMatches) {
|
|
344
|
+
const existingDispatch = this.#database.prepare(`
|
|
345
|
+
SELECT source_id, binding_id, workspace, principal, state, payload_hash, proposal_version
|
|
346
|
+
FROM approval_dispatches WHERE proposal_id = ?
|
|
347
|
+
`).get(existing.id);
|
|
348
|
+
const sameDispatch = input.dispatch === undefined
|
|
349
|
+
? existingDispatch === undefined
|
|
350
|
+
: existingDispatch !== undefined
|
|
351
|
+
&& existingDispatch.source_id === input.dispatch.sourceId
|
|
352
|
+
&& existingDispatch.binding_id === input.dispatch.bindingId
|
|
353
|
+
&& existingDispatch.workspace === input.dispatch.workspace
|
|
354
|
+
&& existingDispatch.principal === input.dispatch.principal;
|
|
355
|
+
const sameDiff = input.dispatch === undefined
|
|
356
|
+
? existingDispatch === undefined && existing.diff_hash === diffHash
|
|
357
|
+
: existingDispatch !== undefined
|
|
358
|
+
&& existing.diff_hash === diffHash
|
|
359
|
+
&& (existing.diff_text === null || existing.diff_text === input.diff)
|
|
360
|
+
&& existingDispatch.payload_hash === dispatchPayloadHash({
|
|
361
|
+
proposalId: existing.id,
|
|
362
|
+
route: input.dispatch,
|
|
363
|
+
requester: input.requester,
|
|
364
|
+
action: input.action,
|
|
365
|
+
resourceKind: input.resource.kind,
|
|
366
|
+
resourceId: input.resource.id,
|
|
367
|
+
summary: input.summary,
|
|
368
|
+
diff: input.diff,
|
|
369
|
+
diffHash,
|
|
370
|
+
expiresAt: existing.expires_at,
|
|
371
|
+
proposalVersion: existingDispatch.proposal_version,
|
|
372
|
+
});
|
|
373
|
+
const sameInput = existing.requester === input.requester
|
|
374
|
+
&& existing.principal === input.principal
|
|
375
|
+
&& existing.action === input.action
|
|
376
|
+
&& existing.resource_kind === input.resource.kind
|
|
377
|
+
&& existing.resource_id === input.resource.id
|
|
378
|
+
&& sameDiff
|
|
379
|
+
&& existing.summary === input.summary
|
|
380
|
+
&& expiryMatches
|
|
381
|
+
&& sameDispatch;
|
|
382
|
+
if (!sameInput) {
|
|
383
|
+
throw new PolicyLedgerError('idempotency-conflict', 'idempotency key was used for another proposal');
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
#insertProposal(input, diffHash, createdAt, expiresAt) {
|
|
387
|
+
const id = randomUUID();
|
|
388
|
+
this.#database.prepare(`
|
|
389
|
+
INSERT INTO approval_proposals(
|
|
390
|
+
id, idempotency_key, requester, principal, action, resource_kind,
|
|
391
|
+
resource_id, diff_hash, diff_text, summary, status, created_at, expires_at
|
|
392
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
|
|
393
|
+
`).run(id, input.idempotencyKey, input.requester, input.principal, input.action, input.resource.kind, input.resource.id, diffHash, input.dispatch === undefined ? null : input.diff, input.summary, createdAt, expiresAt);
|
|
394
|
+
if (input.dispatch !== undefined) {
|
|
395
|
+
const payloadHash = dispatchPayloadHash({
|
|
396
|
+
proposalId: id,
|
|
397
|
+
route: input.dispatch,
|
|
398
|
+
requester: input.requester,
|
|
399
|
+
action: input.action,
|
|
400
|
+
resourceKind: input.resource.kind,
|
|
401
|
+
resourceId: input.resource.id,
|
|
402
|
+
summary: input.summary,
|
|
403
|
+
diff: input.diff,
|
|
404
|
+
diffHash,
|
|
405
|
+
expiresAt,
|
|
406
|
+
proposalVersion: 1,
|
|
407
|
+
});
|
|
408
|
+
this.#database.prepare(`
|
|
409
|
+
INSERT INTO approval_dispatches(
|
|
410
|
+
proposal_id, source_id, binding_id, workspace, principal,
|
|
411
|
+
state, payload_hash, created_at, proposal_version
|
|
412
|
+
) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, 1)
|
|
413
|
+
`).run(id, input.dispatch.sourceId, input.dispatch.bindingId, input.dispatch.workspace, input.dispatch.principal, payloadHash, createdAt);
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
proposalId: id,
|
|
417
|
+
status: 'pending',
|
|
418
|
+
diffHash,
|
|
419
|
+
expiresAt,
|
|
420
|
+
version: 1,
|
|
421
|
+
replayed: false,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
propose(input) {
|
|
425
|
+
const diffHash = this.#validateProposalContent(input);
|
|
426
|
+
requirePeriod(input.ttlMs);
|
|
427
|
+
const now = this.#now();
|
|
428
|
+
const expiresAt = now + input.ttlMs;
|
|
429
|
+
if (!Number.isSafeInteger(expiresAt)) {
|
|
430
|
+
throw new PolicyLedgerError('invalid-input', 'proposal expiry exceeds the safe timestamp range');
|
|
431
|
+
}
|
|
432
|
+
return this.#transaction(() => {
|
|
433
|
+
const tombstone = this.#database.prepare(`
|
|
434
|
+
SELECT idempotency_key FROM approval_idempotency_tombstones WHERE idempotency_key = ?
|
|
435
|
+
`).get(input.idempotencyKey);
|
|
436
|
+
if (tombstone !== undefined) {
|
|
437
|
+
throw new PolicyLedgerError('idempotency-conflict', 'proposal idempotency key was permanently abandoned');
|
|
438
|
+
}
|
|
439
|
+
const existing = this.#proposalByIdempotencyKey(input.idempotencyKey);
|
|
440
|
+
if (existing !== undefined) {
|
|
441
|
+
this.#assertExistingProposalMatches(existing, input, diffHash, existing.expires_at - existing.created_at === input.ttlMs);
|
|
442
|
+
return this.#proposalResult(existing, true);
|
|
443
|
+
}
|
|
444
|
+
return this.#insertProposal(input, diffHash, now, expiresAt);
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Atomically recover an existing exact proposal, create it under an immutable
|
|
449
|
+
* absolute deadline, or permanently abandon the idempotency key after that
|
|
450
|
+
* deadline. `BEGIN IMMEDIATE` serializes this decision across Policy processes,
|
|
451
|
+
* closing the lookup-then-create orphan-card race.
|
|
452
|
+
*/
|
|
453
|
+
recoverOrCreateProposal(input) {
|
|
454
|
+
const diffHash = this.#validateProposalContent(input);
|
|
455
|
+
if (!Number.isSafeInteger(input.notAfter) || input.notAfter < 0) {
|
|
456
|
+
throw new PolicyLedgerError('invalid-input', 'proposal notAfter must be a non-negative safe integer');
|
|
457
|
+
}
|
|
458
|
+
const intentHash = approvalRecoveryIntentHash(input);
|
|
459
|
+
return this.#transaction(() => {
|
|
460
|
+
const existing = this.#proposalByIdempotencyKey(input.idempotencyKey);
|
|
461
|
+
if (existing !== undefined) {
|
|
462
|
+
this.#assertExistingProposalMatches(existing, input, diffHash, existing.expires_at === input.notAfter);
|
|
463
|
+
return Object.freeze({
|
|
464
|
+
kind: 'proposal',
|
|
465
|
+
proposal: this.#proposalResult(existing, true),
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
const tombstone = this.#database.prepare(`
|
|
469
|
+
SELECT idempotency_key, not_after, abandoned_at, intent_hash
|
|
470
|
+
FROM approval_idempotency_tombstones WHERE idempotency_key = ?
|
|
471
|
+
`).get(input.idempotencyKey);
|
|
472
|
+
if (tombstone !== undefined) {
|
|
473
|
+
if (tombstone.intent_hash === legacyApprovalIntentHash || tombstone.intent_hash !== intentHash) {
|
|
474
|
+
throw new PolicyLedgerError('idempotency-conflict', 'idempotency key was abandoned for another or unverifiable proposal intent');
|
|
475
|
+
}
|
|
476
|
+
return Object.freeze({
|
|
477
|
+
kind: 'abandoned',
|
|
478
|
+
idempotencyKey: tombstone.idempotency_key,
|
|
479
|
+
notAfter: tombstone.not_after,
|
|
480
|
+
abandonedAt: tombstone.abandoned_at,
|
|
481
|
+
replayed: true,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
// Sample only after BEGIN IMMEDIATE has acquired the write lock. A value
|
|
485
|
+
// read before lock acquisition could become stale while another Policy
|
|
486
|
+
// process holds the transaction across the absolute deadline.
|
|
487
|
+
const now = this.#now();
|
|
488
|
+
if (now >= input.notAfter) {
|
|
489
|
+
this.#database.prepare(`
|
|
490
|
+
INSERT INTO approval_idempotency_tombstones(
|
|
491
|
+
idempotency_key, not_after, abandoned_at, intent_hash
|
|
492
|
+
) VALUES (?, ?, ?, ?)
|
|
493
|
+
`).run(input.idempotencyKey, input.notAfter, now, intentHash);
|
|
494
|
+
return Object.freeze({
|
|
495
|
+
kind: 'abandoned',
|
|
496
|
+
idempotencyKey: input.idempotencyKey,
|
|
497
|
+
notAfter: input.notAfter,
|
|
498
|
+
abandonedAt: now,
|
|
499
|
+
replayed: false,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
return Object.freeze({
|
|
503
|
+
kind: 'proposal',
|
|
504
|
+
proposal: this.#insertProposal(input, diffHash, now, input.notAfter),
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
decideProposal(input) {
|
|
509
|
+
requireBoundedText(input.proposalId, 'proposalId', textLimits.id);
|
|
510
|
+
requireBoundedText(input.principal, 'principal', textLimits.id);
|
|
511
|
+
requireBoundedText(input.reason, 'reason', textLimits.reason);
|
|
512
|
+
if (!Number.isSafeInteger(input.expectedVersion) || input.expectedVersion <= 0) {
|
|
513
|
+
throw new PolicyLedgerError('invalid-input', 'expectedVersion must be a positive safe integer');
|
|
514
|
+
}
|
|
515
|
+
return this.#transaction(() => {
|
|
516
|
+
const proposal = this.#proposal(input.proposalId);
|
|
517
|
+
if (proposal.principal !== input.principal) {
|
|
518
|
+
throw new PolicyLedgerError('unauthorized', 'proposal is bound to another principal');
|
|
519
|
+
}
|
|
520
|
+
if (proposal.status === input.decision) {
|
|
521
|
+
if (proposal.decided_by !== input.principal || proposal.decision_reason !== input.reason) {
|
|
522
|
+
throw new PolicyLedgerError('idempotency-conflict', 'proposal already has a different decision');
|
|
523
|
+
}
|
|
524
|
+
return this.#proposalResult(proposal, true);
|
|
525
|
+
}
|
|
526
|
+
if (proposal.version !== input.expectedVersion) {
|
|
527
|
+
throw new PolicyLedgerError('version-conflict', 'proposal version changed');
|
|
528
|
+
}
|
|
529
|
+
if (proposal.status !== 'pending') {
|
|
530
|
+
throw new PolicyLedgerError('invalid-state', `proposal is already ${proposal.status}`);
|
|
531
|
+
}
|
|
532
|
+
const now = this.#now();
|
|
533
|
+
const status = now >= proposal.expires_at ? 'expired' : input.decision;
|
|
534
|
+
const decidedBy = status === 'expired' ? 'system:expiry' : input.principal;
|
|
535
|
+
const decisionReason = status === 'expired' ? 'expired' : input.reason;
|
|
536
|
+
this.#database.prepare(`
|
|
537
|
+
UPDATE approval_proposals
|
|
538
|
+
SET status = ?, decided_at = ?, decided_by = ?, decision_reason = ?,
|
|
539
|
+
diff_text = NULL, version = version + 1
|
|
540
|
+
WHERE id = ? AND version = ?
|
|
541
|
+
`).run(status, now, decidedBy, decisionReason, proposal.id, proposal.version);
|
|
542
|
+
return this.#proposalResult({
|
|
543
|
+
...proposal,
|
|
544
|
+
status,
|
|
545
|
+
decided_by: decidedBy,
|
|
546
|
+
decision_reason: decisionReason,
|
|
547
|
+
version: proposal.version + 1,
|
|
548
|
+
}, false);
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Read one proposal's current status without deciding it. A domain reconciler
|
|
553
|
+
* uses this to learn that its own pending operation was already approved or
|
|
554
|
+
* rejected elsewhere, then commits that outcome itself.
|
|
555
|
+
*/
|
|
556
|
+
getProposal(proposalId) {
|
|
557
|
+
requireBoundedText(proposalId, 'proposalId', textLimits.id);
|
|
558
|
+
const row = this.#database.prepare(`
|
|
559
|
+
SELECT id, idempotency_key, requester, principal, action, resource_kind,
|
|
560
|
+
resource_id, diff_hash, diff_text, summary, status, created_at, expires_at, decided_by,
|
|
561
|
+
decision_reason, version
|
|
562
|
+
FROM approval_proposals WHERE id = ?
|
|
563
|
+
`).get(proposalId);
|
|
564
|
+
if (row === undefined)
|
|
565
|
+
return undefined;
|
|
566
|
+
return Object.freeze({
|
|
567
|
+
proposalId: row.id,
|
|
568
|
+
requester: row.requester,
|
|
569
|
+
principal: row.principal,
|
|
570
|
+
action: row.action,
|
|
571
|
+
resource: Object.freeze({ kind: row.resource_kind, id: row.resource_id }),
|
|
572
|
+
summary: row.summary,
|
|
573
|
+
status: row.status,
|
|
574
|
+
diffHash: row.diff_hash,
|
|
575
|
+
expiresAt: row.expires_at,
|
|
576
|
+
version: row.version,
|
|
577
|
+
decidedBy: row.decided_by ?? undefined,
|
|
578
|
+
decisionReason: row.decision_reason ?? undefined,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Recover an existing proposal by its immutable scoped idempotency identity.
|
|
583
|
+
* A mismatch is indistinguishable from absence; callers may subsequently try
|
|
584
|
+
* `propose`, whose normal idempotency conflict check remains fail-closed.
|
|
585
|
+
*/
|
|
586
|
+
getProposalByIdempotencyKey(input) {
|
|
587
|
+
requireBoundedText(input.idempotencyKey, 'idempotencyKey', textLimits.id);
|
|
588
|
+
requireBoundedText(input.requester, 'requester', textLimits.id);
|
|
589
|
+
requireBoundedText(input.principal, 'principal', textLimits.id);
|
|
590
|
+
requireBoundedText(input.action, 'action', textLimits.action);
|
|
591
|
+
requireBoundedText(input.resource.kind, 'resource.kind', textLimits.action);
|
|
592
|
+
requireBoundedText(input.resource.id, 'resource.id', textLimits.resourceId);
|
|
593
|
+
const row = this.#database.prepare(`
|
|
594
|
+
SELECT id, idempotency_key, requester, principal, action, resource_kind,
|
|
595
|
+
resource_id, diff_hash, diff_text, summary, status, created_at, expires_at, decided_by,
|
|
596
|
+
decision_reason, version
|
|
597
|
+
FROM approval_proposals
|
|
598
|
+
WHERE idempotency_key = ? AND requester = ? AND principal = ? AND action = ?
|
|
599
|
+
AND resource_kind = ? AND resource_id = ?
|
|
600
|
+
`).get(input.idempotencyKey, input.requester, input.principal, input.action, input.resource.kind, input.resource.id);
|
|
601
|
+
if (row === undefined)
|
|
602
|
+
return undefined;
|
|
603
|
+
return Object.freeze({
|
|
604
|
+
proposalId: row.id,
|
|
605
|
+
requester: row.requester,
|
|
606
|
+
principal: row.principal,
|
|
607
|
+
action: row.action,
|
|
608
|
+
resource: Object.freeze({ kind: row.resource_kind, id: row.resource_id }),
|
|
609
|
+
summary: row.summary,
|
|
610
|
+
status: row.status,
|
|
611
|
+
diffHash: row.diff_hash,
|
|
612
|
+
expiresAt: row.expires_at,
|
|
613
|
+
version: row.version,
|
|
614
|
+
decidedBy: row.decided_by ?? undefined,
|
|
615
|
+
decisionReason: row.decision_reason ?? undefined,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
listPendingApprovalDispatches(limit = 100, after) {
|
|
619
|
+
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 100) {
|
|
620
|
+
throw new PolicyLedgerError('invalid-input', 'approval dispatch limit must be between 1 and 100');
|
|
621
|
+
}
|
|
622
|
+
if (after !== undefined) {
|
|
623
|
+
if (after === null || typeof after !== 'object' || Array.isArray(after)
|
|
624
|
+
|| Object.keys(after).length !== 2
|
|
625
|
+
|| !Object.hasOwn(after, 'createdAt') || !Object.hasOwn(after, 'proposalId')
|
|
626
|
+
|| !Number.isSafeInteger(after.createdAt) || after.createdAt < 0
|
|
627
|
+
|| typeof after.proposalId !== 'string') {
|
|
628
|
+
throw new PolicyLedgerError('invalid-input', 'approval dispatch cursor is invalid');
|
|
629
|
+
}
|
|
630
|
+
requireBoundedText(after.proposalId, 'approval dispatch cursor proposalId', textLimits.id);
|
|
631
|
+
}
|
|
632
|
+
const cursorCreatedAt = after?.createdAt ?? null;
|
|
633
|
+
const cursorProposalId = after?.proposalId ?? '';
|
|
634
|
+
const rows = this.#database.prepare(`
|
|
635
|
+
SELECT d.proposal_id, d.source_id, d.binding_id, d.workspace,
|
|
636
|
+
d.principal AS dispatch_principal, d.state, d.payload_hash,
|
|
637
|
+
d.created_at AS dispatch_created_at, d.enqueued_at,
|
|
638
|
+
d.version AS dispatch_version, d.proposal_version, p.requester,
|
|
639
|
+
p.principal AS proposal_principal, p.action, p.resource_kind,
|
|
640
|
+
p.resource_id, p.diff_hash, p.diff_text, p.summary, p.status,
|
|
641
|
+
p.expires_at, p.version AS current_proposal_version
|
|
642
|
+
FROM approval_dispatches d
|
|
643
|
+
JOIN approval_proposals p ON p.id = d.proposal_id
|
|
644
|
+
WHERE d.state = 'pending' AND p.status = 'pending' AND p.expires_at > ?
|
|
645
|
+
AND (? IS NULL OR d.created_at > ? OR (d.created_at = ? AND d.proposal_id > ?))
|
|
646
|
+
ORDER BY d.created_at ASC, d.proposal_id ASC
|
|
647
|
+
LIMIT ?
|
|
648
|
+
`).all(this.#now(), cursorCreatedAt, cursorCreatedAt, cursorCreatedAt, cursorProposalId, limit);
|
|
649
|
+
return rows.map(row => this.#dispatchSnapshot(row));
|
|
650
|
+
}
|
|
651
|
+
markApprovalDispatchEnqueued(proposalId, expectedVersion) {
|
|
652
|
+
requireBoundedText(proposalId, 'proposalId', textLimits.id);
|
|
653
|
+
if (!Number.isSafeInteger(expectedVersion) || expectedVersion <= 0) {
|
|
654
|
+
throw new PolicyLedgerError('invalid-input', 'expectedVersion must be a positive safe integer');
|
|
655
|
+
}
|
|
656
|
+
return this.#transaction(() => {
|
|
657
|
+
const dispatch = this.#dispatch(proposalId);
|
|
658
|
+
if (dispatch.state === 'enqueued') {
|
|
659
|
+
return this.#dispatchResult(dispatch, true);
|
|
660
|
+
}
|
|
661
|
+
if (dispatch.dispatch_version !== expectedVersion) {
|
|
662
|
+
throw new PolicyLedgerError('version-conflict', 'approval dispatch version changed');
|
|
663
|
+
}
|
|
664
|
+
const now = this.#now();
|
|
665
|
+
if (dispatch.status !== 'pending' || dispatch.expires_at <= now) {
|
|
666
|
+
throw new PolicyLedgerError('invalid-state', 'approval proposal is no longer pending');
|
|
667
|
+
}
|
|
668
|
+
this.#dispatchSnapshot(dispatch);
|
|
669
|
+
const result = this.#database.prepare(`
|
|
670
|
+
UPDATE approval_dispatches
|
|
671
|
+
SET state = 'enqueued', enqueued_at = ?, version = version + 1
|
|
672
|
+
WHERE proposal_id = ? AND state = 'pending' AND version = ?
|
|
673
|
+
`).run(now, proposalId, expectedVersion);
|
|
674
|
+
if (Number(result.changes) !== 1) {
|
|
675
|
+
throw new PolicyLedgerError('version-conflict', 'approval dispatch version changed');
|
|
676
|
+
}
|
|
677
|
+
const cleared = this.#database.prepare(`
|
|
678
|
+
UPDATE approval_proposals SET diff_text = NULL
|
|
679
|
+
WHERE id = ? AND status = 'pending' AND version = ? AND diff_text IS NOT NULL
|
|
680
|
+
`).run(proposalId, dispatch.proposal_version);
|
|
681
|
+
if (Number(cleared.changes) !== 1) {
|
|
682
|
+
throw new PolicyLedgerError('invalid-state', 'approval dispatch diff could not be cleared');
|
|
683
|
+
}
|
|
684
|
+
return this.#dispatchResult({
|
|
685
|
+
...dispatch,
|
|
686
|
+
state: 'enqueued',
|
|
687
|
+
enqueued_at: now,
|
|
688
|
+
dispatch_version: dispatch.dispatch_version + 1,
|
|
689
|
+
diff_text: null,
|
|
690
|
+
}, false);
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
expireProposals(limit = 100) {
|
|
694
|
+
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 1_000) {
|
|
695
|
+
throw new PolicyLedgerError('invalid-input', 'proposal expiry limit must be between 1 and 1000');
|
|
696
|
+
}
|
|
697
|
+
const now = this.#now();
|
|
698
|
+
return this.#transaction(() => {
|
|
699
|
+
const rows = this.#database.prepare(`
|
|
700
|
+
SELECT id FROM approval_proposals
|
|
701
|
+
WHERE status = 'pending' AND expires_at <= ?
|
|
702
|
+
ORDER BY expires_at ASC, id ASC
|
|
703
|
+
LIMIT ?
|
|
704
|
+
`).all(now, limit);
|
|
705
|
+
const expire = this.#database.prepare(`
|
|
706
|
+
UPDATE approval_proposals
|
|
707
|
+
SET status = 'expired', decided_at = ?, decided_by = 'system:expiry',
|
|
708
|
+
decision_reason = 'expired', diff_text = NULL, version = version + 1
|
|
709
|
+
WHERE id = ? AND status = 'pending'
|
|
710
|
+
`);
|
|
711
|
+
let changed = 0;
|
|
712
|
+
for (const row of rows)
|
|
713
|
+
changed += Number(expire.run(now, row.id).changes);
|
|
714
|
+
return changed;
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
#proposal(id) {
|
|
718
|
+
const proposal = this.#database.prepare(`
|
|
719
|
+
SELECT id, idempotency_key, requester, principal, action, resource_kind,
|
|
720
|
+
resource_id, diff_hash, diff_text, summary, status, created_at, expires_at, decided_by,
|
|
721
|
+
decision_reason, version
|
|
722
|
+
FROM approval_proposals WHERE id = ?
|
|
723
|
+
`).get(id);
|
|
724
|
+
if (proposal === undefined)
|
|
725
|
+
throw new PolicyLedgerError('not-found', 'approval proposal was not found');
|
|
726
|
+
return proposal;
|
|
727
|
+
}
|
|
728
|
+
#dispatch(proposalId) {
|
|
729
|
+
const dispatch = this.#database.prepare(`
|
|
730
|
+
SELECT d.proposal_id, d.source_id, d.binding_id, d.workspace,
|
|
731
|
+
d.principal AS dispatch_principal, d.state, d.payload_hash,
|
|
732
|
+
d.created_at AS dispatch_created_at, d.enqueued_at,
|
|
733
|
+
d.version AS dispatch_version, d.proposal_version, p.requester,
|
|
734
|
+
p.principal AS proposal_principal, p.action, p.resource_kind,
|
|
735
|
+
p.resource_id, p.diff_hash, p.diff_text, p.summary, p.status,
|
|
736
|
+
p.expires_at, p.version AS current_proposal_version
|
|
737
|
+
FROM approval_dispatches d
|
|
738
|
+
JOIN approval_proposals p ON p.id = d.proposal_id
|
|
739
|
+
WHERE d.proposal_id = ?
|
|
740
|
+
`).get(proposalId);
|
|
741
|
+
if (dispatch === undefined) {
|
|
742
|
+
throw new PolicyLedgerError('not-found', 'approval dispatch was not found');
|
|
743
|
+
}
|
|
744
|
+
return dispatch;
|
|
745
|
+
}
|
|
746
|
+
#dispatchSnapshot(row) {
|
|
747
|
+
if (row.diff_text === null) {
|
|
748
|
+
throw new PolicyLedgerError('invalid-state', 'approval dispatch is missing its immutable diff');
|
|
749
|
+
}
|
|
750
|
+
if (row.dispatch_principal !== row.proposal_principal) {
|
|
751
|
+
throw new PolicyLedgerError('invalid-state', 'approval dispatch principal does not match proposal');
|
|
752
|
+
}
|
|
753
|
+
if (row.current_proposal_version !== row.proposal_version) {
|
|
754
|
+
throw new PolicyLedgerError('invalid-state', 'approval dispatch proposal version changed');
|
|
755
|
+
}
|
|
756
|
+
const expectedPayloadHash = dispatchPayloadHash({
|
|
757
|
+
proposalId: row.proposal_id,
|
|
758
|
+
route: {
|
|
759
|
+
sourceId: row.source_id,
|
|
760
|
+
bindingId: row.binding_id,
|
|
761
|
+
workspace: row.workspace,
|
|
762
|
+
principal: row.dispatch_principal,
|
|
763
|
+
},
|
|
764
|
+
requester: row.requester,
|
|
765
|
+
action: row.action,
|
|
766
|
+
resourceKind: row.resource_kind,
|
|
767
|
+
resourceId: row.resource_id,
|
|
768
|
+
summary: row.summary,
|
|
769
|
+
diff: row.diff_text,
|
|
770
|
+
diffHash: row.diff_hash,
|
|
771
|
+
expiresAt: row.expires_at,
|
|
772
|
+
proposalVersion: row.proposal_version,
|
|
773
|
+
});
|
|
774
|
+
if (row.payload_hash !== expectedPayloadHash) {
|
|
775
|
+
throw new PolicyLedgerError('invalid-state', 'approval dispatch payload hash does not match proposal');
|
|
776
|
+
}
|
|
777
|
+
return Object.freeze({
|
|
778
|
+
proposalId: row.proposal_id,
|
|
779
|
+
sourceId: row.source_id,
|
|
780
|
+
bindingId: row.binding_id,
|
|
781
|
+
workspace: row.workspace,
|
|
782
|
+
principal: row.proposal_principal,
|
|
783
|
+
requester: row.requester,
|
|
784
|
+
action: row.action,
|
|
785
|
+
resource: Object.freeze({ kind: row.resource_kind, id: row.resource_id }),
|
|
786
|
+
summary: row.summary,
|
|
787
|
+
diff: row.diff_text,
|
|
788
|
+
diffHash: row.diff_hash,
|
|
789
|
+
payloadHash: row.payload_hash,
|
|
790
|
+
expiresAt: row.expires_at,
|
|
791
|
+
proposalVersion: row.proposal_version,
|
|
792
|
+
state: row.state,
|
|
793
|
+
createdAt: row.dispatch_created_at,
|
|
794
|
+
enqueuedAt: row.enqueued_at ?? undefined,
|
|
795
|
+
version: row.dispatch_version,
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
#dispatchResult(row, replayed) {
|
|
799
|
+
if (row.state !== 'enqueued' || row.enqueued_at === null) {
|
|
800
|
+
throw new PolicyLedgerError('invalid-state', 'approval dispatch is not enqueued');
|
|
801
|
+
}
|
|
802
|
+
return Object.freeze({
|
|
803
|
+
proposalId: row.proposal_id,
|
|
804
|
+
state: 'enqueued',
|
|
805
|
+
payloadHash: row.payload_hash,
|
|
806
|
+
enqueuedAt: row.enqueued_at,
|
|
807
|
+
version: row.dispatch_version,
|
|
808
|
+
replayed,
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
#proposalResult(proposal, replayed) {
|
|
812
|
+
return {
|
|
813
|
+
proposalId: proposal.id,
|
|
814
|
+
status: proposal.status,
|
|
815
|
+
diffHash: proposal.diff_hash,
|
|
816
|
+
expiresAt: proposal.expires_at,
|
|
817
|
+
version: proposal.version,
|
|
818
|
+
replayed,
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
appendAudit(input) {
|
|
822
|
+
requireText(input.actor, 'actor');
|
|
823
|
+
requireText(input.action, 'action');
|
|
824
|
+
requireText(input.resource.kind, 'resource.kind');
|
|
825
|
+
requireText(input.resource.id, 'resource.id');
|
|
826
|
+
requireText(input.outcome, 'outcome');
|
|
827
|
+
requireText(input.reasonCode, 'reasonCode');
|
|
828
|
+
const resourceHash = createHash('sha256').update(input.resource.id).digest('hex');
|
|
829
|
+
const details = redactAuditValue(input.details);
|
|
830
|
+
let detailsJson;
|
|
831
|
+
try {
|
|
832
|
+
detailsJson = JSON.stringify(details);
|
|
833
|
+
}
|
|
834
|
+
catch {
|
|
835
|
+
throw new PolicyLedgerError('invalid-input', 'audit details must be JSON serializable');
|
|
836
|
+
}
|
|
837
|
+
const eventId = randomUUID();
|
|
838
|
+
const occurredAt = this.#now();
|
|
839
|
+
const result = this.#database.prepare(`
|
|
840
|
+
INSERT INTO audit_events(
|
|
841
|
+
event_id, occurred_at, actor, action, resource_kind, resource_hash,
|
|
842
|
+
outcome, reason_code, details_json
|
|
843
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
844
|
+
`).run(eventId, occurredAt, input.actor, input.action, input.resource.kind, resourceHash, input.outcome, input.reasonCode, detailsJson);
|
|
845
|
+
return {
|
|
846
|
+
sequence: Number(result.lastInsertRowid),
|
|
847
|
+
eventId,
|
|
848
|
+
occurredAt,
|
|
849
|
+
actor: input.actor,
|
|
850
|
+
action: input.action,
|
|
851
|
+
resourceKind: input.resource.kind,
|
|
852
|
+
resourceHash,
|
|
853
|
+
outcome: input.outcome,
|
|
854
|
+
reasonCode: input.reasonCode,
|
|
855
|
+
details,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
queryAudit(options = {}) {
|
|
859
|
+
const afterSequence = options.afterSequence ?? 0;
|
|
860
|
+
const limit = options.limit ?? 50;
|
|
861
|
+
if (!Number.isSafeInteger(afterSequence) || afterSequence < 0) {
|
|
862
|
+
throw new PolicyLedgerError('invalid-input', 'afterSequence must be a non-negative safe integer');
|
|
863
|
+
}
|
|
864
|
+
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 100) {
|
|
865
|
+
throw new PolicyLedgerError('invalid-input', 'audit limit must be between 1 and 100');
|
|
866
|
+
}
|
|
867
|
+
const rows = this.#database.prepare(`
|
|
868
|
+
SELECT sequence, event_id, occurred_at, actor, action, resource_kind,
|
|
869
|
+
resource_hash, outcome, reason_code, details_json
|
|
870
|
+
FROM audit_events WHERE sequence > ? ORDER BY sequence ASC LIMIT ?
|
|
871
|
+
`).all(afterSequence, limit);
|
|
872
|
+
return rows.map(row => ({
|
|
873
|
+
sequence: row.sequence,
|
|
874
|
+
eventId: row.event_id,
|
|
875
|
+
occurredAt: row.occurred_at,
|
|
876
|
+
actor: row.actor,
|
|
877
|
+
action: row.action,
|
|
878
|
+
resourceKind: row.resource_kind,
|
|
879
|
+
resourceHash: row.resource_hash,
|
|
880
|
+
outcome: row.outcome,
|
|
881
|
+
reasonCode: row.reason_code,
|
|
882
|
+
details: JSON.parse(row.details_json),
|
|
883
|
+
}));
|
|
884
|
+
}
|
|
885
|
+
health() {
|
|
886
|
+
const row = this.#database.prepare('SELECT COALESCE(MAX(sequence), 0) AS sequence FROM audit_events')
|
|
887
|
+
.get();
|
|
888
|
+
return { lastAuditSequence: row.sequence };
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
//# sourceMappingURL=ledger.js.map
|