@aetheros/kernel-runtime 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -3
- package/src/agent-memory.js +4 -2
- package/src/audit-anchors.js +124 -0
- package/src/audit.js +2 -0
- package/src/authenticated-connectors.js +233 -3
- package/src/budgets.js +226 -0
- package/src/claims-conformance.js +83 -0
- package/src/claims.js +64 -0
- package/src/effect-journal.js +153 -1
- package/src/encrypted-state.js +63 -1
- package/src/external-identity.js +59 -0
- package/src/index.js +4 -1
- package/src/lock-inspection.js +39 -0
- package/src/process-identity.js +82 -0
- package/src/retry-capability.js +35 -0
- package/src/storage-conformance.js +81 -0
- package/src/storage.js +87 -0
- package/src/transactions.js +78 -8
package/src/budgets.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import {createHash} from 'node:crypto';
|
|
2
|
+
import {TransactionalStateStore} from './transactions.js';
|
|
3
|
+
import {canonicalJson} from './canonical-json.js';
|
|
4
|
+
|
|
5
|
+
const clone = value => structuredClone(value);
|
|
6
|
+
const id = value => {if (typeof value !== 'string' || !value.trim() || value.length > 256) throw new TypeError('Invalid budget identifier'); return value;};
|
|
7
|
+
const amount = (value, positive = false) => {
|
|
8
|
+
if (typeof value !== 'string' || !/^(0|[1-9][0-9]{0,77})$/.test(value) || (positive && value === '0')) throw new TypeError('Canonical integer amount required');
|
|
9
|
+
return BigInt(value);
|
|
10
|
+
};
|
|
11
|
+
const time = value => {if (!Number.isSafeInteger(value) || value < 0) throw new TypeError('Invalid budget time'); return value;};
|
|
12
|
+
const slot = (...parts) => createHash('sha256').update(JSON.stringify(parts)).digest('hex');
|
|
13
|
+
const policySnapshot = p => {
|
|
14
|
+
const {tenantId, subjectId, period, unit, scale, limit, startsAt, endsAt} = p ?? {};
|
|
15
|
+
[tenantId, subjectId, period, unit].forEach(id); amount(limit);
|
|
16
|
+
if (!Number.isInteger(scale) || scale < 0 || scale > 18 || time(startsAt) >= time(endsAt)) throw new TypeError('Invalid budget policy');
|
|
17
|
+
return {tenantId, subjectId, period, unit, scale, limit, startsAt, endsAt};
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const receiptSnapshot = value => {
|
|
21
|
+
canonicalJson(value);
|
|
22
|
+
const {source, receiptId, evidenceId, kind, final} = value ?? {};
|
|
23
|
+
[source, receiptId, evidenceId].forEach(id);
|
|
24
|
+
if (final !== true || !['billed', 'no_spend'].includes(kind)) throw new TypeError('Final usage provenance required');
|
|
25
|
+
const receipt = {source, receiptId, evidenceId, kind, final};
|
|
26
|
+
if (canonicalJson(receipt) !== canonicalJson(value)) throw new TypeError('Invalid usage receipt');
|
|
27
|
+
return receipt;
|
|
28
|
+
};
|
|
29
|
+
const estimateReceiptSnapshot = value => {
|
|
30
|
+
canonicalJson(value);
|
|
31
|
+
const {source,receiptId,evidenceId,kind,final} = value ?? {};
|
|
32
|
+
[source,receiptId,evidenceId].forEach(id);
|
|
33
|
+
const receipt = {source,receiptId,evidenceId,kind,final};
|
|
34
|
+
if (kind !== 'estimate' || final !== false || canonicalJson(receipt) !== canonicalJson(value)) throw new TypeError('Explicit non-billed estimate receipt required');
|
|
35
|
+
return receipt;
|
|
36
|
+
};
|
|
37
|
+
const terminal = r => r.status === 'settled' || r.status === 'released';
|
|
38
|
+
const totals = bucket => {
|
|
39
|
+
let reserved = 0n, spent = 0n, estimatedSpent = 0n;
|
|
40
|
+
for (const r of bucket.reservations) {
|
|
41
|
+
if (r.status === 'settled') spent += amount(r.actualAmount);
|
|
42
|
+
else if (r.status === 'estimated') estimatedSpent += amount(r.estimatedAmount);
|
|
43
|
+
else if (r.status !== 'released') reserved += amount(r.amount, true);
|
|
44
|
+
}
|
|
45
|
+
const remaining = amount(bucket.policy.limit) - reserved - spent - estimatedSpent;
|
|
46
|
+
return {reserved:reserved.toString(), spent:spent.toString(), estimatedSpent:estimatedSpent.toString(), available:(remaining < 0n ? 0n : remaining).toString(), deficit:(remaining < 0n ? -remaining : 0n).toString()};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Trusted-host primitive, not an authentication facade. Hosts authenticate and
|
|
50
|
+
* authorize tenant/subject policy and reservation access before calling it.
|
|
51
|
+
* Cooperating local processes must share one canonical dedicated file path.
|
|
52
|
+
* Busy/abandoned locks fail closed; no dispatch or lock recovery.
|
|
53
|
+
*/
|
|
54
|
+
export class FileBudgetStore {
|
|
55
|
+
#store;
|
|
56
|
+
#clock;
|
|
57
|
+
#verifyUsage;
|
|
58
|
+
#verifyEstimate;
|
|
59
|
+
constructor(path, {clock = Date.now, codec, verifyUsage, verifyEstimate} = {}) {
|
|
60
|
+
if (typeof clock !== 'function') throw new TypeError('Budget clock required');
|
|
61
|
+
if (verifyUsage !== undefined && typeof verifyUsage !== 'function') throw new TypeError('Usage verifier must be a function');
|
|
62
|
+
if (verifyEstimate !== undefined && typeof verifyEstimate !== 'function') throw new TypeError('Estimate verifier must be a function');
|
|
63
|
+
this.#verifyEstimate = verifyEstimate;
|
|
64
|
+
this.#verifyUsage = verifyUsage;
|
|
65
|
+
this.#clock = clock; this.#store = new TransactionalStateStore(path, {codec});
|
|
66
|
+
}
|
|
67
|
+
#validate(state) {
|
|
68
|
+
const receipts = new Set();
|
|
69
|
+
for (const [key, bucket] of Object.entries(state.tenants)) {
|
|
70
|
+
if (bucket?.kind !== 'budget-v1' || !Array.isArray(bucket.reservations)) throw new Error('Invalid budget state');
|
|
71
|
+
const p = policySnapshot(bucket.policy);
|
|
72
|
+
if (key !== slot(p.tenantId, p.subjectId, p.period) || canonicalJson(p) !== canonicalJson(bucket.policy)) throw new Error('Invalid budget policy state');
|
|
73
|
+
const keys = new Set();
|
|
74
|
+
for (const r of bucket.reservations) {
|
|
75
|
+
id(r.runId);
|
|
76
|
+
if (r.tenantId !== p.tenantId || r.subjectId !== p.subjectId || r.period !== p.period || r.unit !== p.unit || r.scale !== p.scale || keys.has(r.runId) || r.reservationId !== slot(p.tenantId, r.runId) || !['reserved','uncertain','settled','released','estimated'].includes(r.status) || time(r.reservedAt) < p.startsAt || r.reservedAt >= p.endsAt) throw new Error('Invalid budget reservation state');
|
|
77
|
+
keys.add(r.runId); amount(r.amount, true);
|
|
78
|
+
const baseKeys = ['tenantId','subjectId','period','unit','scale','reservationId','runId','amount','status','reservedAt'];
|
|
79
|
+
if (Object.hasOwn(r, 'interruptedAt')) {
|
|
80
|
+
if (time(r.interruptedAt) < r.reservedAt || r.status === 'reserved') throw new Error('Invalid interruption');
|
|
81
|
+
baseKeys.push('interruptedAt');
|
|
82
|
+
}
|
|
83
|
+
if (r.status === 'uncertain' && !Object.hasOwn(r, 'interruptedAt')) throw new Error('Missing interruption');
|
|
84
|
+
if (r.status === 'estimated' || Object.hasOwn(r, 'estimatedAmount')) {
|
|
85
|
+
const receipt = estimateReceiptSnapshot(r.estimateReceipt);
|
|
86
|
+
amount(r.estimatedAmount);
|
|
87
|
+
if (!['estimated','settled','released'].includes(r.status) || time(r.estimatedAt) < (r.interruptedAt ?? r.reservedAt)) throw new Error('Invalid estimate state');
|
|
88
|
+
const receiptKey = slot(r.tenantId,receipt.source,receipt.receiptId);
|
|
89
|
+
if (receipts.has(receiptKey)) throw new Error('Duplicate estimate receipt');
|
|
90
|
+
receipts.add(receiptKey);
|
|
91
|
+
baseKeys.push('estimatedAmount','estimateReceipt','estimatedAt');
|
|
92
|
+
}
|
|
93
|
+
if (terminal(r)) {
|
|
94
|
+
const receipt = receiptSnapshot(r.receipt);
|
|
95
|
+
amount(r.actualAmount);
|
|
96
|
+
if (time(r.finalizedAt) < Math.max(r.estimatedAt ?? 0, r.interruptedAt ?? r.reservedAt) || (r.status === 'settled' ? receipt.kind !== 'billed' : receipt.kind !== 'no_spend' || r.actualAmount !== '0')) throw new Error('Invalid final usage');
|
|
97
|
+
const receiptKey = slot(r.tenantId, receipt.source, receipt.receiptId);
|
|
98
|
+
if (receipts.has(receiptKey)) throw new Error('Duplicate usage receipt');
|
|
99
|
+
receipts.add(receiptKey);
|
|
100
|
+
baseKeys.push('receipt','actualAmount','finalizedAt');
|
|
101
|
+
}
|
|
102
|
+
if (Object.keys(r).length !== baseKeys.length || baseKeys.some(k => !Object.hasOwn(r,k))) throw new Error('Invalid reservation fields');
|
|
103
|
+
}
|
|
104
|
+
// A final verified bill may exceed the limit. Never discard real spend.
|
|
105
|
+
if (BigInt(totals(bucket).reserved) > amount(p.limit)) throw new Error('Invalid held budget total');
|
|
106
|
+
}
|
|
107
|
+
// Stable keys are unique across all subjects and periods within a tenant.
|
|
108
|
+
const keys = new Set(); const policies = [];
|
|
109
|
+
for (const b of Object.values(state.tenants)) {
|
|
110
|
+
const p = b.policy;
|
|
111
|
+
for (const prior of policies) if (prior.tenantId === p.tenantId && prior.subjectId === p.subjectId && prior.startsAt < p.endsAt && p.startsAt < prior.endsAt) throw new Error('Overlapping budget periods');
|
|
112
|
+
policies.push(p);
|
|
113
|
+
for (const r of b.reservations) {if (keys.has(r.reservationId)) throw new Error('Duplicate reservation key'); keys.add(r.reservationId);}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async #mutate(update) {
|
|
117
|
+
let result;
|
|
118
|
+
await this.#store.exclusive(() => this.#store.transaction(async state => {
|
|
119
|
+
this.#validate(state); result = await update(state); this.#validate(state); return state;
|
|
120
|
+
}));
|
|
121
|
+
return clone(result);
|
|
122
|
+
}
|
|
123
|
+
async setPolicy(input) {
|
|
124
|
+
const p = policySnapshot(input);
|
|
125
|
+
return this.#mutate(state => {
|
|
126
|
+
const key = slot(p.tenantId, p.subjectId, p.period), prior = state.tenants[key];
|
|
127
|
+
if (prior && canonicalJson(prior.policy) !== canonicalJson(p)) throw new Error('BUDGET_POLICY_CONFLICT');
|
|
128
|
+
if (!prior) state.tenants[key] = {kind:'budget-v1', policy:p, reservations:[]};
|
|
129
|
+
return p;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
async getBudget({tenantId, subjectId, period} = {}) {
|
|
133
|
+
[tenantId, subjectId, period].forEach(id);
|
|
134
|
+
const state = await this.#store.load(); this.#validate(state);
|
|
135
|
+
const bucket = state.tenants[slot(tenantId, subjectId, period)];
|
|
136
|
+
if (!bucket) return null;
|
|
137
|
+
return {...clone(bucket.policy), ...totals(bucket), reservations:clone(bucket.reservations)};
|
|
138
|
+
}
|
|
139
|
+
async reserveBudget({tenantId, subjectId, period, amount: requested, runId} = {}) {
|
|
140
|
+
[tenantId, subjectId, period, runId].forEach(id); const value = amount(requested, true);
|
|
141
|
+
// Snapshot all caller values before waiting for the transaction queue.
|
|
142
|
+
return this.#mutate(state => {
|
|
143
|
+
const bucket = state.tenants[slot(tenantId, subjectId, period)];
|
|
144
|
+
if (!bucket) throw new Error('BUDGET_POLICY_REQUIRED');
|
|
145
|
+
for (const b of Object.values(state.tenants)) {
|
|
146
|
+
if (b.policy.tenantId !== tenantId) continue;
|
|
147
|
+
const prior = b.reservations.find(r => r.runId === runId);
|
|
148
|
+
if (prior) {
|
|
149
|
+
if (b !== bucket || prior.amount !== requested) throw new Error('BUDGET_KEY_CONFLICT');
|
|
150
|
+
return prior; // Old-period replay returns history, never new capacity.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const now = time(this.#clock()), p = bucket.policy;
|
|
154
|
+
if (now < p.startsAt || now >= p.endsAt) throw new Error('BUDGET_PERIOD_INACTIVE');
|
|
155
|
+
const usage = totals(bucket);
|
|
156
|
+
if (BigInt(usage.deficit) > 0n || value > BigInt(usage.available)) throw new Error('BUDGET_LIMIT_EXCEEDED');
|
|
157
|
+
const r = {tenantId, subjectId, period, unit:p.unit, scale:p.scale, reservationId:slot(tenantId, runId), runId, amount:requested, status:'reserved', reservedAt:now};
|
|
158
|
+
bucket.reservations.push(r); return r;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
#find(state, {tenantId, subjectId, period, reservationId}) {
|
|
162
|
+
const bucket = state.tenants[slot(tenantId, subjectId, period)];
|
|
163
|
+
const r = bucket?.reservations.find(r => r.reservationId === reservationId);
|
|
164
|
+
if (!r) throw new Error('BUDGET_RESERVATION_NOT_FOUND');
|
|
165
|
+
return r;
|
|
166
|
+
}
|
|
167
|
+
/** Retire an interrupted run to uncertainty. Never frees capacity or dispatches. */
|
|
168
|
+
async recoverBudget({tenantId, subjectId, period, reservationId} = {}) {
|
|
169
|
+
const binding = {tenantId, subjectId, period, reservationId};
|
|
170
|
+
Object.values(binding).forEach(id);
|
|
171
|
+
return this.#mutate(state => {
|
|
172
|
+
const r = this.#find(state, binding);
|
|
173
|
+
if (r.status === 'reserved') {
|
|
174
|
+
const now = time(this.#clock());
|
|
175
|
+
if (now < r.reservedAt) throw new Error('BUDGET_CLOCK_REGRESSED');
|
|
176
|
+
r.status = 'uncertain'; r.interruptedAt = now;
|
|
177
|
+
}
|
|
178
|
+
return r;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/** Opt-in host estimate accounting. Never final bills or definitive no-spend. */
|
|
182
|
+
async estimateBudget({tenantId,subjectId,period,reservationId,estimatedAmount,receipt:supplied} = {}) {
|
|
183
|
+
const binding = {tenantId,subjectId,period,reservationId};Object.values(binding).forEach(id);
|
|
184
|
+
amount(estimatedAmount);const receipt = estimateReceiptSnapshot(supplied);
|
|
185
|
+
return this.#mutate(async state => {
|
|
186
|
+
const r = this.#find(state,binding);
|
|
187
|
+
if (Object.hasOwn(r,'estimatedAmount')) {
|
|
188
|
+
if (r.estimatedAmount !== estimatedAmount || canonicalJson(r.estimateReceipt) !== canonicalJson(receipt)) throw new Error('BUDGET_ESTIMATE_CONFLICT');
|
|
189
|
+
return r;
|
|
190
|
+
}
|
|
191
|
+
if (terminal(r)) throw new Error('BUDGET_FINALIZATION_CONFLICT');
|
|
192
|
+
for (const b of Object.values(state.tenants)) if (b.policy.tenantId === tenantId && b.reservations.some(other => [other.receipt,other.estimateReceipt].some(value => value?.source === receipt.source && value.receiptId === receipt.receiptId))) throw new Error('BUDGET_RECEIPT_CONFLICT');
|
|
193
|
+
if (!this.#verifyEstimate || await this.#verifyEstimate(clone({reservation:r,estimatedAmount,receipt})) !== true) throw new Error('BUDGET_ESTIMATE_UNVERIFIED');
|
|
194
|
+
const now = time(this.#clock());if (now < (r.interruptedAt ?? r.reservedAt)) throw new Error('BUDGET_CLOCK_REGRESSED');
|
|
195
|
+
Object.assign(r,{status:'estimated',estimatedAmount,estimateReceipt:receipt,estimatedAt:now});return r;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
async settleBudget(input) {return this.#finalize(input, 'settled');}
|
|
199
|
+
async releaseBudget(input) {return this.#finalize(input, 'released');}
|
|
200
|
+
async #finalize({tenantId, subjectId, period, reservationId, actualAmount, receipt: supplied} = {}, status) {
|
|
201
|
+
const binding = {tenantId, subjectId, period, reservationId};
|
|
202
|
+
Object.values(binding).forEach(id);
|
|
203
|
+
amount(actualAmount);
|
|
204
|
+
const receipt = receiptSnapshot(supplied);
|
|
205
|
+
if (status === 'settled' ? receipt.kind !== 'billed' : receipt.kind !== 'no_spend' || actualAmount !== '0') throw new Error('BUDGET_PROVENANCE_REQUIRED');
|
|
206
|
+
return this.#mutate(async state => {
|
|
207
|
+
const r = this.#find(state, binding);
|
|
208
|
+
if (terminal(r)) {
|
|
209
|
+
if (r.status !== status || r.actualAmount !== actualAmount || canonicalJson(r.receipt) !== canonicalJson(receipt)) throw new Error('BUDGET_FINALIZATION_CONFLICT');
|
|
210
|
+
return r; // Durable replay needs no remote lookup and never changes capacity.
|
|
211
|
+
}
|
|
212
|
+
for (const b of Object.values(state.tenants)) {
|
|
213
|
+
if (b.policy.tenantId !== tenantId) continue;
|
|
214
|
+
if (b.reservations.some(other => [other.receipt,other.estimateReceipt].some(value => value?.source === receipt.source && value.receiptId === receipt.receiptId))) throw new Error('BUDGET_RECEIPT_CONFLICT');
|
|
215
|
+
}
|
|
216
|
+
// The host must prove complete final usage and fence every potential spender.
|
|
217
|
+
// A caller-supplied declaration/estimate is insufficient. No verifier = hold.
|
|
218
|
+
if (!this.#verifyUsage || await this.#verifyUsage(clone({action:status === 'settled' ? 'settle' : 'release', reservation:r, actualAmount, receipt})) !== true) throw new Error('BUDGET_USAGE_UNVERIFIED');
|
|
219
|
+
const now = time(this.#clock());
|
|
220
|
+
if (now < Math.max(r.estimatedAt ?? 0, r.interruptedAt ?? r.reservedAt)) throw new Error('BUDGET_CLOCK_REGRESSED');
|
|
221
|
+
Object.assign(r, {status, actualAmount, receipt, finalizedAt:now});
|
|
222
|
+
return r;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {strict as assert} from 'node:assert';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Conformance cases for any claim backend. `make()` returns {backend, advance(ms)}: a fresh backend and a way to move ITS clock
|
|
5
|
+
* forward (a real backend adapter can sleep, or drive a fake clock on the server side).
|
|
6
|
+
* Use: for (const c of claimConformanceCases(make)) test(c.name, c.run);
|
|
7
|
+
*/
|
|
8
|
+
export function claimConformanceCases(make) {
|
|
9
|
+
const ttl = 1000;
|
|
10
|
+
return [
|
|
11
|
+
{name: 'acquire gives a fencing token; a second worker is refused while the claim is live', run: async () => {
|
|
12
|
+
const {backend} = make();
|
|
13
|
+
const a = await backend.acquire({key: 'k', owner: 'a', ttlMs: ttl});
|
|
14
|
+
assert.equal(a.ok, true); assert.ok(Number.isSafeInteger(a.token) && a.token >= 1);
|
|
15
|
+
const b = await backend.acquire({key: 'k', owner: 'b', ttlMs: ttl});
|
|
16
|
+
assert.deepEqual([b.ok, b.reason, b.heldBy], [false, 'held', 'a']);
|
|
17
|
+
assert.equal((await backend.acquire({key: 'other', owner: 'b', ttlMs: ttl})).ok, true, 'keys are independent');
|
|
18
|
+
assert.equal((await backend.get('k')).owner, 'a');
|
|
19
|
+
}},
|
|
20
|
+
{name: 'a claim that expires can be taken, and the new token is larger', run: async () => {
|
|
21
|
+
const {backend, advance} = make();
|
|
22
|
+
const a = await backend.acquire({key: 'k', owner: 'a', ttlMs: ttl});
|
|
23
|
+
advance(ttl + 1);
|
|
24
|
+
assert.equal(await backend.get('k'), null);
|
|
25
|
+
const b = await backend.acquire({key: 'k', owner: 'b', ttlMs: ttl});
|
|
26
|
+
assert.equal(b.ok, true); assert.ok(b.token > a.token);
|
|
27
|
+
}},
|
|
28
|
+
{name: 'a stale worker (its claim expired and was taken over) can neither renew, release nor pass validation', run: async () => {
|
|
29
|
+
const {backend, advance} = make();
|
|
30
|
+
const a = await backend.acquire({key: 'k', owner: 'a', ttlMs: ttl});
|
|
31
|
+
advance(ttl + 1);
|
|
32
|
+
const b = await backend.acquire({key: 'k', owner: 'b', ttlMs: ttl});
|
|
33
|
+
assert.deepEqual(await backend.renew({key: 'k', owner: 'a', token: a.token, ttlMs: ttl}), {ok: false, reason: 'stale'});
|
|
34
|
+
assert.deepEqual(await backend.release({key: 'k', owner: 'a', token: a.token}), {ok: false, reason: 'stale'});
|
|
35
|
+
assert.equal((await backend.validate({key: 'k', token: a.token})).ok, false);
|
|
36
|
+
assert.equal((await backend.validate({key: 'k', token: b.token})).ok, true);
|
|
37
|
+
assert.equal((await backend.get('k')).owner, 'b', 'the new holder is untouched');
|
|
38
|
+
}},
|
|
39
|
+
{name: 'a partitioned worker that comes back late cannot renew an expired claim even if nobody took it', run: async () => {
|
|
40
|
+
const {backend, advance} = make();
|
|
41
|
+
const a = await backend.acquire({key: 'k', owner: 'a', ttlMs: ttl});
|
|
42
|
+
advance(ttl + 1);
|
|
43
|
+
const late = await backend.renew({key: 'k', owner: 'a', token: a.token, ttlMs: ttl});
|
|
44
|
+
assert.equal(late.ok, false); assert.ok(['expired', 'stale', 'not-held'].includes(late.reason));
|
|
45
|
+
assert.equal((await backend.validate({key: 'k', token: a.token})).ok, false);
|
|
46
|
+
}},
|
|
47
|
+
{name: 'renew extends the claim; release frees it; tokens keep growing across releases', run: async () => {
|
|
48
|
+
const {backend, advance} = make();
|
|
49
|
+
const a = await backend.acquire({key: 'k', owner: 'a', ttlMs: ttl});
|
|
50
|
+
advance(ttl - 100);
|
|
51
|
+
assert.equal((await backend.renew({key: 'k', owner: 'a', token: a.token, ttlMs: ttl})).ok, true);
|
|
52
|
+
advance(ttl - 100);
|
|
53
|
+
assert.equal((await backend.get('k')).owner, 'a', 'still held thanks to the renewal');
|
|
54
|
+
assert.equal((await backend.release({key: 'k', owner: 'a', token: a.token})).ok, true);
|
|
55
|
+
assert.equal(await backend.get('k'), null);
|
|
56
|
+
const again = await backend.acquire({key: 'k', owner: 'a', ttlMs: ttl});
|
|
57
|
+
assert.ok(again.token > a.token, 'a token is never reused');
|
|
58
|
+
assert.equal((await backend.release({key: 'k', owner: 'a', token: a.token})).ok, false, 'the old token cannot release the new claim');
|
|
59
|
+
assert.equal((await backend.get('k')).token, again.token);
|
|
60
|
+
}},
|
|
61
|
+
{name: 'only the holder with the right token can renew or release; nothing held is not-held', run: async () => {
|
|
62
|
+
const {backend} = make();
|
|
63
|
+
assert.deepEqual(await backend.release({key: 'k', owner: 'a', token: 1}), {ok: false, reason: 'not-held'});
|
|
64
|
+
assert.deepEqual(await backend.renew({key: 'k', owner: 'a', token: 1, ttlMs: ttl}), {ok: false, reason: 'not-held'});
|
|
65
|
+
const a = await backend.acquire({key: 'k', owner: 'a', ttlMs: ttl});
|
|
66
|
+
assert.equal((await backend.renew({key: 'k', owner: 'b', token: a.token, ttlMs: ttl})).reason, 'stale');
|
|
67
|
+
assert.equal((await backend.renew({key: 'k', owner: 'a', token: a.token + 1, ttlMs: ttl})).reason, 'stale');
|
|
68
|
+
assert.equal((await backend.release({key: 'k', owner: 'b', token: a.token})).reason, 'stale');
|
|
69
|
+
assert.equal((await backend.get('k')).owner, 'a');
|
|
70
|
+
}},
|
|
71
|
+
{name: 'concurrent acquires: exactly one wins', run: async () => {
|
|
72
|
+
const {backend} = make();
|
|
73
|
+
const results = await Promise.all(Array.from({length: 8}, (_, i) => backend.acquire({key: 'k', owner: 'w' + i, ttlMs: ttl})));
|
|
74
|
+
assert.equal(results.filter(r => r.ok).length, 1);
|
|
75
|
+
}},
|
|
76
|
+
{name: 'bad input is a programming error, not a refusal', run: async () => {
|
|
77
|
+
const {backend} = make();
|
|
78
|
+
await assert.rejects(backend.acquire({key: '', owner: 'a', ttlMs: ttl}), TypeError);
|
|
79
|
+
await assert.rejects(backend.acquire({key: 'k', owner: '', ttlMs: ttl}), TypeError);
|
|
80
|
+
await assert.rejects(backend.acquire({key: 'k', owner: 'a', ttlMs: 0}), TypeError);
|
|
81
|
+
}}
|
|
82
|
+
];
|
|
83
|
+
}
|
package/src/claims.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claim backend contract (A5): a lease on a named resource that several workers, possibly on several machines, compete for.
|
|
3
|
+
* The contract is what a shared backend (a database, a lock service) must provide; this file defines it, ships an in-memory
|
|
4
|
+
* reference and a conformance suite (claims-conformance.js). Which shared backend is used is a deployment decision.
|
|
5
|
+
*
|
|
6
|
+
* Rules every backend must keep:
|
|
7
|
+
* - acquire succeeds only when nobody holds a live (unexpired) claim on the key; it returns a fencing token.
|
|
8
|
+
* - fencing tokens only grow, per key, across releases and expiries. A token is never reused.
|
|
9
|
+
* - renew and release only work for the current holder with the current token; a worker whose claim expired and was taken
|
|
10
|
+
* over is "stale" and is refused. Expiry is judged by the backend's own clock, never by the worker's.
|
|
11
|
+
* - validate(key, token) tells a protected resource whether a token is still the current one (use it before every write).
|
|
12
|
+
* Methods are async so a network backend can implement them; they never throw for refusals, they return {ok: false, reason}.
|
|
13
|
+
*/
|
|
14
|
+
export const CLAIM_REASONS = Object.freeze(['held', 'not-held', 'stale', 'expired']);
|
|
15
|
+
|
|
16
|
+
const check = ({key, owner, ttlMs}) => {
|
|
17
|
+
if (typeof key !== 'string' || !key) throw new TypeError('Claim key required');
|
|
18
|
+
if (typeof owner !== 'string' || !owner) throw new TypeError('Claim owner required');
|
|
19
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new TypeError('Claim ttlMs must be a positive number');
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** In-memory reference backend. `now` is the backend's clock (injectable for tests). */
|
|
23
|
+
export function createMemoryClaimBackend({now = () => Date.now()} = {}) {
|
|
24
|
+
const claims = new Map(); // key -> {owner, token, expiresAt} (live or expired)
|
|
25
|
+
const tokens = new Map(); // key -> highest token ever issued
|
|
26
|
+
const live = key => { const c = claims.get(key); return c && c.expiresAt > now() ? c : null; };
|
|
27
|
+
return {
|
|
28
|
+
async acquire({key, owner, ttlMs}) {
|
|
29
|
+
check({key, owner, ttlMs});
|
|
30
|
+
const current = live(key);
|
|
31
|
+
if (current) return {ok: false, reason: 'held', heldBy: current.owner, expiresAt: current.expiresAt};
|
|
32
|
+
const token = (tokens.get(key) ?? 0) + 1;
|
|
33
|
+
tokens.set(key, token);
|
|
34
|
+
const claim = {owner, token, expiresAt: now() + ttlMs};
|
|
35
|
+
claims.set(key, claim);
|
|
36
|
+
return {ok: true, token, expiresAt: claim.expiresAt};
|
|
37
|
+
},
|
|
38
|
+
async renew({key, owner, token, ttlMs}) {
|
|
39
|
+
check({key, owner, ttlMs});
|
|
40
|
+
const claim = claims.get(key);
|
|
41
|
+
if (!claim) return {ok: false, reason: 'not-held'};
|
|
42
|
+
if (claim.owner !== owner || claim.token !== token) return {ok: false, reason: 'stale'};
|
|
43
|
+
if (claim.expiresAt <= now()) return {ok: false, reason: 'expired'}; // it ran out: renewing late is not allowed
|
|
44
|
+
claim.expiresAt = now() + ttlMs;
|
|
45
|
+
return {ok: true, token, expiresAt: claim.expiresAt};
|
|
46
|
+
},
|
|
47
|
+
async release({key, owner, token}) {
|
|
48
|
+
const claim = claims.get(key);
|
|
49
|
+
if (!claim) return {ok: false, reason: 'not-held'};
|
|
50
|
+
if (claim.owner !== owner || claim.token !== token) return {ok: false, reason: 'stale'};
|
|
51
|
+
claims.delete(key);
|
|
52
|
+
return {ok: true};
|
|
53
|
+
},
|
|
54
|
+
/** Is this token the current, live claim on the key? For a protected resource to check before it accepts a write. */
|
|
55
|
+
async validate({key, token}) {
|
|
56
|
+
const claim = live(key);
|
|
57
|
+
return claim && claim.token === token ? {ok: true, owner: claim.owner} : {ok: false, reason: claim ? 'stale' : 'expired'};
|
|
58
|
+
},
|
|
59
|
+
async get(key) {
|
|
60
|
+
const claim = live(key);
|
|
61
|
+
return claim ? {owner: claim.owner, token: claim.token, expiresAt: claim.expiresAt} : null;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
package/src/effect-journal.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {createHash} from 'node:crypto';
|
|
2
2
|
import {TransactionalStateStore} from './transactions.js';
|
|
3
|
+
import {TokenAuthority} from './index.js';
|
|
3
4
|
import {canonicalJson} from './canonical-json.js';
|
|
5
|
+
import {snapshotRetryCapability, assertRetryCapabilityTime} from './retry-capability.js';
|
|
4
6
|
import {snapshotEffectBinding, assertEffectBinding} from './effect-binding.js';
|
|
5
7
|
|
|
6
8
|
const required = (value, label) => {
|
|
@@ -55,6 +57,26 @@ export class FileEffectJournal {
|
|
|
55
57
|
const state = await this.#store.load();
|
|
56
58
|
return clone(state.tenants[slot(tenantId, effectKey)] ?? null);
|
|
57
59
|
}
|
|
60
|
+
// Trusted-host raw snapshot; never expose directly to clients.
|
|
61
|
+
async snapshot() { return clone((await this.#store.load()).tenants); }
|
|
62
|
+
// Logical tombstone only: preserve the complete replay record indefinitely.
|
|
63
|
+
async retain({expectedEntry, reviewId, decisionId, subject, beforeCommit} = {}) {
|
|
64
|
+
const expected = JSON.parse(canonical(expectedEntry));
|
|
65
|
+
if (!expected || expected.status !== 'confirmed' || expected.receipt === undefined ||
|
|
66
|
+
(expected.retry && expected.retry.status !== 'confirmed')) throw new TypeError('Confirmed effect required');
|
|
67
|
+
for (const [name, value] of Object.entries({tenantId:expected.tenantId, effectKey:expected.effectKey, reviewId, decisionId, subject})) required(value, name);
|
|
68
|
+
if (typeof beforeCommit !== 'function') throw new TypeError('Retention guard required');
|
|
69
|
+
const references = [expected.binding, expected.reconciliation, expected.claimRecovery,
|
|
70
|
+
expected.retry?.authorization, expected.retry?.execution, expected.retry?.recovery].filter(Boolean);
|
|
71
|
+
if (references.some(reference => reference.reviewId === reviewId || reference.decisionId === decisionId)) throw new Error('Fresh retention Review required');
|
|
72
|
+
return this.#mutate(async state => {
|
|
73
|
+
const key = slot(expected.tenantId, expected.effectKey), prior = state.tenants[key];
|
|
74
|
+
if (!prior || canonical(prior) !== canonical(expected)) throw new Error('EFFECT_REVIEW_CONFLICT');
|
|
75
|
+
if (await beforeCommit() !== true) throw new Error('EFFECT_RETENTION_DENIED');
|
|
76
|
+
if (!prior.retention) state.tenants[key] = {...prior, retention:{version:1, reviewId, decisionId, subject, reviewedDigest:digest(expected), retainedAt:Date.now()}};
|
|
77
|
+
return clone(state.tenants[key]);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
58
80
|
async markDispatched({tenantId, effectKey, binding} = {}) {
|
|
59
81
|
return this.#transition(tenantId, effectKey, 'dispatched', undefined, binding);
|
|
60
82
|
}
|
|
@@ -82,7 +104,85 @@ export class FileEffectJournal {
|
|
|
82
104
|
if (!prior || canonical(prior) !== canonical(expected)) throw new Error('EFFECT_REVIEW_CONFLICT');
|
|
83
105
|
if (await beforeCommit() !== true) throw new Error('EFFECT_RECONCILIATION_DENIED');
|
|
84
106
|
const next = {...prior, status:'confirmed', receipt:safeReceipt,
|
|
85
|
-
reconciliation:{decisionId, subject}
|
|
107
|
+
reconciliation:{decisionId, subject},
|
|
108
|
+
...(prior.retry && {retry:{...prior.retry, status:'confirmed', completedAt:Date.now()}})};
|
|
109
|
+
state.tenants[key] = next;
|
|
110
|
+
return clone(next);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
// Trusted-host reservation primitive. The authenticated facade supplies policy/Review guards.
|
|
114
|
+
// Reservation alone never dispatches; a reserved effect remains uncertain.
|
|
115
|
+
async reserveRetryAttempt({expectedEntry, input, capability, reviewId, decisionId, subject, attemptId, beforeCommit} = {}) {
|
|
116
|
+
const expected = JSON.parse(canonical(expectedEntry));
|
|
117
|
+
if (!expected || expected.kind !== 'idempotent-write' ||
|
|
118
|
+
!['dispatched', 'reconciliation_required'].includes(expected.status) ||
|
|
119
|
+
!snapshotEffectBinding(expected.binding) || Object.hasOwn(expected, 'retry')) throw new TypeError('Unreserved bound uncertain idempotent effect required');
|
|
120
|
+
for (const [name, value] of Object.entries({tenantId:expected.tenantId, effectKey:expected.effectKey, reviewId, decisionId, subject, attemptId})) required(value, name);
|
|
121
|
+
if (reviewId === expected.binding.reviewId || decisionId === expected.binding.decisionId || typeof beforeCommit !== 'function') throw new TypeError('Fresh retry Review and commit guard required');
|
|
122
|
+
const contract = snapshotRetryCapability(capability, expected, input);
|
|
123
|
+
return this.#mutate(async state => {
|
|
124
|
+
const key = slot(expected.tenantId, expected.effectKey), prior = state.tenants[key];
|
|
125
|
+
if (!prior || canonical(prior) !== canonical(expected)) throw new Error('EFFECT_REVIEW_CONFLICT');
|
|
126
|
+
if (await beforeCommit() !== true) throw new Error('EFFECT_RETRY_DENIED');
|
|
127
|
+
assertRetryCapabilityTime(contract);
|
|
128
|
+
const next = {...prior, retry:{version:1, id:attemptId, number:1, status:'reserved',
|
|
129
|
+
authorization:{action:'reserve-retry', reviewId, decisionId, subject,
|
|
130
|
+
expectedEntryDigest:digest(expected), capability:contract}, reservedAt:Date.now()}};
|
|
131
|
+
state.tenants[key] = next;
|
|
132
|
+
return clone(next);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
// Trusted-host one-shot transitions. No resume, release, reuse or second attempt.
|
|
136
|
+
async transitionRetryAttempt({expectedEntry, target, input, reviewId, decisionId, subject, receipt, beforeCommit} = {}) {
|
|
137
|
+
const expected = JSON.parse(canonical(expectedEntry));
|
|
138
|
+
if (!expected || expected.kind !== 'idempotent-write' ||
|
|
139
|
+
!['dispatched', 'reconciliation_required'].includes(expected.status) ||
|
|
140
|
+
!snapshotEffectBinding(expected.binding) || expected.retry?.version !== 1 || expected.retry.number !== 1 ||
|
|
141
|
+
!['dispatched', 'confirmed', 'reconciliation_required'].includes(target) || typeof beforeCommit !== 'function') throw new TypeError('Bound retry transition required');
|
|
142
|
+
const retry = expected.retry;
|
|
143
|
+
required(retry.id, 'attemptId');
|
|
144
|
+
if (target === 'dispatched') {
|
|
145
|
+
if (retry.status !== 'reserved') throw new Error('EFFECT_REPLAY_BLOCKED');
|
|
146
|
+
for (const [name, value] of Object.entries({reviewId, decisionId, subject})) required(value, name);
|
|
147
|
+
if ([expected.binding.reviewId, retry.authorization.reviewId].includes(reviewId) ||
|
|
148
|
+
[expected.binding.decisionId, retry.authorization.decisionId].includes(decisionId)) throw new Error('Fresh execution Review required');
|
|
149
|
+
} else if (retry.status !== 'dispatched') throw new Error('EFFECT_REPLAY_BLOCKED');
|
|
150
|
+
const contract = target === 'dispatched' ? snapshotRetryCapability(retry.authorization.capability, expected, input) : undefined;
|
|
151
|
+
const safeReceipt = target === 'confirmed' ? JSON.parse(canonical(receipt)) : undefined;
|
|
152
|
+
return this.#mutate(async state => {
|
|
153
|
+
const key = slot(expected.tenantId, expected.effectKey), prior = state.tenants[key];
|
|
154
|
+
if (!prior || canonical(prior) !== canonical(expected)) throw new Error('EFFECT_REVIEW_CONFLICT');
|
|
155
|
+
if (await beforeCommit() !== true) throw new Error('EFFECT_RETRY_DENIED');
|
|
156
|
+
if (contract) assertRetryCapabilityTime(contract);
|
|
157
|
+
const next = {...prior, status:target === 'confirmed' ? 'confirmed' : prior.status,
|
|
158
|
+
...(target === 'confirmed' && {receipt:safeReceipt}),
|
|
159
|
+
retry:{...prior.retry, status:target,
|
|
160
|
+
...(target === 'dispatched' ? {dispatchedAt:Date.now(), execution:{action:'execute-retry', reviewId, decisionId, subject}} : {completedAt:Date.now()})}};
|
|
161
|
+
state.tenants[key] = next;
|
|
162
|
+
return clone(next);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
// Retire an interrupted attempt, never reset it. Whole-record CAS fences old
|
|
166
|
+
// reservations and late completions; it cannot cancel already admitted remote work.
|
|
167
|
+
async recoverRetryAttempt({expectedEntry, reviewId, decisionId, subject, beforeCommit} = {}) {
|
|
168
|
+
const expected = JSON.parse(canonical(expectedEntry));
|
|
169
|
+
if (!expected || expected.kind !== 'idempotent-write' ||
|
|
170
|
+
!['dispatched', 'reconciliation_required'].includes(expected.status) ||
|
|
171
|
+
!snapshotEffectBinding(expected.binding) || expected.retry?.version !== 1 || expected.retry.number !== 1 ||
|
|
172
|
+
!['reserved', 'dispatched', 'reconciliation_required'].includes(expected.retry.status) ||
|
|
173
|
+
Object.hasOwn(expected.retry, 'recovery') || typeof beforeCommit !== 'function') throw new TypeError('Interrupted bound retry required');
|
|
174
|
+
const retry = expected.retry;
|
|
175
|
+
for (const [name, value] of Object.entries({tenantId:expected.tenantId, effectKey:expected.effectKey, attemptId:retry.id, reviewId, decisionId, subject})) required(value, name);
|
|
176
|
+
if ([expected.binding.reviewId, retry.authorization?.reviewId, retry.execution?.reviewId].includes(reviewId) ||
|
|
177
|
+
[expected.binding.decisionId, retry.authorization?.decisionId, retry.execution?.decisionId].includes(decisionId)) throw new Error('Fresh recovery Review required');
|
|
178
|
+
return this.#mutate(async state => {
|
|
179
|
+
const key = slot(expected.tenantId, expected.effectKey), prior = state.tenants[key];
|
|
180
|
+
if (!prior || canonical(prior) !== canonical(expected)) throw new Error('EFFECT_REVIEW_CONFLICT');
|
|
181
|
+
if (await beforeCommit() !== true) throw new Error('EFFECT_RETRY_RECOVERY_DENIED');
|
|
182
|
+
const next = {...prior, status:'reconciliation_required', retry:{...prior.retry,
|
|
183
|
+
status:'reconciliation_required', completedAt:Date.now(),
|
|
184
|
+
recovery:{action:'recover-retry', previousStatus:retry.status, reviewId, decisionId, subject,
|
|
185
|
+
expectedEntryDigest:digest(expected)}}};
|
|
86
186
|
state.tenants[key] = next;
|
|
87
187
|
return clone(next);
|
|
88
188
|
});
|
|
@@ -140,3 +240,55 @@ export class FileEffectJournal {
|
|
|
140
240
|
}
|
|
141
241
|
}
|
|
142
242
|
|
|
243
|
+
/** Authenticated, redacted operator facade; raw journal access stays trusted-host only. */
|
|
244
|
+
export class AuthenticatedEffectJournal {
|
|
245
|
+
#journal; #authority; #tenantId; #authorizeRetention;
|
|
246
|
+
constructor(journal, authority, {tenantId, authorizeRetention} = {}) {
|
|
247
|
+
if (!(journal instanceof FileEffectJournal) || !(authority instanceof TokenAuthority)) throw new TypeError('Journal and authority required');
|
|
248
|
+
required(tenantId, 'tenantId');
|
|
249
|
+
if (authorizeRetention !== undefined && typeof authorizeRetention !== 'function') throw new TypeError('Invalid retention policy');
|
|
250
|
+
this.#journal = journal; this.#authority = authority; this.#tenantId = tenantId; this.#authorizeRetention = authorizeRetention;
|
|
251
|
+
}
|
|
252
|
+
#auth(token, permission = 'read') { return this.#authority.authenticate(token, {tenantId:this.#tenantId, permission}); }
|
|
253
|
+
#visible(entry, identity) { return entry.tenantId === identity.tenantId && (identity.permissions.includes('admin') || entry.subject === identity.subject); }
|
|
254
|
+
#view(entry) {
|
|
255
|
+
if (!['prepared', 'dispatched', 'confirmed', 'reconciliation_required'].includes(entry.status)) throw new Error('Invalid effect status');
|
|
256
|
+
return {id:slot(entry.tenantId, entry.effectKey), status:entry.status, retained:!!entry.retention,
|
|
257
|
+
bound:!!entry.binding, retryPresent:!!entry.retry, reconciliationRecorded:!!entry.reconciliation,
|
|
258
|
+
claimRecoveryRecorded:!!entry.claimRecovery, expectedDigest:digest(entry), remoteAbsenceProven:false};
|
|
259
|
+
}
|
|
260
|
+
async list({token, limit = 25, cursor, includeRetained = false} = {}) {
|
|
261
|
+
const identity = this.#auth(token);
|
|
262
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100 || typeof includeRetained !== 'boolean') throw new TypeError('Invalid page options');
|
|
263
|
+
if (cursor !== undefined && (!cursor || Object.keys(cursor).sort().join() !== 'after,snapshotDigest' || !/^[a-f0-9]{64}$/.test(cursor.after) || !/^[a-f0-9]{64}$/.test(cursor.snapshotDigest))) throw new TypeError('Invalid cursor');
|
|
264
|
+
const snapshot = await this.#journal.snapshot(); this.#auth(token);
|
|
265
|
+
const entries = Object.values(snapshot).filter(e => this.#visible(e, identity));
|
|
266
|
+
const snapshotDigest = digest({identity, includeRetained, entries});
|
|
267
|
+
if (cursor && cursor.snapshotDigest !== snapshotDigest) throw new Error('Journal snapshot changed');
|
|
268
|
+
const views = entries.filter(e => includeRetained || !e.retention).map(e => this.#view(e)).sort((a,b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
269
|
+
if (cursor && !views.some(e => e.id === cursor.after)) throw new Error('Invalid cursor');
|
|
270
|
+
const remaining = views.filter(e => !cursor || e.id > cursor.after), page = remaining.slice(0, limit);
|
|
271
|
+
return {entries:page, snapshotDigest, next:remaining.length > limit ? {after:page.at(-1).id, snapshotDigest} : null, remoteAbsenceProven:false};
|
|
272
|
+
}
|
|
273
|
+
async inspect(effectKey, {token} = {}) {
|
|
274
|
+
const identity = this.#auth(token);
|
|
275
|
+
const entry = await this.#journal.get({tenantId:this.#tenantId, effectKey}); this.#auth(token);
|
|
276
|
+
if (!entry || !this.#visible(entry, identity)) throw new Error('Effect unavailable');
|
|
277
|
+
return this.#view(entry);
|
|
278
|
+
}
|
|
279
|
+
async retain(effectKey, {token, expectedDigest, reviewId, decisionId} = {}) {
|
|
280
|
+
const identity = this.#auth(token, 'admin');
|
|
281
|
+
required(expectedDigest, 'expectedDigest'); required(reviewId, 'reviewId'); required(decisionId, 'decisionId');
|
|
282
|
+
if (!this.#authorizeRetention) throw new Error('Retention policy required');
|
|
283
|
+
const entry = await this.#journal.get({tenantId:this.#tenantId, effectKey}); this.#auth(token, 'admin');
|
|
284
|
+
if (!entry || !this.#visible(entry, identity) || digest(entry) !== expectedDigest) throw new Error('EFFECT_REVIEW_CONFLICT');
|
|
285
|
+
const guard = async () => {
|
|
286
|
+
this.#auth(token, 'admin');
|
|
287
|
+
const allowed = await this.#authorizeRetention({action:'retain-effect', identity:clone(identity), entry:clone(entry), reviewId, decisionId, expectedDigest});
|
|
288
|
+
this.#auth(token, 'admin'); return allowed === true;
|
|
289
|
+
};
|
|
290
|
+
if (await guard() !== true) throw new Error('EFFECT_RETENTION_DENIED');
|
|
291
|
+
const retained = await this.#journal.retain({expectedEntry:entry, reviewId, decisionId, subject:identity.subject, beforeCommit:guard});
|
|
292
|
+
this.#auth(token, 'admin'); return this.#view(retained);
|
|
293
|
+
}
|
|
294
|
+
}
|
package/src/encrypted-state.js
CHANGED
|
@@ -12,7 +12,12 @@ const keyCopy = key => {
|
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
/** Encrypt live snapshots and temporary files while retaining local transaction semantics. */
|
|
15
|
-
export function createEncryptedStateStore(path,
|
|
15
|
+
export function createEncryptedStateStore(path, options = {}) {
|
|
16
|
+
const {key, keyId, tenantId, readKeys = [], keyProvider} = options;
|
|
17
|
+
if (keyProvider !== undefined) {
|
|
18
|
+
if (['key', 'keyId', 'readKeys'].some(field => Object.hasOwn(options, field))) throw new TypeError('Select static keys or a host key provider');
|
|
19
|
+
return new TransactionalStateStore(path, {codec: createProviderEncryptedStateCodec({keyProvider, tenantId})});
|
|
20
|
+
}
|
|
16
21
|
id(keyId, 'state key id', 128); id(tenantId, 'state storage context', 256);
|
|
17
22
|
if (!Array.isArray(readKeys) || readKeys.length > 32) throw new Error('Invalid state read-key ring');
|
|
18
23
|
const keys = new Map([[keyId, keyCopy(key)]]);
|
|
@@ -33,3 +38,60 @@ export function createEncryptedStateStore(path, {key, keyId, tenantId, readKeys
|
|
|
33
38
|
});
|
|
34
39
|
return new TransactionalStateStore(path, {codec});
|
|
35
40
|
}
|
|
41
|
+
|
|
42
|
+
/** Host callback is trusted, asynchronous and consulted anew for every codec operation. */
|
|
43
|
+
export function createProviderEncryptedStateCodec({keyProvider, tenantId} = {}) {
|
|
44
|
+
id(tenantId, 'state storage context', 256);
|
|
45
|
+
if (!keyProvider || typeof keyProvider.getKeyRing !== 'function') throw new TypeError('Host key provider required');
|
|
46
|
+
return Object.freeze({
|
|
47
|
+
async encode(state) {
|
|
48
|
+
const backup = await createStateBackup({load: async () => state});
|
|
49
|
+
return encryptStateBackupWithProvider(backup, {keyProvider, tenantId});
|
|
50
|
+
},
|
|
51
|
+
async decode(envelope) {
|
|
52
|
+
return (await decryptStateBackupWithProvider(envelope, {keyProvider, tenantId})).state;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// No raw callback error/cause escapes: a credential adapter may include secrets in it.
|
|
58
|
+
async function withProviderKey({keyProvider, tenantId}, operation, selected, use) {
|
|
59
|
+
id(tenantId, 'state storage context', 256);
|
|
60
|
+
if (!keyProvider || typeof keyProvider.getKeyRing !== 'function') throw new TypeError('Host key provider required');
|
|
61
|
+
// Reject foreign metadata before asking the host to resolve any key.
|
|
62
|
+
if (operation === 'read') id(selected, 'state key id', 128);
|
|
63
|
+
const copies = [];
|
|
64
|
+
let chosen;
|
|
65
|
+
try {
|
|
66
|
+
const ring = await keyProvider.getKeyRing(Object.freeze({tenantId, operation}));
|
|
67
|
+
id(ring?.currentKeyId, 'current key id', 128);
|
|
68
|
+
if (!Array.isArray(ring.keys) || ring.keys.length < 1 || ring.keys.length > 33) throw new Error();
|
|
69
|
+
const keys = new Map();
|
|
70
|
+
for (const entry of ring.keys) {
|
|
71
|
+
id(entry?.keyId, 'read key id', 128);
|
|
72
|
+
if (keys.has(entry.keyId) || !['active', 'read-only', 'revoked'].includes(entry.status)) throw new Error();
|
|
73
|
+
if (entry.status === 'active' && entry.keyId !== ring.currentKeyId) throw new Error();
|
|
74
|
+
const secret = entry.status === 'revoked' ? null : keyCopy(entry.key);
|
|
75
|
+
if (secret) copies.push(secret);
|
|
76
|
+
keys.set(entry.keyId, {secret, status: entry.status});
|
|
77
|
+
}
|
|
78
|
+
const keyId = operation === 'write' ? ring.currentKeyId : selected;
|
|
79
|
+
const entry = keys.get(keyId);
|
|
80
|
+
if (!entry?.secret || (operation === 'write' && entry.status !== 'active')) throw new Error();
|
|
81
|
+
chosen = {key: entry.secret, keyId, tenantId};
|
|
82
|
+
} catch {
|
|
83
|
+
for (const key of copies) key.fill(0);
|
|
84
|
+
throw new Error('Encryption key provider unavailable or key denied');
|
|
85
|
+
}
|
|
86
|
+
try { return await use(chosen); }
|
|
87
|
+
finally { for (const key of copies) key.fill(0); }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Same v1 envelope as the static-key API; policy/key material never enters it. */
|
|
91
|
+
export async function encryptStateBackupWithProvider(backup, options = {}) {
|
|
92
|
+
return withProviderKey(options, 'write', undefined, key => encryptStateBackup(backup, key));
|
|
93
|
+
}
|
|
94
|
+
export async function decryptStateBackupWithProvider(envelope, options = {}) {
|
|
95
|
+
if (envelope?.tenantId !== options.tenantId) throw new Error('Encrypted backup context mismatch');
|
|
96
|
+
return withProviderKey(options, 'read', envelope?.keyId, key => decryptStateBackup(envelope, key));
|
|
97
|
+
}
|