@aetheros/kernel-runtime 0.0.0-stage → 0.1.1

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,71 @@
1
+ const EFFECTS=new Set(['read','write']);
2
+ const clone=value=>structuredClone(value);
3
+ const text=(value,label)=>{if(typeof value!=='string'||!value.trim())throw new Error(label+' required');return value;};
4
+
5
+ function stableJson(value,label){
6
+ const active=new Set();
7
+ const visit=item=>{
8
+ if(item===null||typeof item==='string'||typeof item==='boolean')return;
9
+ if(typeof item==='number'&&Number.isFinite(item))return;
10
+ if(typeof item!=='object'||ArrayBuffer.isView(item)||item instanceof Date||active.has(item))throw new Error(label+' must be stable JSON');
11
+ active.add(item);
12
+ if(Array.isArray(item)){for(let index=0;index<item.length;index+=1){if(!Object.hasOwn(item,index))throw new Error(label+' must be stable JSON');visit(item[index]);}}
13
+ else{const prototype=Object.getPrototypeOf(item);if(prototype!==Object.prototype&&prototype!==null)throw new Error(label+' must be stable JSON');for(const child of Object.values(item))visit(child);}
14
+ active.delete(item);
15
+ };
16
+ visit(value);
17
+ const encoded=JSON.stringify(value);
18
+ if(encoded===undefined)throw new Error(label+' must be stable JSON');
19
+ const decoded=JSON.parse(encoded);
20
+ if(JSON.stringify(decoded)!==encoded)throw new Error(label+' must be stable JSON');
21
+ return decoded;
22
+ }
23
+
24
+ /**
25
+ * Host-controlled connector boundary. Actor and tenant strings are assertions
26
+ * supplied by the host; authorize must verify them against its own identity system.
27
+ */
28
+ export class ConnectorRegistry {
29
+ #connectors=new Map();
30
+ constructor({authorize}={}){
31
+ if(authorize!==undefined&&typeof authorize!=='function')throw new TypeError('Connector authorization must be a function');
32
+ this.authorize=authorize;
33
+ }
34
+ register({id,operations}){
35
+ text(id,'connector id');
36
+ if(this.#connectors.has(id))throw new Error('Connector already registered');
37
+ if(!Array.isArray(operations)||!operations.length)throw new Error('Connector operations required');
38
+ const selected=new Map();
39
+ for(const operation of operations){
40
+ if(!operation||typeof operation!=='object')throw new Error('Invalid connector operation');
41
+ text(operation.id,'operation id');
42
+ if(selected.has(operation.id)||!EFFECTS.has(operation.effect)||typeof operation.execute!=='function')throw new Error('Invalid connector operation');
43
+ if(operation.description!==undefined)text(operation.description,'operation description');
44
+ selected.set(operation.id,{effect:operation.effect,execute:operation.execute,description:operation.description});
45
+ }
46
+ this.#connectors.set(id,selected);return this;
47
+ }
48
+ list(){
49
+ return [...this.#connectors].map(([id,operations])=>({
50
+ id,
51
+ operations:[...operations].map(([operationId,{effect,description}])=>({id:operationId,effect,...(description&&{description})}))
52
+ }));
53
+ }
54
+ async invoke({connectorId,operationId,input,actor,tenantId,idempotencyKey,signal}){
55
+ text(connectorId,'connector id');text(operationId,'operation id');text(actor,'actor');text(tenantId,'tenantId');
56
+ signal?.throwIfAborted();
57
+ const operations=this.#connectors.get(connectorId);
58
+ if(!operations)throw new Error('Unknown connector');
59
+ const operation=operations.get(operationId);
60
+ if(!operation)throw new Error('Unknown connector operation');
61
+ if(operation.effect==='write')text(idempotencyKey,'idempotency key');
62
+ else if(idempotencyKey!==undefined)text(idempotencyKey,'idempotency key');
63
+ const safeInput=stableJson(input,'Connector input');
64
+ if(typeof this.authorize!=='function')throw new Error('Connector authorization required');
65
+ const request={connectorId,operationId,effect:operation.effect,actor,tenantId,input:clone(safeInput),...(idempotencyKey&&{idempotencyKey})};
66
+ if(await this.authorize(clone(request))!==true)throw new Error('Connector invocation denied');
67
+ signal?.throwIfAborted();
68
+ const result=await operation.execute({input:clone(safeInput),actor,tenantId,...(idempotencyKey&&{idempotencyKey}),...(signal&&{signal})});
69
+ return stableJson(result,'Connector result');
70
+ }
71
+ }
@@ -0,0 +1,18 @@
1
+ import {canonicalJson} from './canonical-json.js';
2
+
3
+ // Internal shared validator: snapshot before any await or host callback.
4
+ export function snapshotEffectBinding(binding) {
5
+ if (binding === undefined) return undefined;
6
+ let value;
7
+ try { value = JSON.parse(canonicalJson(binding)); } catch { throw new TypeError('Invalid effect binding'); }
8
+ if (!value || Array.isArray(value) || Object.keys(value).sort().join(',') !== 'claimId,decisionId,generation,reviewId' ||
9
+ ['reviewId', 'decisionId', 'claimId'].some(key => typeof value[key] !== 'string' || !value[key].trim()) ||
10
+ !Number.isSafeInteger(value.generation) || value.generation < 1) throw new TypeError('Invalid effect binding');
11
+ return value;
12
+ }
13
+
14
+ export function assertEffectBinding(stored, supplied) {
15
+ const expected = snapshotEffectBinding(stored);
16
+ if ((expected === undefined) !== (supplied === undefined) ||
17
+ (expected !== undefined && canonicalJson(expected) !== canonicalJson(supplied))) throw new Error('EFFECT_BINDING_CONFLICT');
18
+ }
@@ -0,0 +1,142 @@
1
+ import {createHash} from 'node:crypto';
2
+ import {TransactionalStateStore} from './transactions.js';
3
+ import {canonicalJson} from './canonical-json.js';
4
+ import {snapshotEffectBinding, assertEffectBinding} from './effect-binding.js';
5
+
6
+ const required = (value, label) => {
7
+ if (typeof value !== 'string' || !value.trim()) throw new TypeError(label + ' required');
8
+ return value;
9
+ };
10
+ const clone = value => structuredClone(value);
11
+ const canonical = value => {
12
+ try { return canonicalJson(value); } catch { throw new TypeError('JSON input required'); }
13
+ };
14
+ const digest = value => createHash('sha256').update(canonical(value)).digest('hex');
15
+ const slot = (tenantId, effectKey) => createHash('sha256').update(JSON.stringify([tenantId, effectKey])).digest('hex');
16
+
17
+ /**
18
+ * Durable, fail-closed local effect journal. This is an intent/state primitive,
19
+ * Cooperating processes use the same canonical path and transactional lock.
20
+ * Busy or abandoned locks fail closed; there is no stale-lock takeover.
21
+ * NOT a remote exactly-once guarantee. Authenticate/review every dispatch;
22
+ * never replay a dispatched non-idempotent effect automatically.
23
+ */
24
+ export class FileEffectJournal {
25
+ #store;
26
+ constructor(path, options) { this.#store = new TransactionalStateStore(path, options); }
27
+ async #mutate(update) {
28
+ let result;
29
+ await this.#store.exclusive(() => this.#store.transaction(async state => {
30
+ result = await update(state);
31
+ return state;
32
+ }));
33
+ return result;
34
+ }
35
+ async prepare({tenantId, subject, connectorId, operationId, effectKey, input, kind = 'non-idempotent-write', binding} = {}) {
36
+ for (const [name, value] of Object.entries({tenantId, subject, connectorId, operationId, effectKey})) required(value, name);
37
+ if (!['non-idempotent-write', 'idempotent-write'].includes(kind)) throw new TypeError('Invalid write classification');
38
+ const requestDigest = digest(input);
39
+ const safeBinding = snapshotEffectBinding(binding);
40
+ return this.#mutate(async state => {
41
+ const key = slot(tenantId, effectKey);
42
+ const prior = state.tenants[key];
43
+ if (prior) {
44
+ assertEffectBinding(prior.binding, safeBinding);
45
+ if (prior.tenantId !== tenantId || prior.subject !== subject || prior.connectorId !== connectorId || prior.operationId !== operationId || prior.requestDigest !== requestDigest || prior.kind !== kind) throw new Error('EFFECT_KEY_CONFLICT');
46
+ return clone(prior);
47
+ }
48
+ const entry = {tenantId, subject, connectorId, operationId, effectKey, requestDigest, kind, ...(safeBinding && {binding: safeBinding}), status:'prepared'};
49
+ state.tenants[key] = entry;
50
+ return clone(entry);
51
+ });
52
+ }
53
+ async get({tenantId, effectKey} = {}) {
54
+ required(tenantId, 'tenantId'); required(effectKey, 'effectKey');
55
+ const state = await this.#store.load();
56
+ return clone(state.tenants[slot(tenantId, effectKey)] ?? null);
57
+ }
58
+ async markDispatched({tenantId, effectKey, binding} = {}) {
59
+ return this.#transition(tenantId, effectKey, 'dispatched', undefined, binding);
60
+ }
61
+ async confirm({tenantId, effectKey, receipt, binding} = {}) {
62
+ // The host must verify receipt against the remote operation before confirming.
63
+ if (receipt === undefined) throw new TypeError('Verified receipt required');
64
+ const safeReceipt = JSON.parse(canonical(receipt));
65
+ return this.#transition(tenantId, effectKey, 'confirmed', safeReceipt, binding);
66
+ }
67
+ async requireReconciliation({tenantId, effectKey, binding} = {}) {
68
+ return this.#transition(tenantId, effectKey, 'reconciliation_required', undefined, binding);
69
+ }
70
+ // Trusted-host primitive: authenticate, review and verify evidence outside this store.
71
+ // Compare the complete reviewed snapshot under the same lock as writer completion.
72
+ async confirmReconciled({expectedEntry, receipt, decisionId, subject, beforeCommit} = {}) {
73
+ const expected = JSON.parse(canonical(expectedEntry));
74
+ if (!expected || !['dispatched', 'reconciliation_required'].includes(expected.status) ||
75
+ !snapshotEffectBinding(expected.binding)) throw new TypeError('Bound uncertain effect required');
76
+ required(expected.tenantId, 'tenantId'); required(expected.effectKey, 'effectKey');
77
+ required(decisionId, 'decisionId'); required(subject, 'subject');
78
+ if (receipt === undefined || typeof beforeCommit !== 'function') throw new TypeError('Verified receipt and commit guard required');
79
+ const safeReceipt = JSON.parse(canonical(receipt));
80
+ return this.#mutate(async state => {
81
+ const key = slot(expected.tenantId, expected.effectKey), prior = state.tenants[key];
82
+ if (!prior || canonical(prior) !== canonical(expected)) throw new Error('EFFECT_REVIEW_CONFLICT');
83
+ if (await beforeCommit() !== true) throw new Error('EFFECT_RECONCILIATION_DENIED');
84
+ const next = {...prior, status:'confirmed', receipt:safeReceipt,
85
+ reconciliation:{decisionId, subject}};
86
+ state.tenants[key] = next;
87
+ return clone(next);
88
+ });
89
+ }
90
+ // Authenticated host recovery uses a complete reviewed snapshot, not binding alone.
91
+ async advancePreparedClaim({expectedEntry, nextGeneration, decisionId, subject, beforeCommit} = {}) {
92
+ const expected = JSON.parse(canonical(expectedEntry));
93
+ if (!expected || expected.status !== 'prepared' || !snapshotEffectBinding(expected.binding)) throw new TypeError('Bound prepared effect required');
94
+ required(expected.tenantId, 'tenantId'); required(expected.effectKey, 'effectKey');
95
+ required(decisionId, 'decisionId'); required(subject, 'subject');
96
+ if (!Number.isSafeInteger(nextGeneration) || nextGeneration <= expected.binding.generation || typeof beforeCommit !== 'function') throw new TypeError('Newer generation and commit guard required');
97
+ return this.#mutate(async state => {
98
+ const key = slot(expected.tenantId, expected.effectKey), prior = state.tenants[key];
99
+ if (!prior || canonical(prior) !== canonical(expected)) throw new Error('EFFECT_REVIEW_CONFLICT');
100
+ if (await beforeCommit() !== true) throw new Error('EFFECT_CLAIM_RECOVERY_DENIED');
101
+ const next = {...prior, binding:{...prior.binding, generation:nextGeneration},
102
+ claimRecovery:{decisionId, subject, previousGeneration:prior.binding.generation}};
103
+ state.tenants[key] = next;
104
+ return clone(next);
105
+ });
106
+ }
107
+ // Trusted-host CAS only; never use this to take over a dispatched action.
108
+ async advanceClaim({tenantId, effectKey, binding, nextGeneration} = {}) {
109
+ required(tenantId, 'tenantId'); required(effectKey, 'effectKey');
110
+ const expected = snapshotEffectBinding(binding);
111
+ if (!expected || !Number.isSafeInteger(nextGeneration) || nextGeneration <= expected.generation) throw new TypeError('Newer claim generation required');
112
+ return this.#mutate(async state => {
113
+ const key = slot(tenantId, effectKey), prior = state.tenants[key];
114
+ if (!prior) throw new Error('UNKNOWN_EFFECT');
115
+ assertEffectBinding(prior.binding, expected);
116
+ if (prior.status !== 'prepared') throw new Error('EFFECT_REPLAY_BLOCKED');
117
+ const next = {...prior, binding: {...expected, generation: nextGeneration}};
118
+ state.tenants[key] = next;
119
+ return clone(next);
120
+ });
121
+ }
122
+ async #transition(tenantId, effectKey, target, receipt, binding) {
123
+ required(tenantId, 'tenantId'); required(effectKey, 'effectKey');
124
+ const expected = snapshotEffectBinding(binding);
125
+ return this.#mutate(async state => {
126
+ const key = slot(tenantId, effectKey), prior = state.tenants[key];
127
+ if (!prior) throw new Error('UNKNOWN_EFFECT');
128
+ assertEffectBinding(prior.binding, expected);
129
+ if (prior.status === 'confirmed') {
130
+ if (target === 'confirmed' && digest(prior.receipt) === digest(receipt)) return clone(prior);
131
+ throw new Error('EFFECT_ALREADY_CONFIRMED');
132
+ }
133
+ if (target === 'dispatched' && prior.status !== 'prepared') throw new Error('EFFECT_REPLAY_BLOCKED');
134
+ if (target === 'confirmed' && prior.status !== 'dispatched' && prior.status !== 'reconciliation_required') throw new Error('EFFECT_NOT_DISPATCHED');
135
+ if (target === 'reconciliation_required' && prior.status !== 'dispatched' && prior.status !== 'reconciliation_required') throw new Error('EFFECT_NOT_DISPATCHED');
136
+ const next = {...prior, status:target, ...(target === 'confirmed' ? {receipt} : {})};
137
+ state.tenants[key] = next;
138
+ return clone(next);
139
+ });
140
+ }
141
+ }
142
+
@@ -0,0 +1,61 @@
1
+ import {createCipheriv, createDecipheriv, randomBytes} from 'node:crypto';
2
+ import {verifyStateBackup} from './backups.js';
3
+
4
+ const FORMAT = 'aetheros-encrypted-state-backup';
5
+ const ALGORITHM = 'aes-256-gcm';
6
+ const MAX_BYTES = 16 * 1024 * 1024 + 512;
7
+ const FIELDS = ['algorithm', 'ciphertext', 'format', 'iv', 'keyId', 'tag', 'tenantId', 'version'];
8
+ function context(keyId, tenantId) {
9
+ for (const [value, max] of [[keyId, 128], [tenantId, 256]]) {
10
+ if (typeof value !== 'string' || !value.trim() || value.length > max) throw new Error('Invalid encryption context');
11
+ }
12
+ return {format: FORMAT, version: 1, algorithm: ALGORITHM, keyId, tenantId};
13
+ }
14
+ function keyCopy(key) {
15
+ if (!Buffer.isBuffer(key) || key.length !== 32) throw new Error('A 32-byte encryption key is required');
16
+ return Buffer.from(key);
17
+ }
18
+ function decode(value, max, exact) {
19
+ if (typeof value !== 'string' || !value.length || value.length > 4 * Math.ceil(max / 3) || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) throw new Error('Invalid encrypted backup encoding');
20
+ const bytes = Buffer.from(value, 'base64');
21
+ if (bytes.length > max || (exact !== undefined && bytes.length !== exact) || bytes.toString('base64') !== value) throw new Error('Invalid encrypted backup encoding');
22
+ return bytes;
23
+ }
24
+ function plainBackup(checked) {
25
+ return {format: 'aetheros-state-backup', version: 1, state: checked.state, digest: checked.digest};
26
+ }
27
+
28
+ /** Keys are supplied by the host; no key material is retained in the envelope. */
29
+ export function encryptStateBackup(backup, {key, keyId, tenantId} = {}) {
30
+ const header = context(keyId, tenantId), secret = keyCopy(key);
31
+ try {
32
+ const plaintext = Buffer.from(JSON.stringify(plainBackup(verifyStateBackup(backup))));
33
+ if (plaintext.length > MAX_BYTES) throw new Error('Encrypted backup exceeds size limit');
34
+ const iv = randomBytes(12), cipher = createCipheriv(ALGORITHM, secret, iv);
35
+ cipher.setAAD(Buffer.from(JSON.stringify(header)));
36
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
37
+ return {...header, iv: iv.toString('base64'), ciphertext: ciphertext.toString('base64'), tag: cipher.getAuthTag().toString('base64')};
38
+ } finally { secret.fill(0); }
39
+ }
40
+
41
+ /** Verify host-selected tenant/key context and GCM authentication before returning any state. */
42
+ export function decryptStateBackup(envelope, {key, keyId, tenantId} = {}) {
43
+ const header = context(keyId, tenantId), secret = keyCopy(key);
44
+ try {
45
+ if (!envelope || Object.getPrototypeOf(envelope) !== Object.prototype || Reflect.ownKeys(envelope).length !== FIELDS.length) throw new Error('Invalid encrypted backup envelope');
46
+ const descriptors = Object.getOwnPropertyDescriptors(envelope);
47
+ if (Reflect.ownKeys(envelope).some(field => !FIELDS.includes(field) || !descriptors[field].enumerable || !Object.hasOwn(descriptors[field], 'value'))) throw new Error('Invalid encrypted backup envelope');
48
+ for (const [field, value] of Object.entries(header)) if (envelope[field] !== value) throw new Error('Encrypted backup context mismatch');
49
+ const iv = decode(envelope.iv, 12, 12), tag = decode(envelope.tag, 16, 16), ciphertext = decode(envelope.ciphertext, MAX_BYTES);
50
+ let plaintext;
51
+ try {
52
+ const decipher = createDecipheriv(ALGORITHM, secret, iv);
53
+ decipher.setAAD(Buffer.from(JSON.stringify(header))); decipher.setAuthTag(tag);
54
+ plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
55
+ } catch { throw new Error('Encrypted backup authentication failed'); }
56
+ let parsed;
57
+ try { parsed = JSON.parse(plaintext.toString('utf8')); }
58
+ catch { throw new Error('Invalid encrypted backup payload'); }
59
+ return plainBackup(verifyStateBackup(parsed));
60
+ } finally { secret.fill(0); }
61
+ }
@@ -0,0 +1,35 @@
1
+ import {TransactionalStateStore} from './transactions.js';
2
+ import {createStateBackup} from './backups.js';
3
+ import {encryptStateBackup, decryptStateBackup} from './encrypted-backups.js';
4
+
5
+ const id = (value, label, max) => {
6
+ if (typeof value !== 'string' || !value.trim() || value.length > max) throw new Error('Invalid ' + label);
7
+ return value;
8
+ };
9
+ const keyCopy = key => {
10
+ if (!Buffer.isBuffer(key) || key.length !== 32) throw new Error('A 32-byte state encryption key is required');
11
+ return Buffer.from(key);
12
+ };
13
+
14
+ /** Encrypt live snapshots and temporary files while retaining local transaction semantics. */
15
+ export function createEncryptedStateStore(path, {key, keyId, tenantId, readKeys = []} = {}) {
16
+ id(keyId, 'state key id', 128); id(tenantId, 'state storage context', 256);
17
+ if (!Array.isArray(readKeys) || readKeys.length > 32) throw new Error('Invalid state read-key ring');
18
+ const keys = new Map([[keyId, keyCopy(key)]]);
19
+ for (const entry of readKeys) {
20
+ if (!entry || keys.has(id(entry.keyId, 'state read-key id', 128))) throw new Error('Duplicate state key id');
21
+ keys.set(entry.keyId, keyCopy(entry.key));
22
+ }
23
+ const codec = Object.freeze({
24
+ async encode(state) {
25
+ const backup = await createStateBackup({load: async () => state});
26
+ return encryptStateBackup(backup, {key: keys.get(keyId), keyId, tenantId});
27
+ },
28
+ async decode(envelope) {
29
+ const selected = envelope?.keyId, secret = keys.get(selected);
30
+ if (!secret) throw new Error('Encrypted state key unavailable');
31
+ return decryptStateBackup(envelope, {key: secret, keyId: selected, tenantId}).state;
32
+ }
33
+ });
34
+ return new TransactionalStateStore(path, {codec});
35
+ }
@@ -0,0 +1,62 @@
1
+ import {canonicalJson} from './canonical-json.js';
2
+ import {createStateBackup} from './backups.js';
3
+
4
+ function version(value) {
5
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error('Invalid state envelope version');
6
+ return value;
7
+ }
8
+ function copy(value) {
9
+ const encoded = canonicalJson(value);
10
+ if (Buffer.byteLength(encoded) > 16 * 1024 * 1024) throw new Error('State import exceeds size limit');
11
+ return JSON.parse(JSON.stringify(value));
12
+ }
13
+ function definitions(migrations) {
14
+ if (!Array.isArray(migrations) || migrations.length > 256) throw new Error('Bounded envelope migrations required');
15
+ const byVersion = new Map();
16
+ for (const item of migrations) {
17
+ if (!item || typeof item !== 'object') throw new Error('Invalid envelope migration');
18
+ const from = version(item.from), to = version(item.to);
19
+ if (to !== from + 1 || byVersion.has(from) || typeof item.migrate !== 'function') throw new Error('Invalid envelope migration');
20
+ if (item.name !== undefined && (typeof item.name !== 'string' || !item.name.trim())) throw new Error('Invalid envelope migration name');
21
+ byVersion.set(from, {from, to, migrate: item.migrate, ...(item.name && {name: item.name})});
22
+ }
23
+ return byVersion;
24
+ }
25
+ function plan(current, target, byVersion) {
26
+ version(current); version(target);
27
+ if (target < 1 || target < current) throw new Error('Envelope downgrades are not supported');
28
+ if (target - current > 256) throw new Error('Envelope migration limit exceeded');
29
+ const result = [];
30
+ for (let from = current; from < target; from += 1) {
31
+ const step = byVersion.get(from);
32
+ if (!step) throw new Error('Missing envelope migration from version ' + from);
33
+ result.push({from, to: step.to, ...(step.name && {name: step.name})});
34
+ }
35
+ return result;
36
+ }
37
+
38
+ export function planStateEnvelopeMigration(currentVersion, targetVersion, migrations) {
39
+ return plan(currentVersion, targetVersion, definitions(migrations));
40
+ }
41
+
42
+ /** Pure snapshot transformation. Callbacks must not perform external effects. No files are written. */
43
+ export async function migrateStateEnvelope(source, {targetVersion = 1, migrations = []} = {}) {
44
+ let next = copy(source);
45
+ if (!next || typeof next !== 'object' || Array.isArray(next)) throw new Error('State envelope object required');
46
+ const byVersion = definitions(migrations), applied = plan(next.version, targetVersion, byVersion);
47
+ for (const step of applied) {
48
+ const candidate = copy(await byVersion.get(step.from).migrate(structuredClone(next)));
49
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate) || candidate.version !== step.to) throw new Error('Migration must return envelope version ' + step.to);
50
+ next = candidate;
51
+ }
52
+ if (!next.tenants || typeof next.tenants !== 'object' || Array.isArray(next.tenants)) throw new Error('Migrated envelope requires tenants');
53
+ if (targetVersion === 1 && Object.keys(next).sort().join(',') !== 'tenants,version') throw new Error('Invalid version-1 envelope fields');
54
+ return {state: structuredClone(next), applied: structuredClone(applied)};
55
+ }
56
+
57
+ /** Produce a current-runtime backup for reviewed import through the guarded restore boundary. */
58
+ export async function prepareStateImport(source, {targetVersion = 1, migrations = []} = {}) {
59
+ if (targetVersion !== 1) throw new Error('Runtime imports require envelope version 1');
60
+ const result = await migrateStateEnvelope(source, {targetVersion, migrations});
61
+ return {backup: await createStateBackup({load: async () => result.state}), applied: result.applied};
62
+ }
@@ -0,0 +1,68 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import {TokenAuthority} from './index.js';
3
+ import {TransactionalStateStore} from './transactions.js';
4
+ import {FileAuditLog} from './audit.js';
5
+ import {createStateBackup, verifyStateBackup, restoreStateBackup} from './backups.js';
6
+
7
+ const text = (value, label) => {
8
+ if (typeof value !== 'string' || !value.trim() || value.length > 256) throw new Error('Invalid ' + label);
9
+ return value;
10
+ };
11
+ const isDigest = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
12
+
13
+ export class StateRestoreError extends Error {
14
+ constructor(code, stateMayHaveChanged) {
15
+ super(code === 'STATE_RESTORE_AUDIT_FAILED' ? 'State restore outcome could not be audited' : 'State restore failed');
16
+ this.name = 'StateRestoreError'; this.code = code; this.stateMayHaveChanged = stateMayHaveChanged;
17
+ }
18
+ }
19
+
20
+ /** Whole-store administration: tenantId is a host-fixed administrative storage context. */
21
+ export class GuardedStateRestore {
22
+ constructor(store, authority, audit, {tenantId, loadBackup, authorizeRestore} = {}) {
23
+ if (!(store instanceof TransactionalStateStore) || !(authority instanceof TokenAuthority) || !(audit instanceof FileAuditLog)) throw new TypeError('Transactional store, authority and audit log required');
24
+ if (store.path === audit.state.path) throw new Error('Restore audit must use a separate state path');
25
+ if (typeof loadBackup !== 'function' || typeof authorizeRestore !== 'function') throw new TypeError('Host backup loader and restore policy required');
26
+ this.store = store; this.authority = authority; this.audit = audit;
27
+ this.tenantId = text(tenantId, 'restore administrative context');
28
+ this.loadBackup = loadBackup; this.authorizeRestore = authorizeRestore;
29
+ }
30
+ identity(token) { return this.authority.authenticate(token, {tenantId: this.tenantId, permission: 'admin'}); }
31
+ async review(backupId, {token} = {}) {
32
+ this.identity(token); text(backupId, 'backup id');
33
+ try {
34
+ const backup = verifyStateBackup(await this.loadBackup(backupId, {tenantId: this.tenantId}));
35
+ const current = await createStateBackup(this.store);
36
+ this.identity(token);
37
+ return {backupId, expectedBackupDigest: backup.digest, expectedCurrentDigest: current.digest, backupTenantCount: Object.keys(backup.state.tenants).length, currentTenantCount: Object.keys(current.state.tenants).length};
38
+ } catch { throw new Error('State restore review failed'); }
39
+ }
40
+ async restore({token, backupId, expectedBackupDigest, expectedCurrentDigest, acknowledgeQuiesced = false, signal} = {}) {
41
+ const identity = this.identity(token); text(backupId, 'backup id');
42
+ if (!isDigest(expectedBackupDigest) || !isDigest(expectedCurrentDigest)) throw new Error('Reviewed backup and current digests required');
43
+ if (acknowledgeQuiesced !== true) throw new Error('Confirm all writers and effect workers have stopped');
44
+ signal?.throwIfAborted();
45
+ const requestId = randomUUID(); let commitApproved = false, auditingResult = false, denied = false;
46
+ const record = (outcome, phase) => this.audit.state.exclusive(() => this.audit.append({tenantId: this.tenantId, actor: identity.subject, action: 'state.restore', outcome, metadata: {requestId, backupId, expectedBackupDigest, expectedCurrentDigest, phase}}));
47
+ try {
48
+ const checked = verifyStateBackup(await this.loadBackup(backupId, {tenantId: this.tenantId}));
49
+ this.identity(token); signal?.throwIfAborted();
50
+ if (checked.digest !== expectedBackupDigest) throw new Error('Backup changed since restore review');
51
+ const backup = {format: 'aetheros-state-backup', version: 1, state: checked.state, digest: checked.digest};
52
+ await record('success', 'intent');
53
+ const result = await restoreStateBackup(this.store, backup, {expectedCurrentDigest, beforeCommit: async context => {
54
+ const authenticated = this.identity(token); signal?.throwIfAborted();
55
+ const approved = await this.authorizeRestore({...context, backupId, identity: structuredClone(authenticated)});
56
+ this.identity(token); signal?.throwIfAborted();
57
+ if (approved !== true) { denied = true; return false; }
58
+ commitApproved = true; return true;
59
+ }});
60
+ auditingResult = true; await record('success', 'result'); auditingResult = false;
61
+ this.identity(token);
62
+ return result;
63
+ } catch {
64
+ try { await record(denied ? 'denied' : 'failed', commitApproved ? 'commit-or-result' : 'pre-commit'); } catch { /* An audit failure must never trigger an implicit restore retry. */ }
65
+ throw new StateRestoreError(auditingResult ? 'STATE_RESTORE_AUDIT_FAILED' : 'STATE_RESTORE_FAILED', commitApproved);
66
+ }
67
+ }
68
+ }
package/src/index.js ADDED
@@ -0,0 +1,77 @@
1
+ import {randomBytes,scryptSync,timingSafeEqual} from 'node:crypto';
2
+ import {readFile,writeFile,rename,mkdir,open} from 'node:fs/promises';
3
+ import {dirname,resolve} from 'node:path';
4
+
5
+ const scopes=new Set(['read','write','workflow','approve','audit','admin']);
6
+ const validate=(value,name)=>{if(typeof value!=='string'||!value.trim())throw new Error(name+' required');return value;};
7
+ const clone=value=>structuredClone(value);
8
+ const keyHash=(secret,salt)=>scryptSync(secret,salt,32);
9
+ const verifyHash=(actual,expected)=>actual.length===expected.length&&timingSafeEqual(actual,expected);
10
+
11
+ /** Credentials are generated once; only salted derived keys are retained. */
12
+ export class TokenAuthority {
13
+ #tokens=new Map();
14
+ issue({tenantId,subject,permissions=['read'],expiresAt}){
15
+ validate(tenantId,'tenantId');validate(subject,'subject');
16
+ if(!Array.isArray(permissions)||!permissions.length||permissions.some(p=>!scopes.has(p)))throw new Error('Invalid permissions');
17
+ if(!Number.isSafeInteger(expiresAt)||expiresAt<=Date.now())throw new Error('Future expiry required');
18
+ const id=randomBytes(16).toString('hex'),secret=randomBytes(32).toString('base64url'),salt=randomBytes(16);
19
+ this.#tokens.set(id,{tenantId,subject,permissions:[...new Set(permissions)],expiresAt,salt,derived:keyHash(secret,salt)});
20
+ return {token:id+'.'+secret,id,expiresAt};
21
+ }
22
+ authenticate(token,{tenantId,permission,now=Date.now()}={}){
23
+ if(typeof token!=='string'||!Number.isSafeInteger(now)||now<0)throw new Error('Authentication failed');
24
+ const dot=token.indexOf('.');
25
+ if(dot<1)throw new Error('Authentication failed');
26
+ const id=token.slice(0,dot),secret=token.slice(dot+1),entry=this.#tokens.get(id);
27
+ if(!entry||!secret||!verifyHash(keyHash(secret,entry.salt),entry.derived)||now>=entry.expiresAt)throw new Error('Authentication failed');
28
+ if(tenantId!==undefined&&entry.tenantId!==tenantId)throw new Error('Access denied');
29
+ if(permission!==undefined&&!entry.permissions.includes(permission)&&!entry.permissions.includes('admin'))throw new Error('Permission denied');
30
+ return clone({tenantId:entry.tenantId,subject:entry.subject,permissions:entry.permissions,expiresAt:entry.expiresAt});
31
+ }
32
+ revoke(id){return this.#tokens.delete(id);}
33
+ }
34
+
35
+ /** Atomic local JSON snapshots; putTenant calls serialize within one process.
36
+ * Direct save callers and separate processes must coordinate externally. */
37
+ export class FileStateStore {
38
+ static queues=new Map();
39
+ async exclusive(operation){
40
+ const previous=FileStateStore.queues.get(this.path)??Promise.resolve();
41
+ const current=previous.catch(()=>{}).then(operation);
42
+ FileStateStore.queues.set(this.path,current);
43
+ try{return await current;}finally{if(FileStateStore.queues.get(this.path)===current)FileStateStore.queues.delete(this.path);}
44
+ }
45
+ constructor(path,{codec}={}){
46
+ this.path=resolve(validate(path,'path'));
47
+ if(codec!==undefined&&(!codec||typeof codec.encode!=='function'||typeof codec.decode!=='function'))throw new TypeError('State codec requires encode and decode');
48
+ this.codec=codec;
49
+ }
50
+ async load(){
51
+ let raw;
52
+ try{raw=await readFile(this.path,'utf8');}catch(error){if(error.code==='ENOENT')return {version:1,tenants:{}};throw error;}
53
+ const encoded=JSON.parse(raw),state=this.codec?await this.codec.decode(encoded):encoded;
54
+ if(!state||state.version!==1||typeof state.tenants!=='object'||Array.isArray(state.tenants)||state.tenants===null)throw new Error('Invalid state schema');
55
+ return clone(state);
56
+ }
57
+ async save(state){
58
+ if(!state||state.version!==1||!state.tenants||typeof state.tenants!=='object'||Array.isArray(state.tenants))throw new Error('Invalid state schema');
59
+ const snapshot=clone(state),encoded=this.codec?await this.codec.encode(snapshot):snapshot;
60
+ const serialized=JSON.stringify(encoded);
61
+ if(serialized===undefined)throw new Error('State codec must return JSON');
62
+ const temp=this.path+'.'+randomBytes(12).toString('hex')+'.tmp';
63
+ await mkdir(dirname(this.path),{recursive:true});
64
+ let created=false;
65
+ try{
66
+ const handle=await open(temp,'wx',0o600);created=true;
67
+ try{await handle.writeFile(serialized+'\n','utf8');await handle.sync();}finally{await handle.close();}
68
+ await rename(temp,this.path);
69
+ }catch(error){
70
+ if(created){const {unlink}=await import('node:fs/promises');await unlink(temp).catch(()=>{});}
71
+ throw error;
72
+ }
73
+ }
74
+ async getTenant(id){validate(id,'tenant id');const state=await this.load();return Object.hasOwn(state.tenants,id)?clone(state.tenants[id]):null;}
75
+ async putTenant(id,data){validate(id,'tenant id');if(data===undefined)throw new Error('Tenant data required');return this.exclusive(async()=>{const state=await this.load();Object.defineProperty(state.tenants,id,{value:clone(data),enumerable:true,writable:true,configurable:true});await this.save(state);});}
76
+ }
77
+
@@ -0,0 +1,92 @@
1
+ const clone = value => structuredClone(value);
2
+
3
+ function version(value, label) {
4
+ if (!Number.isSafeInteger(value) || value < 1) throw new Error('Invalid ' + label);
5
+ return value;
6
+ }
7
+
8
+ function stableJson(value, label) {
9
+ const active = new Set();
10
+ const visit = item => {
11
+ if (item === null || typeof item === 'string' || typeof item === 'boolean') return;
12
+ if (typeof item === 'number' && Number.isFinite(item)) return;
13
+ if (typeof item !== 'object' || ArrayBuffer.isView(item) || item instanceof Date || active.has(item)) {
14
+ throw new Error(label + ' must be stable JSON');
15
+ }
16
+ active.add(item);
17
+ if (Array.isArray(item)) for (const child of item) visit(child);
18
+ else {
19
+ const prototype = Object.getPrototypeOf(item);
20
+ if (prototype !== Object.prototype && prototype !== null) throw new Error(label + ' must be stable JSON');
21
+ for (const child of Object.values(item)) visit(child);
22
+ }
23
+ active.delete(item);
24
+ };
25
+ visit(value);
26
+ let encoded;
27
+ try { encoded = JSON.stringify(value); } catch { throw new Error(label + ' must be stable JSON'); }
28
+ if (encoded === undefined) throw new Error(label + ' must be stable JSON');
29
+ const decoded = JSON.parse(encoded);
30
+ if (JSON.stringify(decoded) !== encoded) throw new Error(label + ' must be stable JSON');
31
+ return decoded;
32
+ }
33
+
34
+ function migrationMap(migrations) {
35
+ if (!Array.isArray(migrations)) throw new TypeError('Migrations must be an array');
36
+ const byVersion = new Map();
37
+ for (const entry of migrations) {
38
+ if (!entry || typeof entry !== 'object') throw new Error('Invalid migration');
39
+ const from = version(entry.from, 'migration version');
40
+ const to = version(entry.to, 'migration version');
41
+ if (to !== from + 1 || typeof entry.migrate !== 'function' || byVersion.has(from)) throw new Error('Invalid migration');
42
+ if (entry.name !== undefined && (typeof entry.name !== 'string' || !entry.name.trim())) throw new Error('Invalid migration name');
43
+ byVersion.set(from, entry);
44
+ }
45
+ return byVersion;
46
+ }
47
+
48
+ /** Return the exact one-version steps required without executing migration code. */
49
+ export function planTenantMigration(currentVersion, targetVersion, migrations) {
50
+ const current = version(currentVersion, 'current schema version');
51
+ const target = version(targetVersion, 'target schema version');
52
+ if (current > target) throw new Error('Schema downgrades are not supported');
53
+ const byVersion = migrationMap(migrations), plan = [];
54
+ for (let from = current; from < target; from += 1) {
55
+ const entry = byVersion.get(from);
56
+ if (!entry) throw new Error('Missing migration from schema version ' + from);
57
+ plan.push({from: entry.from, to: entry.to, ...(entry.name && {name: entry.name})});
58
+ }
59
+ return plan;
60
+ }
61
+
62
+ /**
63
+ * Atomically migrate one tenant inside a TransactionalStateStore.
64
+ * Every callback runs under the store lock; the final snapshot commits only if
65
+ * the complete chain succeeds and each result advances exactly one version.
66
+ */
67
+ export async function migrateTenant(store, tenantId, {targetVersion, migrations} = {}) {
68
+ if (!store || typeof store.updateTenant !== 'function') throw new TypeError('Transactional tenant store required');
69
+ if (typeof tenantId !== 'string' || !tenantId.trim()) throw new TypeError('Tenant id required');
70
+ const target = version(targetVersion, 'target schema version');
71
+ const byVersion = migrationMap(migrations);
72
+ let applied = [];
73
+ const state = await store.updateTenant(tenantId, async current => {
74
+ if (!current || typeof current !== 'object' || Array.isArray(current)) throw new Error('Tenant state required');
75
+ const start = version(current.schemaVersion, 'tenant schema version');
76
+ const plan = planTenantMigration(start, target, migrations);
77
+ let next = stableJson(current, 'Tenant state');
78
+ applied = [];
79
+ for (const step of plan) {
80
+ const definition = byVersion.get(step.from);
81
+ const candidate = await definition.migrate(clone(next));
82
+ const checked = stableJson(candidate, 'Migrated tenant state');
83
+ if (!checked || typeof checked !== 'object' || Array.isArray(checked) || checked.schemaVersion !== step.to) {
84
+ throw new Error('Migration must return schema version ' + step.to);
85
+ }
86
+ next = checked;
87
+ applied.push(step);
88
+ }
89
+ return next;
90
+ });
91
+ return {state: clone(state), applied: clone(applied)};
92
+ }