@aetheros/kernel-runtime 0.1.0 → 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.
@@ -0,0 +1,59 @@
1
+ const permissions = new Set(['read', 'write', 'workflow', 'approve', 'audit', 'admin']);
2
+ const text = value => typeof value === 'string' && value.trim().length > 0 && value.length <= 2048;
3
+ const live = claims => Number.isSafeInteger(claims.expiresAt) && claims.expiresAt > Date.now();
4
+ const denied = () => new Error('External identity denied');
5
+
6
+ /** Trusted host adapter, never a JWT decoder or a replacement for TokenAuthority.
7
+ * Every call verifies credentials and checks current revocation; nothing is cached.
8
+ */
9
+ export class ExternalIdentityBoundary {
10
+ #issuer; #audience; #tenantId; #verify; #active; #subject; #scopes;
11
+ constructor({issuer, audience, tenantId, verifyCredential, isActive, mapSubject, scopeMap} = {}) {
12
+ if (![issuer, audience, tenantId].every(text) ||
13
+ ![verifyCredential, isActive, mapSubject].every(value => typeof value === 'function') ||
14
+ !scopeMap || Object.getPrototypeOf(scopeMap) !== Object.prototype ||
15
+ !Object.keys(scopeMap).length || Object.entries(scopeMap).some(([key, value]) => !text(key) || !permissions.has(value))) {
16
+ throw new TypeError('Explicit external identity host configuration required');
17
+ }
18
+ this.#issuer = issuer; this.#audience = audience; this.#tenantId = tenantId;
19
+ this.#verify = verifyCredential; this.#active = isActive; this.#subject = mapSubject;
20
+ this.#scopes = new Map(Object.entries(scopeMap));
21
+ }
22
+ async verify(credential) {
23
+ try {
24
+ if (typeof credential !== 'string' || !credential.trim() || credential.length > 16384) throw denied();
25
+ // Only the configured verifier sees the bearer credential. It must verify
26
+ // signature/protocol, algorithm, key trust, not-before and provider policy.
27
+ const claims = structuredClone(await this.#verify(credential));
28
+ if (!claims || claims.issuer !== this.#issuer || claims.audience !== this.#audience ||
29
+ claims.tenantId !== this.#tenantId || !text(claims.subject) || !text(claims.credentialId) ||
30
+ !live(claims) || !Array.isArray(claims.scopes) || claims.scopes.length > 128 ||
31
+ !claims.scopes.every(text)) throw denied();
32
+ const verified = {issuer: this.#issuer, audience: this.#audience, tenantId: this.#tenantId,
33
+ subject: claims.subject, credentialId: claims.credentialId, scopes: [...new Set(claims.scopes)], expiresAt: claims.expiresAt};
34
+ // Subject mapping is synchronous, explicit, issuer/tenant qualified and host
35
+ // owned. A tool cannot select the local actor or scope mapping.
36
+ const subject = this.#subject(Object.freeze({issuer: verified.issuer, tenantId: verified.tenantId, subject: verified.subject}));
37
+ if (!text(subject)) throw denied();
38
+ if (await this.#active(structuredClone(verified)) !== true || !live(verified)) throw denied();
39
+ const mapped = [...new Set(verified.scopes.filter(scope => this.#scopes.has(scope)).map(scope => this.#scopes.get(scope)))];
40
+ if (!mapped.length) throw denied();
41
+ return {tenantId: verified.tenantId, subject, permissions: mapped, expiresAt: verified.expiresAt};
42
+ } catch { throw denied(); } // Never forward provider exceptions, credentials or claims.
43
+ }
44
+ /** Compose with an existing facade's host policy, alongside Review/claim checks.
45
+ * A previous verify result is deliberately insufficient: verify again on every call.
46
+ */
47
+ async authorize({credential, identity, permission} = {}) {
48
+ try {
49
+ const expected = structuredClone(identity);
50
+ if (!expected || expected.tenantId !== this.#tenantId || !text(expected.subject) ||
51
+ !permissions.has(permission) || !Array.isArray(expected.permissions) || !live(expected) ||
52
+ (!expected.permissions.includes(permission) && !expected.permissions.includes('admin'))) throw denied();
53
+ const current = await this.verify(credential);
54
+ if (!live(expected) || current.tenantId !== expected.tenantId || current.subject !== expected.subject ||
55
+ (!current.permissions.includes(permission) && !current.permissions.includes('admin'))) throw denied();
56
+ return true;
57
+ } catch { throw denied(); }
58
+ }
59
+ }
package/src/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import {randomBytes,scryptSync,timingSafeEqual} from 'node:crypto';
2
2
  import {readFile,writeFile,rename,mkdir,open} from 'node:fs/promises';
3
3
  import {dirname,resolve} from 'node:path';
4
+ export {ExternalIdentityBoundary} from './external-identity.js';
4
5
 
5
6
  const scopes=new Set(['read','write','workflow','approve','audit','admin']);
6
7
  const validate=(value,name)=>{if(typeof value!=='string'||!value.trim())throw new Error(name+' required');return value;};
@@ -42,8 +43,10 @@ export class FileStateStore {
42
43
  FileStateStore.queues.set(this.path,current);
43
44
  try{return await current;}finally{if(FileStateStore.queues.get(this.path)===current)FileStateStore.queues.delete(this.path);}
44
45
  }
45
- constructor(path,{codec}={}){
46
+ constructor(path,{codec,recovery}={}){
46
47
  this.path=resolve(validate(path,'path'));
48
+ if(recovery!==undefined&&recovery!==false&&(recovery===null||typeof recovery!=='object'))throw new TypeError('Recovery options must be false or an object');
49
+ this.recovery=recovery;
47
50
  if(codec!==undefined&&(!codec||typeof codec.encode!=='function'||typeof codec.decode!=='function'))throw new TypeError('State codec requires encode and decode');
48
51
  this.codec=codec;
49
52
  }
@@ -0,0 +1,39 @@
1
+ import {lstat, open} from 'node:fs/promises';
2
+ import {probeOwner} from './process-identity.js';
3
+
4
+ /**
5
+ * Read-only look at the lock a state store leaves while a transaction runs (R5). It reports what can be seen and never
6
+ * changes, removes or takes over anything. Today's lock file holds only a random lease: it does not say which process
7
+ * holds it, so this can say how long it has been there and what it looks like, but not whether its owner is alive.
8
+ * Age alone is never proof that an owner is gone (see docs/architecture/abandoned-lock-recovery.md).
9
+ */
10
+ const LEASE = /^[0-9a-f]{48}$/;
11
+
12
+ /**
13
+ * @param {string} statePath the state file's path (the lock is `<statePath>.lock`)
14
+ * @param {{now?: () => number}} options
15
+ * @returns {Promise<{path: string, lockPath: string, exists: boolean, ageMs?: number, modifiedAt?: string, bytes?: number, format?: 'record-v2'|'lease-v1'|'unreadable'|'unrecognised', owner?: object, owner_check?: {verdict: string, reason: string}, recovery?: string}>}
16
+ */
17
+ export async function inspectStateLock(statePath, {now = () => Date.now(), probe = probeOwner, probeOptions = {}} = {}) {
18
+ if (typeof statePath !== 'string' || !statePath.trim()) throw new TypeError('State path required');
19
+ const lockPath = statePath + '.lock';
20
+ let info;
21
+ try { info = await lstat(lockPath); } catch (error) { if (error.code === 'ENOENT') return {path: statePath, lockPath, exists: false}; throw error; }
22
+ if (!info.isFile()) return {path: statePath, lockPath, exists: true, format: 'unrecognised', bytes: info.size, recovery: 'The lock path is not a regular file. Do not remove it automatically; look at it by hand.'};
23
+ // Only the first few bytes are read, and only to tell the format; the lease itself is never returned.
24
+ let head = '', whole = '';
25
+ try { const handle = await open(lockPath, 'r'); try { const buffer = Buffer.alloc(2048); const {bytesRead} = await handle.read(buffer, 0, 2048, 0); whole = buffer.subarray(0, bytesRead).toString('utf8'); head = whole.slice(0, 64); } finally { await handle.close(); } }
26
+ catch (error) { if (error.code === 'ENOENT') return {path: statePath, lockPath, exists: false}; return {path: statePath, lockPath, exists: true, format: 'unreadable', bytes: info.size, recovery: 'The lock file could not be read.'}; }
27
+ let record = null;
28
+ try { const v = JSON.parse(whole); if (v && v.v === 2 && typeof v.lease === 'string' && Number.isSafeInteger(v.pid)) record = v; } catch { /* not a record */ }
29
+ const format = record ? 'record-v2' : LEASE.test(head) && info.size === 48 ? 'lease-v1' : 'unrecognised';
30
+ const ageMs = Math.max(0, now() - info.mtimeMs);
31
+ // For a record, say who holds it and whether that process is provably gone (read-only; the lease is never returned).
32
+ const owner = record ? {pid: record.pid, startedAt: new Date(record.processStart).toISOString(), fence: record.fence} : undefined;
33
+ const ownerCheck = record ? await probe(record, probeOptions).catch(() => ({verdict: 'unknown', reason: 'the check failed'})) : undefined;
34
+ return {
35
+ path: statePath, lockPath, exists: true, ageMs: Math.round(ageMs), modifiedAt: new Date(info.mtimeMs).toISOString(), bytes: info.size, format,
36
+ ...(owner && {owner, owner_check: ownerCheck}),
37
+ recovery: record ? (ownerCheck.verdict === 'gone' ? `This lock names process ${record.pid}, which is provably gone (${ownerCheck.reason}). The next write recovers it by itself; nothing needs to be deleted by hand.` : ownerCheck.verdict === 'alive' ? `This lock is held by process ${record.pid}, which is running. Leave it.` : `This lock names process ${record.pid}, but it cannot be proven gone (${ownerCheck.reason}). A long age is not proof. If you are sure no AetherOS process is using this project, delete the lock file by hand; otherwise leave it.`) : 'Nothing in this lock says which process holds it, or whether that process is still running. A transaction normally lasts milliseconds, but a long age is not proof that its owner is gone. If you are sure no AetherOS process is using this project (close them all and check), delete the lock file by hand; otherwise leave it.'
38
+ };
39
+ }
@@ -0,0 +1,82 @@
1
+ import {execFile} from 'node:child_process';
2
+ import {createHash} from 'node:crypto';
3
+ import {readFile} from 'node:fs/promises';
4
+ import {hostname, uptime} from 'node:os';
5
+
6
+ /**
7
+ * Who a process is, and whether a process named in an old lock is provably gone (R6; design in
8
+ * docs/architecture/abandoned-lock-recovery.md). A process is identified by its pid AND its start time, so a reused pid is a
9
+ * different process. "Gone" needs positive evidence on the same machine and the same boot; any doubt is "unknown", and an
10
+ * unknown owner is never recovered. Age is never evidence.
11
+ *
12
+ * Recovery is enabled where the probes have been verified on real machines (Windows). Elsewhere it stays manual unless
13
+ * AETHER_LOCK_RECOVERY=on is set (the Linux and macOS probes are written but have not been run on those systems here).
14
+ */
15
+ export const START_TOLERANCE_MS = 5000;
16
+ export const BOOT_TOLERANCE_MS = 120_000;
17
+ export const VERIFIED_PLATFORMS = Object.freeze(['win32']);
18
+
19
+ const run = (file, args, {timeout = 15_000} = {}) => new Promise((resolve, reject) => execFile(file, args, {timeout, windowsHide: true, encoding: 'utf8'}, (error, stdout) => error ? reject(error) : resolve(String(stdout))));
20
+
21
+ const PROCESS_START = Math.round(Date.now() - process.uptime() * 1000);
22
+ let identityPromise = null;
23
+
24
+ async function machineId(platform, exec) {
25
+ try {
26
+ if (platform === 'win32') { const out = await exec('reg', ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid']); return /MachineGuid\s+REG_SZ\s+(\S+)/.exec(out)?.[1] ?? ''; }
27
+ if (platform === 'linux') { for (const f of ['/etc/machine-id', '/var/lib/dbus/machine-id']) { try { return (await readFile(f, 'utf8')).trim(); } catch { /* try the next */ } } return ''; }
28
+ if (platform === 'darwin') { const out = await exec('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice']); return /"IOPlatformUUID" = "([^"]+)"/.exec(out)?.[1] ?? ''; }
29
+ } catch { /* fall back to the host name alone */ }
30
+ return '';
31
+ }
32
+
33
+ /** This process: {pid, processStart (ms), hostId, bootStart (ms)}. Computed once. */
34
+ export function currentIdentity({platform = process.platform, exec = run} = {}) {
35
+ identityPromise ??= (async () => ({
36
+ pid: process.pid, processStart: PROCESS_START,
37
+ hostId: createHash('sha256').update(hostname() + '|' + await machineId(platform, exec)).digest('hex').slice(0, 32),
38
+ bootStart: Math.round(Date.now() - uptime() * 1000)
39
+ }))();
40
+ return identityPromise;
41
+ }
42
+
43
+ /** The start time (ms since the epoch) of another process, or null when it cannot be read. */
44
+ export async function processStartOf(pid, {platform = process.platform, exec = run} = {}) {
45
+ try {
46
+ if (platform === 'win32') {
47
+ const out = await exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${Number(pid)} -ErrorAction Stop).StartTime.ToUniversalTime().ToString('o')`]);
48
+ const t = Date.parse(out.trim()); return Number.isFinite(t) ? t : null;
49
+ }
50
+ if (platform === 'linux') {
51
+ const stat = await readFile(`/proc/${Number(pid)}/stat`, 'utf8');
52
+ const ticks = Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]);
53
+ const btime = Number(/^btime (\d+)/m.exec(await readFile('/proc/stat', 'utf8'))?.[1]);
54
+ const hz = Number((await exec('getconf', ['CLK_TCK'])).trim()) || 100;
55
+ return Number.isFinite(ticks) && Number.isFinite(btime) ? Math.round(btime * 1000 + (ticks / hz) * 1000) : null;
56
+ }
57
+ if (platform === 'darwin') { const t = Date.parse((await exec('ps', ['-o', 'lstart=', '-p', String(Number(pid))])).trim()); return Number.isFinite(t) ? t : null; }
58
+ } catch { /* unknown */ }
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * Is the owner named in a lock record provably gone? → {verdict: 'gone' | 'alive' | 'unknown', reason}.
64
+ * @param {{pid, processStart, hostId, bootStart}} record
65
+ * @param {{platform?: string, exec?: Function, kill?: Function, identity?: object, enabled?: boolean}} options (injectable for tests)
66
+ */
67
+ export async function probeOwner(record, {platform = process.platform, exec = run, kill = process.kill.bind(process), identity, enabled} = {}) {
68
+ const setting = process.env.AETHER_LOCK_RECOVERY; // 'off' turns automatic recovery off everywhere, 'on' also enables it on platforms not yet verified
69
+ const allowed = enabled ?? (setting === 'off' ? false : VERIFIED_PLATFORMS.includes(platform) || setting === 'on');
70
+ if (!allowed) return {verdict: 'unknown', reason: `automatic recovery is not enabled on ${platform}`};
71
+ if (!record || !Number.isSafeInteger(record.pid) || record.pid < 1 || !Number.isFinite(record.processStart) || typeof record.hostId !== 'string' || !Number.isFinite(record.bootStart)) return {verdict: 'unknown', reason: 'the lock does not say who holds it'};
72
+ const me = identity ?? await currentIdentity({platform, exec});
73
+ if (record.hostId !== me.hostId) return {verdict: 'unknown', reason: 'the lock was made on another machine'};
74
+ if (Math.abs(record.bootStart - me.bootStart) > BOOT_TOLERANCE_MS) return {verdict: 'unknown', reason: 'the lock was made before the last restart (this proves nothing about files on a shared disk)'};
75
+ let exists = true;
76
+ try { kill(record.pid, 0); } catch (error) { if (error?.code === 'ESRCH') exists = false; else if (error?.code !== 'EPERM') return {verdict: 'unknown', reason: 'the process could not be checked'}; }
77
+ if (!exists) return {verdict: 'gone', reason: `no process ${record.pid} exists`};
78
+ const started = await processStartOf(record.pid, {platform, exec});
79
+ if (started === null) return {verdict: 'unknown', reason: 'the start time of that process could not be read'};
80
+ if (Math.abs(started - record.processStart) > START_TOLERANCE_MS) return {verdict: 'gone', reason: `process ${record.pid} is a different process now (started at another time)`};
81
+ return {verdict: 'alive', reason: `process ${record.pid} is running`};
82
+ }
@@ -0,0 +1,35 @@
1
+ import {createHash} from 'node:crypto';
2
+ import {canonicalJson} from './canonical-json.js';
3
+
4
+ // Shared strict host contract validation; this alone never permits dispatch.
5
+ export function snapshotRetryCapability(value, entry, input) {
6
+ const contract = JSON.parse(canonicalJson(value));
7
+ const exact = (object, keys) => object && !Array.isArray(object) &&
8
+ Object.keys(object).sort().join(',') === keys.split(',').sort().join(',');
9
+ const text = value => typeof value === 'string' && value.trim().length > 0;
10
+ const time = value => Number.isSafeInteger(value) && value >= 0;
11
+ if (!exact(contract, 'version,endpointId,policyVersion,effect,deduplication,expiresAt,evidence') || contract.version !== 1 ||
12
+ !text(contract.endpointId) || !text(contract.policyVersion) ||
13
+ !exact(contract.effect, 'tenantId,subject,connectorId,operationId,effectKey,requestDigest,binding') ||
14
+ !exact(contract.deduplication, 'scope,windowStart,windowEnd,guarantee,rejectsKeyReuse') ||
15
+ !exact(contract.deduplication.scope, 'accountId,namespace') ||
16
+ !text(contract.deduplication.scope.accountId) || !text(contract.deduplication.scope.namespace) ||
17
+ contract.deduplication.guarantee !== 'same-key-same-request' || contract.deduplication.rejectsKeyReuse !== true ||
18
+ !time(contract.deduplication.windowStart) || !time(contract.deduplication.windowEnd) ||
19
+ contract.deduplication.windowStart >= contract.deduplication.windowEnd ||
20
+ !time(contract.expiresAt) || contract.expiresAt > contract.deduplication.windowEnd ||
21
+ !contract.evidence || Array.isArray(contract.evidence) || typeof contract.evidence !== 'object' ||
22
+ !Object.keys(contract.evidence).length) throw new TypeError('Invalid remote retry capability');
23
+ const expected = Object.fromEntries(['tenantId', 'subject', 'connectorId', 'operationId', 'effectKey', 'requestDigest', 'binding'].map(key => [key, entry[key]]));
24
+ if (Object.values(expected).some(value => value === undefined) ||
25
+ canonicalJson(contract.effect) !== canonicalJson(expected) ||
26
+ !/^[a-f0-9]{64}$/.test(entry.requestDigest) ||
27
+ createHash('sha256').update(canonicalJson(input)).digest('hex') !== entry.requestDigest) throw new Error('Retry capability binding conflict');
28
+ assertRetryCapabilityTime(contract);
29
+ return contract;
30
+ }
31
+ export function assertRetryCapabilityTime(contract) {
32
+ const now = Date.now();
33
+ if (now < contract.deduplication.windowStart || now >= contract.deduplication.windowEnd || now >= contract.expiresAt) throw new Error('Retry capability expired or not active');
34
+ }
35
+
@@ -0,0 +1,81 @@
1
+ import {strict as assert} from 'node:assert';
2
+ import {StorageCorruptionError} from './storage.js';
3
+
4
+ /**
5
+ * Conformance cases for any storage backend. `make()` returns {backend, corrupt(key)}: a fresh backend and a way to damage a stored
6
+ * document. `failWrite(backend)` is optional: when given, it must make the NEXT compareAndSwap fail part-way and throw; the case
7
+ * then checks that the old document is intact. Use: for (const c of storageConformanceCases(make)) test(c.name, c.run);
8
+ */
9
+ export function storageConformanceCases(make, {failWrite} = {}) {
10
+ const cases = [
11
+ {name: 'a missing key loads as null; create needs expectedVersion null and gives version 1', run: async () => {
12
+ const {backend} = make();
13
+ assert.equal(await backend.load('a'), null);
14
+ assert.deepEqual(await backend.compareAndSwap('a', null, {n: 1}), {ok: true, version: 1});
15
+ assert.deepEqual(await backend.load('a'), {version: 1, value: {n: 1}});
16
+ assert.deepEqual(await backend.compareAndSwap('b', 1, {n: 1}), {ok: false, reason: 'conflict', currentVersion: null}, 'updating what does not exist is a conflict');
17
+ }},
18
+ {name: 'versions only grow, and a stale or repeated create loses', run: async () => {
19
+ const {backend} = make();
20
+ await backend.compareAndSwap('a', null, {n: 1});
21
+ assert.deepEqual(await backend.compareAndSwap('a', null, {n: 9}), {ok: false, reason: 'conflict', currentVersion: 1});
22
+ assert.deepEqual(await backend.compareAndSwap('a', 1, {n: 2}), {ok: true, version: 2});
23
+ assert.deepEqual(await backend.compareAndSwap('a', 1, {n: 3}), {ok: false, reason: 'conflict', currentVersion: 2}, 'the old version is stale');
24
+ assert.deepEqual(await backend.load('a'), {version: 2, value: {n: 2}}, 'a lost write changes nothing');
25
+ }},
26
+ {name: 'concurrent writers from the same version: exactly one wins, and nothing is lost or mixed', run: async () => {
27
+ const {backend} = make();
28
+ await backend.compareAndSwap('a', null, {n: 0});
29
+ const results = await Promise.all(Array.from({length: 8}, (_, i) => backend.compareAndSwap('a', 1, {n: i + 1})));
30
+ const wins = results.filter(r => r.ok);
31
+ assert.equal(wins.length, 1); assert.equal(wins[0].version, 2);
32
+ const now = await backend.load('a');
33
+ assert.equal(now.version, 2); assert.ok(now.value.n >= 1 && now.value.n <= 8);
34
+ }},
35
+ {name: 'a read-modify-write loop with retries loses no update', run: async () => {
36
+ const {backend} = make();
37
+ await backend.compareAndSwap('c', null, {n: 0});
38
+ const bump = async () => { for (;;) { const cur = await backend.load('c'); const r = await backend.compareAndSwap('c', cur.version, {n: cur.value.n + 1}); if (r.ok) return; await new Promise(res => setTimeout(res, 1 + Math.random() * 4)); } };
39
+ await Promise.all(Array.from({length: 6}, bump));
40
+ assert.deepEqual(await backend.load('c'), {version: 7, value: {n: 6}});
41
+ }},
42
+ {name: 'what is returned is a copy: changing it does not change the store, and neither does changing what was stored', run: async () => {
43
+ const {backend} = make();
44
+ const value = {list: [1]};
45
+ await backend.compareAndSwap('a', null, value); value.list.push(2);
46
+ const got = await backend.load('a'); got.value.list.push(3);
47
+ assert.deepEqual((await backend.load('a')).value, {list: [1]});
48
+ }},
49
+ {name: 'a damaged document is reported, never returned, and is not overwritten by a write that trusts it', run: async () => {
50
+ const {backend, corrupt} = make();
51
+ await backend.compareAndSwap('a', null, {n: 1}); await backend.compareAndSwap('keep', null, {n: 1});
52
+ await corrupt('a');
53
+ await assert.rejects(backend.load('a'), StorageCorruptionError);
54
+ await assert.rejects(backend.compareAndSwap('a', 1, {n: 2}), StorageCorruptionError);
55
+ assert.deepEqual((await backend.load('keep')).value, {n: 1}, 'other documents are fine');
56
+ }},
57
+ {name: 'list shows the keys; bad keys and versions are programming errors', run: async () => {
58
+ const {backend} = make();
59
+ await backend.compareAndSwap('b', null, 1); await backend.compareAndSwap('a', null, 2);
60
+ assert.deepEqual(await backend.list(), ['a', 'b']);
61
+ for (const bad of ['', '../x', 'a/b', 'a b', '..', 'x'.repeat(121)]) await assert.rejects(backend.load(bad), TypeError, JSON.stringify(bad));
62
+ await assert.rejects(backend.compareAndSwap('a', 0, 1), TypeError);
63
+ await assert.rejects(backend.compareAndSwap('a', '1', 1), TypeError);
64
+ }},
65
+ {name: 'values are plain JSON: nested data, unicode and null survive exactly', run: async () => {
66
+ const {backend} = make();
67
+ const value = {s: 'é — 日本', n: null, a: [1, {b: [true, false]}], big: 'x'.repeat(100000)};
68
+ await backend.compareAndSwap('a', null, value);
69
+ assert.deepEqual((await backend.load('a')).value, value);
70
+ }}
71
+ ];
72
+ if (failWrite) cases.push({name: 'a write that fails part-way leaves the old document, and the key can be written again', run: async () => {
73
+ const {backend} = make();
74
+ await backend.compareAndSwap('a', null, {n: 1});
75
+ await failWrite(backend);
76
+ await assert.rejects(backend.compareAndSwap('a', 1, {n: 2}));
77
+ assert.deepEqual(await backend.load('a'), {version: 1, value: {n: 1}});
78
+ assert.deepEqual(await backend.compareAndSwap('a', 1, {n: 3}), {ok: true, version: 2});
79
+ }});
80
+ return cases;
81
+ }
package/src/storage.js ADDED
@@ -0,0 +1,87 @@
1
+ import {createHash, randomBytes} from 'node:crypto';
2
+ import {mkdir, open, readFile, rename, unlink, readdir} from 'node:fs/promises';
3
+ import {join} from 'node:path';
4
+ import {canonicalJson} from './canonical-json.js';
5
+
6
+ /**
7
+ * Storage backend contract (P3): versioned documents with compare-and-swap. This is what a state store needs from the place
8
+ * its data lives, so the place can be a local folder, a database or an object store without the code above it changing.
9
+ *
10
+ * - load(key) → {version, value} or null when the key does not exist. A document whose stored checksum does not match its
11
+ * content is never returned: load throws StorageCorruptionError.
12
+ * - compareAndSwap(key, expectedVersion, value) → {ok: true, version} or {ok: false, reason: 'conflict', currentVersion}.
13
+ * expectedVersion is null to create (fails if the key exists) or the version last read. Versions only grow, per key.
14
+ * The write is all-or-nothing: after a failure or a crash a reader sees the old document or the new one, never a mix.
15
+ * - list() → the keys that exist.
16
+ * Values are plain JSON. No claim is made about network file systems: a backend states what it guarantees, and the
17
+ * conformance suite (storage-conformance.js) is what shows it. The file backend is for one machine's local disk.
18
+ */
19
+ export class StorageCorruptionError extends Error {
20
+ constructor(key) { super(`Stored document "${key}" failed its integrity check`); this.name = 'StorageCorruptionError'; this.key = key; }
21
+ }
22
+
23
+ const keyOk = key => { if (typeof key !== 'string' || !/^[A-Za-z0-9._-]{1,120}$/.test(key) || key === '.' || key === '..') throw new TypeError('Storage key must be 1 to 120 letters, digits, dots, dashes or underscores'); };
24
+ export const checksumOf = (version, value) => createHash('sha256').update(String(version)).update('\n').update(canonicalJson(value)).digest('hex');
25
+ const envelope = (version, value) => ({v: 1, version, value, checksum: checksumOf(version, value)});
26
+ const open_ = (key, text) => {
27
+ let e; try { e = JSON.parse(text); } catch { throw new StorageCorruptionError(key); }
28
+ if (!e || e.v !== 1 || !Number.isSafeInteger(e.version) || e.version < 1 || e.checksum !== checksumOf(e.version, e.value)) throw new StorageCorruptionError(key);
29
+ return {version: e.version, value: structuredClone(e.value)};
30
+ };
31
+ const conflict = currentVersion => ({ok: false, reason: 'conflict', currentVersion});
32
+ const checkVersion = expected => { if (expected !== null && !(Number.isSafeInteger(expected) && expected >= 1)) throw new TypeError('expectedVersion must be null or a version number'); };
33
+
34
+ /** In-memory reference backend. `corrupt(key)` damages a stored document (for tests). */
35
+ export function createMemoryStorageBackend() {
36
+ const docs = new Map(); // key -> JSON text of the envelope
37
+ return {
38
+ async load(key) { keyOk(key); return docs.has(key) ? open_(key, docs.get(key)) : null; },
39
+ async compareAndSwap(key, expected, value) {
40
+ keyOk(key); checkVersion(expected);
41
+ const text = docs.get(key); const current = text === undefined ? null : open_(key, text).version;
42
+ if (expected !== current) return conflict(current);
43
+ const version = (current ?? 0) + 1;
44
+ docs.set(key, JSON.stringify(envelope(version, structuredClone(value))));
45
+ return {ok: true, version};
46
+ },
47
+ async list() { return [...docs.keys()].sort(); },
48
+ corrupt(key) { docs.set(key, docs.get(key).replace('"checksum":"', '"checksum":"0')); }
49
+ };
50
+ }
51
+
52
+ /**
53
+ * File backend for one machine's local disk. Each write goes to a temporary file, is flushed, then renamed over the document;
54
+ * a crash leaves the old document. Two writers are serialised by a short-lived exclusive-create guard file per key, so the
55
+ * version check and the write are one step. A guard left by a crash is reported as a conflict until a person removes it
56
+ * (`<dir>/<key>.guard`); it is never taken over on a timer.
57
+ */
58
+ // On Windows a file that someone is reading at that moment cannot be replaced or opened for a split second (EPERM, EACCES, EBUSY).
59
+ const busy = error => process.platform === 'win32' && ['EPERM', 'EACCES', 'EBUSY'].includes(error?.code);
60
+ async function patiently(action) { for (let attempt = 0; ; attempt++) { try { return await action(); } catch (error) { if (!busy(error) || attempt >= 200) throw error; await new Promise(r => setTimeout(r, Math.min(5 + attempt * 2, 25))); } } }
61
+
62
+ export function createFileStorageBackend(dir) {
63
+ if (typeof dir !== 'string' || !dir) throw new TypeError('Storage directory required');
64
+ const path = key => join(dir, key + '.doc.json');
65
+ const read = async key => { try { return await patiently(() => readFile(path(key), 'utf8')); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
66
+ return {
67
+ async load(key) { keyOk(key); const text = await read(key); return text === null ? null : open_(key, text); },
68
+ async compareAndSwap(key, expected, value) {
69
+ keyOk(key); checkVersion(expected); await mkdir(dir, {recursive: true});
70
+ const guard = join(dir, key + '.guard');
71
+ let handle;
72
+ try { handle = await open(guard, 'wx', 0o600); } catch (error) { if (error.code === 'EEXIST' || (process.platform === 'win32' && ['EPERM', 'EACCES', 'EBUSY'].includes(error.code))) return conflict((await this.load(key))?.version ?? null); throw error; }
73
+ const temp = join(dir, `${key}.${randomBytes(6).toString('hex')}.tmp`);
74
+ try {
75
+ const text = await read(key); const current = text === null ? null : open_(key, text).version;
76
+ if (expected !== current) return conflict(current);
77
+ const version = (current ?? 0) + 1;
78
+ const file = await open(temp, 'w', 0o600);
79
+ try { await file.writeFile(JSON.stringify(envelope(version, structuredClone(value))), 'utf8'); await file.sync(); } finally { await file.close(); }
80
+ await patiently(() => rename(temp, path(key)));
81
+ return {ok: true, version};
82
+ } finally { await handle.close().catch(() => {}); await unlink(guard).catch(() => {}); await unlink(temp).catch(() => {}); }
83
+ },
84
+ async list() { try { return (await readdir(dir)).filter(n => n.endsWith('.doc.json')).map(n => n.slice(0, -9)).sort(); } catch (error) { if (error.code === 'ENOENT') return []; throw error; } },
85
+ corruptForTest: async key => { const text = await read(key); const h = await open(path(key), 'w'); try { await h.writeFile(text.replace('"checksum":"', '"checksum":"0'), 'utf8'); } finally { await h.close(); } }
86
+ };
87
+ }
@@ -1,32 +1,102 @@
1
1
  import {randomBytes} from 'node:crypto';
2
- import {open,mkdir,readFile,unlink} from 'node:fs/promises';
2
+ import {open,mkdir,readFile,rename,unlink,writeFile,appendFile} from 'node:fs/promises';
3
3
  import {dirname} from 'node:path';
4
4
  import {FileStateStore} from './index.js';
5
+ import {currentIdentity,probeOwner} from './process-identity.js';
5
6
 
6
7
  export class StateBusyError extends Error {
7
8
  constructor(){super('State store is locked by another transaction');this.name='StateBusyError';}
8
9
  }
9
- /** Cooperating processes, one canonical path. No stale-lock takeover or implicit retry. */
10
+ export class StateLockLostError extends Error {
11
+ constructor(){super('The state lock was taken over while this write was waiting; the write was refused and nothing was changed');this.name='StateLockLostError';}
12
+ }
13
+ // On Windows a lock file that another process is deleting at that moment (or still holds open) cannot be created: the system answers
14
+ // EPERM, EACCES or EBUSY instead of EEXIST. That is contention, not a permission problem, so it is reported as busy like any other lock.
15
+ const contended=error=>process.platform==='win32'&&['EPERM','EACCES','EBUSY'].includes(error?.code);
16
+ const parse=text=>{try{const v=JSON.parse(text);return v&&v.v===2&&typeof v.lease==='string'?v:null;}catch{return null;}};
17
+ const readFence=async path=>{try{const n=Number((await readFile(path,'utf8')).trim());return Number.isSafeInteger(n)&&n>=0?n:0;}catch(error){if(error.code==='ENOENT')return 0;throw error;}};
18
+ const processNonce=randomBytes(8).toString('hex');
19
+
20
+ /**
21
+ * Cooperating processes, one canonical path. The lock file is a record (version 2): a lease, who holds it (pid and start time,
22
+ * host, boot) and the fence it saw. A lock left by a crashed process is recovered automatically ONLY when its owner is provably
23
+ * gone (same machine, same boot, no such process or a different process with that pid; see process-identity.js) and the platform
24
+ * is enabled for recovery; every other case stays manual (StateBusyError). Recovery takes the lock by an atomic rename, raises a
25
+ * fence counter (`<state>.fence`) and logs it (`<state>.recovery.log`). A writer that was merely paused and wakes up after a
26
+ * takeover finds its lock gone and its write is refused (StateLockLostError). No age or timeout is ever used as proof.
27
+ * Options: `recovery: false` turns recovery off; `recovery: {probe, platform, exec, onRecovered}` are for tests and hosts.
28
+ */
10
29
  export class TransactionalStateStore extends FileStateStore {
30
+ async #acquire(lock,record){
31
+ let handle;
32
+ try{handle=await open(lock,'wx',0o600);}catch(error){if(error.code==='EEXIST'||contended(error))throw new StateBusyError();throw error;}
33
+ try{await handle.writeFile(record,'utf8');await handle.sync();}finally{await handle.close();}
34
+ }
35
+ /** Take over a lock whose owner is provably gone. Returns true when the lock was cleared and acquiring can be retried. */
36
+ async #recover(lock,fencePath){
37
+ const options=this.recovery;
38
+ if(options===false)return false;
39
+ const raw=await readFile(lock,'utf8').catch(error=>{if(error.code==='ENOENT')return null;throw error;});
40
+ if(raw===null)return true; // it was released in the meantime
41
+ const record=parse(raw);
42
+ if(!record)return false; // a version 1 or unreadable lock: manual
43
+ const probe=options?.probe??(r=>probeOwner(r,options??{}));
44
+ const first=await probe(record);
45
+ if(first.verdict!=='gone')return false;
46
+ // Claim by rename (then the marker below picks exactly one winner); a lock that changed meanwhile is not what was judged.
47
+ const claimed=lock+'.recovering.'+randomBytes(8).toString('hex');
48
+ try{await rename(lock,claimed);}catch{return false;}
49
+ try{
50
+ const seen=await readFile(claimed,'utf8').catch(()=>null);
51
+ const again=seen===raw?await probe(record):{verdict:'changed'};
52
+ if(again.verdict!=='gone'){
53
+ // Not the lock that was judged (or the evidence changed): put it back if the way is free, otherwise leave it for a person.
54
+ try{const h=await open(lock,'wx',0o600);try{await h.writeFile(seen??raw,'utf8');await h.sync();}finally{await h.close();}await unlink(claimed);}catch{/* left as .recovering for a person */}
55
+ return false;
56
+ }
57
+ // On Windows two concurrent renames of the same file can both succeed, so the rename alone does not pick one winner. Exactly one
58
+ // recoverer per abandoned lease creates this marker (exclusive create); the other drops its copy and just tries to acquire.
59
+ try{const marker=await open(this.path+'.recovered-'+record.lease.slice(0,24).replace(/[^0-9a-f]/gi,'_'),'wx',0o600);await marker.close();}
60
+ catch{await unlink(claimed).catch(()=>{});return true;}
61
+ const fence=(await readFence(fencePath))+1;
62
+ await writeFile(fencePath,String(fence)+'\n','utf8');
63
+ const entry={at:new Date().toISOString(),recoveredPid:record.pid,reason:first.reason,fence,host:record.hostId};
64
+ await appendFile(this.path+'.recovery.log',JSON.stringify(entry)+'\n','utf8').catch(()=>{});
65
+ try{options?.onRecovered?.(entry);}catch{/* a host callback never blocks recovery */}
66
+ await unlink(claimed);
67
+ return true;
68
+ }catch{return false;}
69
+ }
11
70
  async transaction(update) {
12
71
  if(typeof update!=='function')throw new TypeError('Transaction callback required');
13
- const lock=this.path+'.lock',lease=randomBytes(24).toString('hex');
72
+ const lock=this.path+'.lock',fencePath=this.path+'.fence';
14
73
  await mkdir(dirname(this.path),{recursive:true});
15
- let handle;
16
- try{handle=await open(lock,'wx',0o600);}catch(error){if(error.code==='EEXIST')throw new StateBusyError();throw error;}
74
+ const me=await currentIdentity(this.recovery&&this.recovery.platform?{platform:this.recovery.platform,exec:this.recovery.exec}:{});
75
+ const lease=randomBytes(24).toString('hex');
76
+ const recordFor=fence=>JSON.stringify({v:2,lease,owner:processNonce,pid:me.pid,processStart:me.processStart,hostId:me.hostId,bootStart:me.bootStart,fence,createdAt:new Date().toISOString()});
77
+ try{await this.#acquire(lock,recordFor(await readFence(fencePath)));}
78
+ catch(error){
79
+ if(!(error instanceof StateBusyError))throw error;
80
+ if(!(await this.#recover(lock,fencePath).catch(()=>false)))throw error;
81
+ await this.#acquire(lock,recordFor(await readFence(fencePath)));
82
+ }
83
+ const held=await readFile(lock,'utf8');
84
+ let lost=false;
17
85
  try {
18
- try{await handle.writeFile(lease,'utf8');await handle.sync();}finally{await handle.close();}
19
86
  const previous=await super.load();
20
87
  const next=await update(structuredClone(previous));
21
88
  // Clone before awaiting I/O so a caller cannot change the snapshot during save.
22
89
  const snapshot=structuredClone(next);
90
+ // A writer that was paused while someone took the lock over must not write: its lock is no longer the one in place.
91
+ const still=await readFile(lock,'utf8').catch(error=>{if(error.code==='ENOENT')return null;throw error;});
92
+ if(still!==held){lost=true;throw new StateLockLostError();}
23
93
  await super.save(snapshot);
24
94
  return structuredClone(snapshot);
25
95
  } finally {
26
96
  // Never remove a replacement lock if an external operator changed ownership.
27
97
  const owner=await readFile(lock,'utf8').catch(error=>{if(error.code==='ENOENT')return null;throw error;});
28
- if(owner===lease)await unlink(lock);
29
- else throw new Error('State lock ownership changed; manual recovery required');
98
+ if(owner===held)await unlink(lock);
99
+ else if(!lost)throw new Error('State lock ownership changed; manual recovery required');
30
100
  }
31
101
  }
32
102
  async save(state){const snapshot=structuredClone(state);await this.transaction(()=>snapshot);}