@nullsquare/agent-authority 0.4.4 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -5
- package/ROADMAP.md +30 -11
- package/docs/durable-task-leases.md +336 -0
- package/docs/npm-release.md +7 -5
- package/docs/transport-invariance.md +46 -10
- package/package.json +3 -2
- package/src/durable-task-lease.js +185 -0
- package/src/storage.js +298 -9
- package/src/task-lease.js +243 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { TaskLease } from './task-lease.js';
|
|
2
|
+
|
|
3
|
+
function sessionError(code, message, details = {}) {
|
|
4
|
+
const error = new Error(message);
|
|
5
|
+
error.code = code;
|
|
6
|
+
Object.assign(error, details);
|
|
7
|
+
return error;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function assertStore(store) {
|
|
11
|
+
if (!store || typeof store !== 'object') {
|
|
12
|
+
throw new Error('durable Task Lease store is required');
|
|
13
|
+
}
|
|
14
|
+
for (const method of ['save', 'load', 'transact']) {
|
|
15
|
+
if (typeof store[method] !== 'function') {
|
|
16
|
+
throw new Error(`durable Task Lease store must implement ${method}()`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return store;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function assertLease(lease) {
|
|
23
|
+
if (!(lease instanceof TaskLease)) throw new Error('TaskLease instance is required');
|
|
24
|
+
return lease;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A small stateful facade over JsonFileTaskLeaseStore-style transactional
|
|
29
|
+
* persistence.
|
|
30
|
+
*
|
|
31
|
+
* The session never exposes its mutable TaskLease instance. Reads come from the
|
|
32
|
+
* cached recovered lease, explicit refresh() reloads authenticated state, and
|
|
33
|
+
* evaluate() refreshes before every security decision so another worker's
|
|
34
|
+
* completion or narrowing is observed before the next guarded effect.
|
|
35
|
+
*
|
|
36
|
+
* Mutations use optimistic compare-and-swap against the session's current lease
|
|
37
|
+
* hash. A stale session receives task_lease_state_conflict and must refresh and
|
|
38
|
+
* reconsider the intended authority mutation; semantic mutations are never
|
|
39
|
+
* silently replayed against a newer authority state.
|
|
40
|
+
*/
|
|
41
|
+
export class DurableTaskLeaseSession {
|
|
42
|
+
constructor({ store, mission, lease_id, lease, lease_hash } = {}) {
|
|
43
|
+
this.store = assertStore(store);
|
|
44
|
+
if (!mission || typeof mission !== 'object') throw new Error('mission is required');
|
|
45
|
+
if (!lease_id) throw new Error('lease_id is required');
|
|
46
|
+
assertLease(lease);
|
|
47
|
+
if (lease.lease_id !== lease_id || lease.mission.mission_id !== mission.mission_id) {
|
|
48
|
+
throw sessionError('durable_task_lease_identity_mismatch', 'session lease identity does not match mission and lease_id');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this._mission = structuredClone(mission);
|
|
52
|
+
this.lease_id = lease_id;
|
|
53
|
+
this._lease = lease;
|
|
54
|
+
this._leaseHash = lease_hash || lease.hash();
|
|
55
|
+
if (this._leaseHash !== lease.hash()) {
|
|
56
|
+
throw sessionError('durable_task_lease_hash_mismatch', 'session lease hash does not match recovered Task Lease state');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get mission() {
|
|
61
|
+
return structuredClone(this._mission);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get status() {
|
|
65
|
+
return this._lease.status;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get expires_at() {
|
|
69
|
+
return this._lease.expires_at;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get completed_at() {
|
|
73
|
+
return this._lease.completed_at;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get completion_reason() {
|
|
77
|
+
return this._lease.completion_reason;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
hash() {
|
|
81
|
+
return this._leaseHash;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
snapshot() {
|
|
85
|
+
return structuredClone(this._lease.snapshot());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
fact(factId) {
|
|
89
|
+
return this._lease.fact(factId);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
listFacts() {
|
|
93
|
+
return this._lease.listFacts();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
refresh() {
|
|
97
|
+
const lease = this.store.load({
|
|
98
|
+
mission: this._mission,
|
|
99
|
+
lease_id: this.lease_id
|
|
100
|
+
});
|
|
101
|
+
if (!lease) {
|
|
102
|
+
throw sessionError(
|
|
103
|
+
'task_lease_state_missing',
|
|
104
|
+
`task lease ${this.lease_id} has no durable state`,
|
|
105
|
+
{ lease_id: this.lease_id }
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
this._lease = lease;
|
|
109
|
+
this._leaseHash = lease.hash();
|
|
110
|
+
return this;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
evaluate(runtime, request, now = new Date()) {
|
|
114
|
+
this.refresh();
|
|
115
|
+
return this._lease.evaluate(runtime, request, now);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_commit(mutator) {
|
|
119
|
+
const result = this.store.transact({
|
|
120
|
+
mission: this._mission,
|
|
121
|
+
lease_id: this.lease_id,
|
|
122
|
+
expected_lease_hash: this._leaseHash,
|
|
123
|
+
mutate: mutator
|
|
124
|
+
});
|
|
125
|
+
this._lease = result.lease;
|
|
126
|
+
this._leaseHash = result.lease_hash;
|
|
127
|
+
return result.value;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
addRoot(options) {
|
|
131
|
+
return this._commit((lease) => lease.addRoot(options));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
derive(options) {
|
|
135
|
+
return this._commit((lease) => lease.derive(options));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
deriveFromEvidence(options) {
|
|
139
|
+
return this._commit((lease) => lease.deriveFromEvidence(options));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
bind(binding) {
|
|
143
|
+
return this._commit((lease) => lease.bind(binding));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
complete(reason = 'task completed') {
|
|
147
|
+
return this._commit((lease) => lease.complete(reason));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function createDurableTaskLeaseSession({ store, lease } = {}) {
|
|
152
|
+
assertStore(store);
|
|
153
|
+
assertLease(lease);
|
|
154
|
+
const mission = structuredClone(lease.mission);
|
|
155
|
+
const saved = store.save(lease);
|
|
156
|
+
const recovered = store.load({ mission, lease_id: lease.lease_id });
|
|
157
|
+
if (!recovered) {
|
|
158
|
+
throw sessionError('task_lease_state_missing', `task lease ${lease.lease_id} was not recoverable after creation`);
|
|
159
|
+
}
|
|
160
|
+
return new DurableTaskLeaseSession({
|
|
161
|
+
store,
|
|
162
|
+
mission,
|
|
163
|
+
lease_id: lease.lease_id,
|
|
164
|
+
lease: recovered,
|
|
165
|
+
lease_hash: saved.lease_hash
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function openDurableTaskLeaseSession({ store, mission, lease_id } = {}) {
|
|
170
|
+
assertStore(store);
|
|
171
|
+
if (!mission || typeof mission !== 'object') throw new Error('mission is required');
|
|
172
|
+
if (!lease_id) throw new Error('lease_id is required');
|
|
173
|
+
const missionSnapshot = structuredClone(mission);
|
|
174
|
+
const lease = store.load({ mission: missionSnapshot, lease_id });
|
|
175
|
+
if (!lease) {
|
|
176
|
+
throw sessionError('task_lease_state_missing', `task lease ${lease_id} has no durable state`, { lease_id });
|
|
177
|
+
}
|
|
178
|
+
return new DurableTaskLeaseSession({
|
|
179
|
+
store,
|
|
180
|
+
mission: missionSnapshot,
|
|
181
|
+
lease_id,
|
|
182
|
+
lease,
|
|
183
|
+
lease_hash: lease.hash()
|
|
184
|
+
});
|
|
185
|
+
}
|
package/src/storage.js
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
createCipheriv,
|
|
3
|
+
createDecipheriv,
|
|
4
|
+
createHmac,
|
|
5
|
+
randomBytes,
|
|
6
|
+
randomUUID,
|
|
7
|
+
timingSafeEqual
|
|
8
|
+
} from 'node:crypto';
|
|
9
|
+
import {
|
|
10
|
+
chmodSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
renameSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
unlinkSync,
|
|
17
|
+
writeFileSync
|
|
18
|
+
} from 'node:fs';
|
|
3
19
|
import { homedir } from 'node:os';
|
|
4
20
|
import { dirname, join, resolve } from 'node:path';
|
|
21
|
+
import { TaskLease } from './task-lease.js';
|
|
5
22
|
|
|
6
23
|
export function authorityHome(env = process.env) {
|
|
7
24
|
return resolve(env.AGENT_AUTHORITY_HOME || join(homedir(), '.agent-authority'));
|
|
@@ -26,6 +43,68 @@ function atomicJson(path, value, mode = 0o600) {
|
|
|
26
43
|
try { chmodSync(path, mode); } catch {}
|
|
27
44
|
}
|
|
28
45
|
|
|
46
|
+
function loadOrCreateMasterKey(keyPath) {
|
|
47
|
+
ensureDir(dirname(keyPath));
|
|
48
|
+
if (!existsSync(keyPath)) {
|
|
49
|
+
writeFileSync(keyPath, randomBytes(32), { mode: 0o600 });
|
|
50
|
+
try { chmodSync(keyPath, 0o600); } catch {}
|
|
51
|
+
}
|
|
52
|
+
const key = readFileSync(keyPath);
|
|
53
|
+
if (key.length !== 32) throw new Error('Agent Authority master key is invalid');
|
|
54
|
+
return key;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function deriveLocalKey(keyPath, purpose) {
|
|
58
|
+
return createHmac('sha256', loadOrCreateMasterKey(keyPath))
|
|
59
|
+
.update(`agent-authority/${purpose}/v1`)
|
|
60
|
+
.digest();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function safeLeaseFileName(leaseId) {
|
|
64
|
+
if (typeof leaseId !== 'string' || leaseId.trim() === '') throw new Error('lease_id is required');
|
|
65
|
+
return `${Buffer.from(leaseId, 'utf8').toString('base64url')}.json`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function taskLeaseEnvelopePayload(envelope) {
|
|
69
|
+
return JSON.stringify({
|
|
70
|
+
version: envelope.version,
|
|
71
|
+
lease_id: envelope.lease_id,
|
|
72
|
+
mission_id: envelope.mission_id,
|
|
73
|
+
lease_hash: envelope.lease_hash,
|
|
74
|
+
snapshot: envelope.snapshot
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function taskLeaseMac(key, envelope) {
|
|
79
|
+
return createHmac('sha256', key).update(taskLeaseEnvelopePayload(envelope)).digest('hex');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function authenticationError(message) {
|
|
83
|
+
const error = new Error(message);
|
|
84
|
+
error.code = 'task_lease_state_authentication_failed';
|
|
85
|
+
return error;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function stateConflictError(message, details = {}) {
|
|
89
|
+
const error = new Error(message);
|
|
90
|
+
error.code = 'task_lease_state_conflict';
|
|
91
|
+
Object.assign(error, details);
|
|
92
|
+
return error;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function stateLockedError(leaseId) {
|
|
96
|
+
const error = new Error(`task lease ${leaseId} is already being updated by another local worker`);
|
|
97
|
+
error.code = 'task_lease_state_locked';
|
|
98
|
+
error.lease_id = leaseId;
|
|
99
|
+
return error;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function transactionError(code, message) {
|
|
103
|
+
const error = new Error(message);
|
|
104
|
+
error.code = code;
|
|
105
|
+
return error;
|
|
106
|
+
}
|
|
107
|
+
|
|
29
108
|
export function defaultConfig(home = authorityHome()) {
|
|
30
109
|
return {
|
|
31
110
|
version: 1,
|
|
@@ -37,6 +116,7 @@ export function defaultConfig(home = authorityHome()) {
|
|
|
37
116
|
master_key: join(home, 'vault', 'master.key'),
|
|
38
117
|
revocations: join(home, 'state', 'revocations.json'),
|
|
39
118
|
usage: join(home, 'state', 'usage.json'),
|
|
119
|
+
task_leases: join(home, 'state', 'task-leases'),
|
|
40
120
|
receipts: join(home, 'receipts')
|
|
41
121
|
}
|
|
42
122
|
};
|
|
@@ -136,13 +216,7 @@ export class EncryptedFileSecretStore {
|
|
|
136
216
|
}
|
|
137
217
|
|
|
138
218
|
key() {
|
|
139
|
-
|
|
140
|
-
writeFileSync(this.keyPath, randomBytes(32), { mode: 0o600 });
|
|
141
|
-
try { chmodSync(this.keyPath, 0o600); } catch {}
|
|
142
|
-
}
|
|
143
|
-
const key = readFileSync(this.keyPath);
|
|
144
|
-
if (key.length !== 32) throw new Error('Agent Authority master key is invalid');
|
|
145
|
-
return key;
|
|
219
|
+
return loadOrCreateMasterKey(this.keyPath);
|
|
146
220
|
}
|
|
147
221
|
|
|
148
222
|
all() { return readJson(this.path, {}); }
|
|
@@ -179,6 +253,221 @@ export class EncryptedFileSecretStore {
|
|
|
179
253
|
}
|
|
180
254
|
}
|
|
181
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Local authenticated persistence for Task Lease authority state.
|
|
258
|
+
*
|
|
259
|
+
* The entire snapshot is written atomically and authenticated with an HMAC key
|
|
260
|
+
* derived from the Agent Authority local master key. Loading verifies the MAC,
|
|
261
|
+
* exact lease/mission identity, snapshot hash and TaskLease lineage validation
|
|
262
|
+
* before reconstructed authority is returned to the caller.
|
|
263
|
+
*
|
|
264
|
+
* Durable mutations should go through transact(). The per-lease lock serializes
|
|
265
|
+
* local read-modify-write transactions, while expected_lease_hash provides an
|
|
266
|
+
* optimistic compare-and-swap check for workers operating on recovered views.
|
|
267
|
+
*
|
|
268
|
+
* This protects against accidental/caller-controlled state-file modification and
|
|
269
|
+
* stale local writers on the trusted host. It is not designed to contain a
|
|
270
|
+
* malicious host or an attacker that can read the local master key.
|
|
271
|
+
*/
|
|
272
|
+
export class JsonFileTaskLeaseStore {
|
|
273
|
+
constructor({ dir, keyPath }) {
|
|
274
|
+
if (!dir) throw new Error('task lease store dir is required');
|
|
275
|
+
if (!keyPath) throw new Error('task lease store keyPath is required');
|
|
276
|
+
this.dir = dir;
|
|
277
|
+
this.keyPath = keyPath;
|
|
278
|
+
ensureDir(dir);
|
|
279
|
+
ensureDir(dirname(keyPath));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
key() {
|
|
283
|
+
return deriveLocalKey(this.keyPath, 'task-lease-state');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
path(leaseId) {
|
|
287
|
+
return join(this.dir, safeLeaseFileName(leaseId));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
lockPath(leaseId) {
|
|
291
|
+
return `${this.path(leaseId)}.lock`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
withLeaseLock(leaseId, fn) {
|
|
295
|
+
const lockPath = this.lockPath(leaseId);
|
|
296
|
+
try {
|
|
297
|
+
mkdirSync(lockPath, { mode: 0o700 });
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (error?.code === 'EEXIST') throw stateLockedError(leaseId);
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
return fn();
|
|
305
|
+
} finally {
|
|
306
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
envelopeFor(lease) {
|
|
311
|
+
const snapshot = lease.snapshot();
|
|
312
|
+
const envelope = {
|
|
313
|
+
version: 1,
|
|
314
|
+
lease_id: lease.lease_id,
|
|
315
|
+
mission_id: lease.mission.mission_id,
|
|
316
|
+
lease_hash: lease.hash(),
|
|
317
|
+
snapshot
|
|
318
|
+
};
|
|
319
|
+
envelope.mac = taskLeaseMac(this.key(), envelope);
|
|
320
|
+
return envelope;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
writeLease(lease, path = this.path(lease.lease_id)) {
|
|
324
|
+
const envelope = this.envelopeFor(lease);
|
|
325
|
+
atomicJson(path, envelope, 0o600);
|
|
326
|
+
return {
|
|
327
|
+
lease_id: envelope.lease_id,
|
|
328
|
+
mission_id: envelope.mission_id,
|
|
329
|
+
lease_hash: envelope.lease_hash
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
save(lease, { expected_lease_hash = null } = {}) {
|
|
334
|
+
if (!(lease instanceof TaskLease)) throw new Error('TaskLease instance is required');
|
|
335
|
+
return this.withLeaseLock(lease.lease_id, () => {
|
|
336
|
+
const path = this.path(lease.lease_id);
|
|
337
|
+
if (existsSync(path)) {
|
|
338
|
+
const current = this.load({ mission: lease.mission, lease_id: lease.lease_id });
|
|
339
|
+
const currentHash = current.hash();
|
|
340
|
+
const nextHash = lease.hash();
|
|
341
|
+
|
|
342
|
+
if (expected_lease_hash !== null && currentHash !== expected_lease_hash) {
|
|
343
|
+
throw stateConflictError('task lease state changed since the caller recovered it', {
|
|
344
|
+
lease_id: lease.lease_id,
|
|
345
|
+
expected_lease_hash,
|
|
346
|
+
current_lease_hash: currentHash
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
if (expected_lease_hash === null && currentHash !== nextHash) {
|
|
350
|
+
throw stateConflictError(
|
|
351
|
+
'changed durable Task Lease state requires expected_lease_hash or transact()',
|
|
352
|
+
{ lease_id: lease.lease_id, current_lease_hash: currentHash, attempted_lease_hash: nextHash }
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
if (currentHash === nextHash) {
|
|
356
|
+
return {
|
|
357
|
+
lease_id: lease.lease_id,
|
|
358
|
+
mission_id: lease.mission.mission_id,
|
|
359
|
+
lease_hash: currentHash
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
} else if (expected_lease_hash !== null) {
|
|
363
|
+
throw stateConflictError('task lease state does not exist for the supplied expected hash', {
|
|
364
|
+
lease_id: lease.lease_id,
|
|
365
|
+
expected_lease_hash,
|
|
366
|
+
current_lease_hash: null
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const validated = TaskLease.restore({ mission: lease.mission, snapshot: lease.snapshot() });
|
|
371
|
+
return this.writeLease(validated, path);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
load({ mission, lease_id } = {}) {
|
|
376
|
+
if (!mission) throw new Error('mission is required');
|
|
377
|
+
if (!lease_id) throw new Error('lease_id is required');
|
|
378
|
+
const path = this.path(lease_id);
|
|
379
|
+
if (!existsSync(path)) return null;
|
|
380
|
+
|
|
381
|
+
const envelope = readJson(path, null);
|
|
382
|
+
if (!envelope || envelope.version !== 1) {
|
|
383
|
+
throw authenticationError('task lease state envelope is invalid or unsupported');
|
|
384
|
+
}
|
|
385
|
+
if (envelope.lease_id !== lease_id || envelope.mission_id !== mission.mission_id) {
|
|
386
|
+
throw authenticationError('task lease state identity does not match the requested lease and mission');
|
|
387
|
+
}
|
|
388
|
+
if (typeof envelope.mac !== 'string' || !/^[a-f0-9]{64}$/.test(envelope.mac)) {
|
|
389
|
+
throw authenticationError('task lease state authentication tag is invalid');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const expected = Buffer.from(taskLeaseMac(this.key(), envelope), 'hex');
|
|
393
|
+
const actual = Buffer.from(envelope.mac, 'hex');
|
|
394
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
|
|
395
|
+
throw authenticationError('task lease state authentication failed');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
let lease;
|
|
399
|
+
try {
|
|
400
|
+
// Recover against an isolated mission snapshot so a transaction callback
|
|
401
|
+
// cannot mutate the caller's authority ceiling through shared references.
|
|
402
|
+
lease = TaskLease.restore({ mission: structuredClone(mission), snapshot: envelope.snapshot });
|
|
403
|
+
} catch (error) {
|
|
404
|
+
if (!error.code) error.code = 'task_lease_snapshot_invalid';
|
|
405
|
+
throw error;
|
|
406
|
+
}
|
|
407
|
+
if (lease.lease_id !== lease_id || lease.hash() !== envelope.lease_hash) {
|
|
408
|
+
throw authenticationError('task lease state hash does not match recovered authority');
|
|
409
|
+
}
|
|
410
|
+
return lease;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Apply one synchronous durable mutation to the authenticated current lease.
|
|
415
|
+
*
|
|
416
|
+
* The mutation runs against a freshly recovered lease while holding an
|
|
417
|
+
* exclusive local per-lease lock. If expected_lease_hash is supplied, a stale
|
|
418
|
+
* worker fails before its mutation is applied. The resulting snapshot is fully
|
|
419
|
+
* validated and atomically replaced before the updated lease is returned.
|
|
420
|
+
*/
|
|
421
|
+
transact({ mission, lease_id, expected_lease_hash = null, mutate } = {}) {
|
|
422
|
+
if (!mission) throw new Error('mission is required');
|
|
423
|
+
if (!lease_id) throw new Error('lease_id is required');
|
|
424
|
+
if (typeof mutate !== 'function') throw new Error('transaction mutate function is required');
|
|
425
|
+
|
|
426
|
+
return this.withLeaseLock(lease_id, () => {
|
|
427
|
+
const current = this.load({ mission, lease_id });
|
|
428
|
+
if (!current) {
|
|
429
|
+
throw transactionError('task_lease_state_missing', `task lease ${lease_id} has no durable state`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const previousHash = current.hash();
|
|
433
|
+
if (expected_lease_hash !== null && previousHash !== expected_lease_hash) {
|
|
434
|
+
throw stateConflictError('task lease state changed since the caller recovered it', {
|
|
435
|
+
lease_id,
|
|
436
|
+
expected_lease_hash,
|
|
437
|
+
current_lease_hash: previousHash
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const value = mutate(current);
|
|
442
|
+
if (value && typeof value.then === 'function') {
|
|
443
|
+
throw transactionError(
|
|
444
|
+
'task_lease_transaction_async_unsupported',
|
|
445
|
+
'durable Task Lease transactions must be synchronous and side-effect free outside lease state'
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
if (current.lease_id !== lease_id || current.mission.mission_id !== mission.mission_id) {
|
|
449
|
+
throw transactionError('task_lease_transaction_identity_changed', 'transaction changed Task Lease identity');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const validated = TaskLease.restore({ mission, snapshot: current.snapshot() });
|
|
453
|
+
const saved = this.writeLease(validated, this.path(lease_id));
|
|
454
|
+
return {
|
|
455
|
+
lease: validated,
|
|
456
|
+
value,
|
|
457
|
+
previous_lease_hash: previousHash,
|
|
458
|
+
lease_hash: saved.lease_hash
|
|
459
|
+
};
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
delete(leaseId) {
|
|
464
|
+
const path = this.path(leaseId);
|
|
465
|
+
if (!existsSync(path)) return false;
|
|
466
|
+
unlinkSync(path);
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
182
471
|
export class JsonFileRevocationStore {
|
|
183
472
|
constructor(path) { this.path = path; ensureDir(dirname(path)); }
|
|
184
473
|
all() { return readJson(this.path, {}); }
|