@aetheros/kernel-runtime 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hariprasath Thanarajah
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@aetheros/kernel-runtime",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/Abigshana/AetherOS.git",
8
+ "directory": "packages/kernel-runtime"
9
+ },
10
+ "type": "module",
11
+ "exports": {
12
+ ".": "./src/index.js",
13
+ "./transactions": "./src/transactions.js",
14
+ "./migrations": "./src/migrations.js",
15
+ "./audit": "./src/audit.js",
16
+ "./connectors": "./src/connectors.js",
17
+ "./backups": "./src/backups.js",
18
+ "./encrypted-backups": "./src/encrypted-backups.js",
19
+ "./authenticated-connectors": "./src/authenticated-connectors.js",
20
+ "./authenticated-audit": "./src/authenticated-audit.js",
21
+ "./release-signatures": "./src/release-signatures.js",
22
+ "./encrypted-state": "./src/encrypted-state.js",
23
+ "./guarded-restore": "./src/guarded-restore.js",
24
+ "./envelope-migrations": "./src/envelope-migrations.js",
25
+ "./agent-memory": "./src/agent-memory.js",
26
+ "./effect-journal": "./src/effect-journal.js"
27
+ },
28
+ "scripts": {
29
+ "check": "node --check src/index.js && node --check src/transactions.js && node --check src/migrations.js && node --check src/audit.js && node --check src/connectors.js && node --check src/backups.js && node --check src/encrypted-backups.js && node --check src/authenticated-connectors.js && node --check src/authenticated-audit.js && node --check src/release-signatures.js && node --check src/encrypted-state.js && node --check src/canonical-json.js && node --check src/guarded-restore.js && node --check src/envelope-migrations.js && node --check src/agent-memory.js && node --check src/effect-journal.js",
30
+ "test": "node --test test/*.test.mjs"
31
+ },
32
+ "files": [
33
+ "src",
34
+ "dist",
35
+ "bin",
36
+ "templates",
37
+ "kits",
38
+ "README.md"
39
+ ]
40
+ }
@@ -0,0 +1,88 @@
1
+ import {randomBytes} from 'node:crypto';
2
+ import {TokenAuthority} from './index.js';
3
+ import {createEncryptedStateStore} from './encrypted-state.js';
4
+
5
+ const MAX_ENTRIES = 1000, MAX_TEXT_BYTES = 16384, MAX_TOTAL_BYTES = 1048576;
6
+ const validId = value => typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value);
7
+ const revision = value => typeof value === 'string' && /^[a-f0-9]{32}$/.test(value);
8
+ const textBytes = text => Buffer.byteLength(text, 'utf8');
9
+ const scope = ({agentId, shared = false}) => {
10
+ if (typeof shared !== 'boolean' || (shared ? agentId !== undefined : !validId(agentId))) throw new TypeError('Select one agent or shared memory');
11
+ return shared ? null : agentId;
12
+ };
13
+ const keyOf = entry => JSON.stringify([entry.agentId, entry.id]);
14
+ function checkedMemory(value) {
15
+ if (value === null) return {schemaVersion: 1, entries: []};
16
+ if (!value || value.schemaVersion !== 1 || !Array.isArray(value.entries) || value.entries.length > MAX_ENTRIES) throw new Error('Invalid memory state');
17
+ const seen = new Set(); let bytes = 0;
18
+ for (const entry of value.entries) {
19
+ if (!entry || !validId(entry.id) || (entry.agentId !== null && !validId(entry.agentId)) || !revision(entry.revision) || typeof entry.text !== 'string' || !entry.text.trim() || textBytes(entry.text) > MAX_TEXT_BYTES) throw new Error('Invalid memory record');
20
+ if (seen.has(keyOf(entry))) throw new Error('Duplicate memory record');
21
+ seen.add(keyOf(entry)); bytes += textBytes(entry.text);
22
+ }
23
+ if (bytes > MAX_TOTAL_BYTES) throw new Error('Memory capacity exceeded');
24
+ // Return only the declared fields; internal/unknown fields never escape the facade.
25
+ return {schemaVersion: 1, entries: value.entries.map(({id, agentId, revision, text}) => ({id, agentId, revision, text}))};
26
+ }
27
+
28
+ /** Dedicated encrypted tenant memory. Hosts explicitly authorize every agent/shared scope. */
29
+ export function createEncryptedAgentMemory(path, authority, {tenantId, key, keyId, readKeys, authorize} = {}) {
30
+ if (!(authority instanceof TokenAuthority) || typeof tenantId !== 'string' || !tenantId.trim() || typeof authorize !== 'function') throw new TypeError('Tenant authority and explicit memory authorization policy required');
31
+ const store = createEncryptedStateStore(path, {tenantId, key, keyId, readKeys});
32
+ async function allow(token, operation, agentId) {
33
+ const authenticate = () => authority.authenticate(token, {tenantId, permission: operation === 'get' || operation === 'list' ? 'read' : 'write'});
34
+ const identity = authenticate();
35
+ // No record text or bearer credential is supplied to policy callbacks.
36
+ if (await authorize(structuredClone({identity, operation, agentId, shared: agentId === null})) !== true) throw new Error('Memory access denied');
37
+ authenticate();
38
+ }
39
+ function selected(request, needsId = true) {
40
+ const agentId = scope(request);
41
+ if (needsId && !validId(request.id)) throw new TypeError('Invalid memory id');
42
+ return agentId;
43
+ }
44
+ return Object.freeze({
45
+ async list(request = {}) {
46
+ const agentId = selected(request, false), {token, limit = 100} = request;
47
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_ENTRIES) throw new TypeError('Invalid memory limit');
48
+ await allow(token, 'list', agentId);
49
+ const memory = checkedMemory(await store.getTenant(tenantId));
50
+ await allow(token, 'list', agentId);
51
+ return memory.entries.filter(entry => entry.agentId === agentId).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0).slice(0, limit);
52
+ },
53
+ async get(request = {}) {
54
+ const agentId = selected(request), {token, id} = request;
55
+ await allow(token, 'get', agentId);
56
+ const memory = checkedMemory(await store.getTenant(tenantId));
57
+ await allow(token, 'get', agentId);
58
+ return memory.entries.find(entry => entry.agentId === agentId && entry.id === id) ?? null;
59
+ },
60
+ async put(request = {}) {
61
+ const agentId = selected(request), {token, id, text, expectedRevision} = request;
62
+ if (typeof text !== 'string' || !text.trim() || textBytes(text) > MAX_TEXT_BYTES || (expectedRevision !== null && !revision(expectedRevision))) throw new TypeError('Memory text and expected revision required (null to create)');
63
+ await allow(token, 'put', agentId);
64
+ let result;
65
+ await store.updateTenant(tenantId, async previous => {
66
+ await allow(token, 'put', agentId);
67
+ const memory = checkedMemory(previous), index = memory.entries.findIndex(entry => entry.agentId === agentId && entry.id === id);
68
+ if ((index < 0 ? null : memory.entries[index].revision) !== expectedRevision) throw new Error('Memory revision conflict');
69
+ result = {id, agentId, text, revision: randomBytes(16).toString('hex')};
70
+ if (index < 0) memory.entries.push(result); else memory.entries[index] = result;
71
+ return checkedMemory(memory);
72
+ });
73
+ return structuredClone(result);
74
+ },
75
+ async remove(request = {}) {
76
+ const agentId = selected(request), {token, id, expectedRevision} = request;
77
+ if (!revision(expectedRevision)) throw new TypeError('Expected memory revision required');
78
+ await allow(token, 'remove', agentId);
79
+ await store.updateTenant(tenantId, async previous => {
80
+ await allow(token, 'remove', agentId);
81
+ const memory = checkedMemory(previous), index = memory.entries.findIndex(entry => entry.agentId === agentId && entry.id === id);
82
+ if (index < 0 || memory.entries[index].revision !== expectedRevision) throw new Error('Memory revision conflict');
83
+ memory.entries.splice(index, 1); return memory;
84
+ });
85
+ return {removed: true};
86
+ }
87
+ });
88
+ }
package/src/audit.js ADDED
@@ -0,0 +1,88 @@
1
+ import {canonicalJson} from './canonical-json.js';
2
+ import {createHash,randomUUID} from 'node:crypto';
3
+ import {TransactionalStateStore} from './transactions.js';
4
+
5
+ const OUTCOMES = new Set(['success','denied','failed']);
6
+ const GENESIS = 'GENESIS';
7
+ const clone = value => structuredClone(value);
8
+ const digest = value => createHash('sha256').update(value.hashVersion===2?canonicalJson(value):JSON.stringify(value)).digest('hex');
9
+ const validText = (value,label) => {
10
+ if (typeof value !== 'string' || !value.trim()) throw new Error(label + ' required');
11
+ return value;
12
+ };
13
+
14
+ function stableJson(value,label) {
15
+ const active=new Set();
16
+ const visit=item=>{
17
+ if(item===null||typeof item==='string'||typeof item==='boolean')return;
18
+ if(typeof item==='number'&&Number.isFinite(item))return;
19
+ if(typeof item!=='object'||ArrayBuffer.isView(item)||item instanceof Date||active.has(item))throw new Error(label+' must be stable JSON');
20
+ active.add(item);
21
+ 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]);}}
22
+ 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);}
23
+ active.delete(item);
24
+ };
25
+ visit(value);
26
+ const encoded=JSON.stringify(value),decoded=JSON.parse(encoded);
27
+ if(JSON.stringify(decoded)!==encoded)throw new Error(label+' must be stable JSON');
28
+ return decoded;
29
+ }
30
+
31
+ function validateLog(log,maxEntries) {
32
+ if(!log||log.schemaVersion!==1||log.maxEntries!==maxEntries||typeof log.anchorHash!=='string'||!Array.isArray(log.entries)||log.entries.length>maxEntries)throw new Error('Invalid audit log');
33
+ return log;
34
+ }
35
+
36
+ /** Bounded, hash-linked local audit records. Hashes detect changes but do not authenticate the writer. */
37
+ export class FileAuditLog {
38
+ constructor(path,{maxEntries=1000,clock=()=>new Date().toISOString(),ids=randomUUID,stateStore}={}) {
39
+ if(!Number.isSafeInteger(maxEntries)||maxEntries<1||maxEntries>100000)throw new Error('Invalid audit retention');
40
+ if(typeof clock!=='function'||typeof ids!=='function')throw new TypeError('Audit clock and id source required');
41
+ const base=new TransactionalStateStore(path);
42
+ if(stateStore!==undefined&&(!(stateStore instanceof TransactionalStateStore)||stateStore.path!==base.path))throw new TypeError('Matching transactional audit state store required');
43
+ this.state=stateStore??base;this.maxEntries=maxEntries;this.clock=clock;this.ids=ids;
44
+ }
45
+ key(tenantId){return 'security.audit/'+validText(tenantId,'tenantId');}
46
+ async append({tenantId,actor,action,outcome='success',metadata={}}) {
47
+ validText(actor,'actor');validText(action,'action');
48
+ if(!OUTCOMES.has(outcome))throw new Error('Invalid audit outcome');
49
+ const safeMetadata=stableJson(metadata,'Audit metadata');
50
+ const id=validText(this.ids(),'audit id'),at=validText(this.clock(),'audit time');
51
+ const parsedTime=new Date(at);
52
+ if(Number.isNaN(parsedTime.valueOf())||parsedTime.toISOString()!==at)throw new Error('Invalid audit time');
53
+ let appended;
54
+ await this.state.updateTenant(this.key(tenantId),current=>{
55
+ const log=current??{schemaVersion:1,maxEntries:this.maxEntries,anchorHash:GENESIS,entries:[]};
56
+ validateLog(log,this.maxEntries);
57
+ const previousHash=log.entries.at(-1)?.hash??log.anchorHash;
58
+ const payload={hashVersion:2,id,at,tenantId,actor,action,outcome,metadata:safeMetadata,previousHash};
59
+ appended={...payload,hash:digest(payload)};
60
+ log.entries.push(appended);
61
+ if(log.entries.length>this.maxEntries){const removed=log.entries.splice(0,log.entries.length-this.maxEntries);log.anchorHash=removed.at(-1).hash;}
62
+ return log;
63
+ });
64
+ return clone(appended);
65
+ }
66
+ async list(tenantId,{limit=this.maxEntries}={}) {
67
+ if(!Number.isSafeInteger(limit)||limit<0||limit>this.maxEntries)throw new Error('Invalid audit limit');
68
+ const log=await this.state.getTenant(this.key(tenantId));
69
+ if(!log)return [];
70
+ validateLog(log,this.maxEntries);
71
+ return clone(limit===0?[]:log.entries.slice(-limit));
72
+ }
73
+ async verify(tenantId) {
74
+ const log=await this.state.getTenant(this.key(tenantId));
75
+ if(!log)return {valid:true,count:0,anchorHash:GENESIS,tailHash:GENESIS};
76
+ try{
77
+ validateLog(log,this.maxEntries);
78
+ let previous=log.anchorHash;
79
+ for(const entry of log.entries){
80
+ if(!entry||(entry.hashVersion!==undefined&&entry.hashVersion!==2)||entry.previousHash!==previous||typeof entry.hash!=='string')return {valid:false,count:log.entries.length};
81
+ const {hash,...payload}=entry;
82
+ if(digest(payload)!==hash)return {valid:false,count:log.entries.length};
83
+ previous=hash;
84
+ }
85
+ return {valid:true,count:log.entries.length,anchorHash:log.anchorHash,tailHash:previous};
86
+ }catch{return {valid:false,count:Array.isArray(log?.entries)?log.entries.length:0};}
87
+ }
88
+ }
@@ -0,0 +1,24 @@
1
+ import {TokenAuthority} from './index.js';
2
+ import {FileAuditLog} from './audit.js';
3
+
4
+ /** Read-only host facade; append and file access remain privileged host APIs. */
5
+ export class AuthenticatedAuditReader {
6
+ constructor(log, authority, {tenantId} = {}) {
7
+ if (!(log instanceof FileAuditLog) || !(authority instanceof TokenAuthority)) throw new TypeError('Audit log and token authority required');
8
+ if (typeof tenantId !== 'string' || !tenantId.trim()) throw new TypeError('Audit tenant required');
9
+ this.log = log; this.authority = authority; this.tenantId = tenantId;
10
+ }
11
+ authenticate(token) { return this.authority.authenticate(token, {tenantId: this.tenantId, permission: 'audit'}); }
12
+ async list({token, limit} = {}) {
13
+ this.authenticate(token);
14
+ const entries = await this.log.list(this.tenantId, {limit});
15
+ this.authenticate(token);
16
+ return entries;
17
+ }
18
+ async verify({token} = {}) {
19
+ this.authenticate(token);
20
+ const result = await this.log.verify(this.tenantId);
21
+ this.authenticate(token);
22
+ return result;
23
+ }
24
+ }
@@ -0,0 +1,261 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import {TokenAuthority} from './index.js';
3
+ import {FileAuditLog} from './audit.js';
4
+ import {ConnectorRegistry} from './connectors.js';
5
+ import {FileEffectJournal} from './effect-journal.js';
6
+ import {snapshotEffectBinding} from './effect-binding.js';
7
+ import {canonicalJson} from './canonical-json.js';
8
+
9
+ export class ConnectorInvocationError extends Error {
10
+ constructor(code, effectMayHaveOccurred) {
11
+ super(code === 'CONNECTOR_DENIED' ? 'Connector invocation denied' : code === 'CONNECTOR_AUDIT_FAILED' ? 'Connector outcome could not be audited' : 'Connector invocation failed');
12
+ this.name = 'ConnectorInvocationError'; this.code = code;
13
+ this.effectMayHaveOccurred = effectMayHaveOccurred;
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Host-configured adapter boundary. Request actor/tenant assertions are ignored.
19
+ * Optional effectJournal enables durable write recovery; without it the legacy
20
+ * invocation contract is unchanged. Journal lock contention fails closed. Hosts
21
+ * keep result receipts free of secrets and enforce Review in authorize.
22
+ * All ambiguous writes stay blocked, including remotely idempotent operations.
23
+ */
24
+ export class AuthenticatedConnectorRegistry {
25
+ #definitions = new Map();
26
+ #catalog = new ConnectorRegistry();
27
+ #authority; #audit; #tenantId; #authorize; #effectJournal; #requireEffectBinding;
28
+ #authorizeClaimRecovery; #authorizeReconciliation; #verifyReconciliation; #authorizeReceiptLookup; #lookupRemoteReceipt;
29
+ constructor(authority, audit, {tenantId, authorize, effectJournal, requireEffectBinding = false, authorizeReconciliation, verifyReconciliation, authorizeReceiptLookup, lookupRemoteReceipt, authorizeClaimRecovery} = {}) {
30
+ if (!(authority instanceof TokenAuthority) || !(audit instanceof FileAuditLog)) throw new TypeError('Token authority and file audit log required');
31
+ if (typeof tenantId !== 'string' || !tenantId.trim()) throw new TypeError('Connector tenant required');
32
+ if (authorize !== undefined && typeof authorize !== 'function') throw new TypeError('Invalid connector policy');
33
+ if (effectJournal !== undefined && !(effectJournal instanceof FileEffectJournal)) throw new TypeError('File effect journal required');
34
+ if (typeof requireEffectBinding !== 'boolean' || (requireEffectBinding && !effectJournal)) throw new TypeError('Effect binding requires a journal');
35
+ for (const policy of [authorizeReconciliation, verifyReconciliation, authorizeReceiptLookup, lookupRemoteReceipt, authorizeClaimRecovery]) if (policy !== undefined && typeof policy !== 'function') throw new TypeError('Invalid reconciliation policy');
36
+ this.#authorizeClaimRecovery = authorizeClaimRecovery;
37
+ this.#authorizeReceiptLookup = authorizeReceiptLookup; this.#lookupRemoteReceipt = lookupRemoteReceipt;
38
+ this.#authorizeReconciliation = authorizeReconciliation; this.#verifyReconciliation = verifyReconciliation;
39
+ this.#requireEffectBinding = requireEffectBinding;
40
+ this.#effectJournal = effectJournal;
41
+ this.#authority = authority; this.#audit = audit; this.#tenantId = tenantId; this.#authorize = authorize;
42
+ }
43
+ register(definition) {
44
+ this.#catalog.register(definition);
45
+ this.#definitions.set(definition.id, definition.operations.map(({id, effect, execute, description}) => ({id, effect, execute, ...(description && {description})})));
46
+ return this;
47
+ }
48
+ list() { return this.#catalog.list(); }
49
+ /** Review a prepared-only generation handoff. Never dispatches or recovers an uncertain write. */
50
+ async recoverPreparedClaim({token, expectedEntry, nextGeneration, decisionId, signal} = {}) {
51
+ const authenticate = () => this.#authority.authenticate(token, {tenantId:this.#tenantId, permission:'admin'});
52
+ const identity = authenticate(), requestId = randomUUID(), metadata = {requestId};
53
+ const record = (outcome, phase) => this.#audit.state.exclusive(() => this.#audit.append({
54
+ tenantId:identity.tenantId, actor:identity.subject, action:'connector.claim-recovery', outcome,
55
+ metadata:{...metadata, phase},
56
+ }));
57
+ try {
58
+ signal?.throwIfAborted();
59
+ if (!this.#effectJournal || typeof this.#authorizeClaimRecovery !== 'function') throw new Error('Claim recovery not configured');
60
+ const snapshot = JSON.parse(canonicalJson({expectedEntry, nextGeneration, decisionId}));
61
+ const entry = snapshot.expectedEntry;
62
+ if (!entry || entry.tenantId !== identity.tenantId || entry.status !== 'prepared' || !snapshotEffectBinding(entry.binding) ||
63
+ !Number.isSafeInteger(snapshot.nextGeneration) || snapshot.nextGeneration <= entry.binding.generation ||
64
+ typeof snapshot.decisionId !== 'string' || !snapshot.decisionId.trim()) throw new Error('Invalid prepared claim recovery');
65
+ const operation = this.#definitions.get(entry.connectorId)?.find(value => value.id === entry.operationId);
66
+ if (operation?.effect !== 'write') throw new Error('Unknown write operation');
67
+ Object.assign(metadata, {connectorId:entry.connectorId, operationId:entry.operationId, decisionId:snapshot.decisionId,
68
+ previousGeneration:entry.binding.generation, nextGeneration:snapshot.nextGeneration});
69
+ const request = {...snapshot, identity:structuredClone(identity)};
70
+ const guard = async () => {
71
+ authenticate(); signal?.throwIfAborted();
72
+ // Host verifies fresh Review and authoritative claim ownership/generation for this exact snapshot.
73
+ if (await this.#authorizeClaimRecovery(structuredClone(request)) !== true) throw new Error('Claim recovery denied');
74
+ authenticate(); signal?.throwIfAborted();
75
+ return true;
76
+ };
77
+ await guard();
78
+ await record('success', 'authorized');
79
+ const updated = await this.#effectJournal.advancePreparedClaim({...snapshot, subject:identity.subject, beforeCommit:guard});
80
+ await record('success', 'advanced');
81
+ authenticate(); signal?.throwIfAborted();
82
+ return updated;
83
+ } catch {
84
+ try { await record('failed', 'claim-recovery'); } catch { /* A generation may already be durable; inspect before retry. */ }
85
+ // A competing writer could have dispatched since the supplied snapshot. Never imply absence.
86
+ throw new ConnectorInvocationError('CONNECTOR_RECONCILIATION_REQUIRED', true);
87
+ }
88
+ }
89
+ /** Read-only host lookup. A found candidate still needs a fresh result-bound reconciliation Review. */
90
+ async lookupReceipt({token, expectedEntry, signal} = {}) {
91
+ const authenticate = () => this.#authority.authenticate(token, {tenantId:this.#tenantId, permission:'admin'});
92
+ const identity = authenticate(), requestId = randomUUID();
93
+ const metadata = {requestId};
94
+ const record = (outcome, phase) => this.#audit.state.exclusive(() => this.#audit.append({
95
+ tenantId:identity.tenantId, actor:identity.subject, action:'connector.receipt-lookup', outcome,
96
+ metadata:{...metadata, phase},
97
+ }));
98
+ try {
99
+ signal?.throwIfAborted();
100
+ if (!this.#effectJournal || typeof this.#authorizeReceiptLookup !== 'function' || typeof this.#lookupRemoteReceipt !== 'function') throw new Error('Lookup not configured');
101
+ const entry = JSON.parse(canonicalJson(expectedEntry));
102
+ if (!entry || entry.tenantId !== identity.tenantId || !['dispatched', 'reconciliation_required'].includes(entry.status) ||
103
+ !snapshotEffectBinding(entry.binding)) throw new Error('Bound uncertain effect required');
104
+ const operation = this.#definitions.get(entry.connectorId)?.find(value => value.id === entry.operationId);
105
+ if (operation?.effect !== 'write') throw new Error('Unknown write operation');
106
+ Object.assign(metadata, {connectorId:entry.connectorId, operationId:entry.operationId});
107
+ const request = {expectedEntry:entry, identity:structuredClone(identity)};
108
+ const guard = async () => {
109
+ authenticate(); signal?.throwIfAborted();
110
+ if (await this.#authorizeReceiptLookup(structuredClone(request)) !== true) throw new Error('Lookup denied');
111
+ authenticate(); signal?.throwIfAborted();
112
+ const current = await this.#effectJournal.get({tenantId:entry.tenantId, effectKey:entry.effectKey});
113
+ if (canonicalJson(current) !== canonicalJson(entry)) throw new Error('Effect changed');
114
+ authenticate(); signal?.throwIfAborted();
115
+ };
116
+ await guard();
117
+ await record('success', 'authorized');
118
+ await guard();
119
+ // The trusted host chooses the service and credentials; no caller URL or receipt is accepted.
120
+ // No journal lock is held during network I/O, and no journal mutation follows lookup.
121
+ const response = JSON.parse(canonicalJson(await this.#lookupRemoteReceipt({...structuredClone(request), signal})));
122
+ let candidate;
123
+ if (response?.status === 'found' && Object.keys(response).sort().join(',') === 'evidence,result,status') {
124
+ candidate = response;
125
+ } else if (response?.status === 'unknown' && Object.keys(response).join(',') === 'status') {
126
+ candidate = {status:'unknown'};
127
+ } else throw new Error('Invalid remote lookup outcome');
128
+ await guard();
129
+ await record('success', candidate.status);
130
+ await guard();
131
+ return structuredClone(candidate);
132
+ } catch {
133
+ try { await record('failed', 'lookup'); } catch { /* No outcome authorizes dispatch. */ }
134
+ throw new ConnectorInvocationError('CONNECTOR_RECONCILIATION_REQUIRED', true);
135
+ }
136
+ }
137
+ /** Confirm an observed outcome only. This endpoint never executes an adapter or enables retry. */
138
+ async reconcile({token, expectedEntry, decisionId, result, evidence, signal} = {}) {
139
+ const authenticate = () => this.#authority.authenticate(token, {tenantId:this.#tenantId, permission:'admin'});
140
+ const identity = authenticate();
141
+ let snapshot, request;
142
+ const actionMetadata = {};
143
+ const requestId = randomUUID();
144
+ const record = (outcome, phase) => this.#audit.state.exclusive(() => this.#audit.append({
145
+ tenantId:identity.tenantId, actor:identity.subject, action:'connector.reconcile', outcome,
146
+ metadata:{requestId, phase, ...actionMetadata},
147
+ }));
148
+ try {
149
+ signal?.throwIfAborted();
150
+ if (!this.#effectJournal || typeof this.#authorizeReconciliation !== 'function' || typeof this.#verifyReconciliation !== 'function') throw new Error('Reconciliation not configured');
151
+ snapshot = JSON.parse(canonicalJson({expectedEntry, decisionId, result, evidence}));
152
+ const entry = snapshot.expectedEntry;
153
+ if (!entry || entry.tenantId !== identity.tenantId || !['dispatched', 'reconciliation_required'].includes(entry.status) ||
154
+ !snapshotEffectBinding(entry.binding) || typeof decisionId !== 'string' || !decisionId.trim()) throw new Error('Invalid reviewed effect');
155
+ const operation = this.#definitions.get(entry.connectorId)?.find(value => value.id === entry.operationId);
156
+ if (operation?.effect !== 'write') throw new Error('Unknown write operation');
157
+ Object.assign(actionMetadata, {connectorId:entry.connectorId, operationId:entry.operationId, decisionId:snapshot.decisionId});
158
+ request = {...snapshot, identity:structuredClone(identity)};
159
+ const guard = async () => {
160
+ authenticate(); signal?.throwIfAborted();
161
+ if (await this.#authorizeReconciliation(structuredClone(request)) !== true) throw new Error('Reconciliation denied');
162
+ authenticate(); signal?.throwIfAborted();
163
+ return true;
164
+ };
165
+ await guard();
166
+ // Only a host callback can validate remote evidence against the exact reviewed action/result.
167
+ if (await this.#verifyReconciliation(structuredClone(request)) !== true) throw new Error('Remote outcome unverified');
168
+ await guard();
169
+ await record('success', 'verified');
170
+ await this.#effectJournal.confirmReconciled({expectedEntry:entry, decisionId:snapshot.decisionId,
171
+ subject:identity.subject, receipt:{source:'authenticated-connector-result-v1', result:snapshot.result}, beforeCommit:guard});
172
+ await record('success', 'confirmed');
173
+ authenticate(); signal?.throwIfAborted();
174
+ return structuredClone(snapshot.result);
175
+ } catch {
176
+ try { await record('failed', 'reconciliation'); } catch { /* Outcome may already be durable; never dispatch. */ }
177
+ throw new ConnectorInvocationError('CONNECTOR_RECONCILIATION_REQUIRED', true);
178
+ }
179
+ }
180
+ async invoke({token, connectorId, operationId, input, idempotencyKey, signal, binding} = {}) {
181
+ signal?.throwIfAborted();
182
+ const identity = this.#authority.authenticate(token, {tenantId: this.#tenantId});
183
+ const operations = this.#definitions.get(connectorId), selected = operations?.find(operation => operation.id === operationId);
184
+ if (!selected) throw new Error('Unknown connector operation');
185
+ const safeBinding = snapshotEffectBinding(binding);
186
+ if ((safeBinding && (selected.effect !== 'write' || !this.#effectJournal)) ||
187
+ (selected.effect === 'write' && this.#requireEffectBinding && !safeBinding)) throw new ConnectorInvocationError('CONNECTOR_DENIED', false);
188
+ const requestId = randomUUID();
189
+ const metadata = phase => ({requestId, connectorId, operationId, effect: selected.effect, phase});
190
+ const record = (outcome, phase) => this.#audit.state.exclusive(() => this.#audit.append({tenantId: identity.tenantId, actor: identity.subject, action: 'connector.invoke', outcome, metadata: metadata(phase)}));
191
+ const authenticate = () => this.#authority.authenticate(token, {tenantId: this.#tenantId, permission: selected.effect});
192
+ let permitted = false, denied = false, dispatched = false, auditingResult = false, recovered = false, uncertain = false;
193
+ const journal = selected.effect === 'write' ? this.#effectJournal : undefined;
194
+ const effect = {tenantId: identity.tenantId, effectKey: idempotencyKey, ...(safeBinding && {binding: safeBinding})};
195
+ let policyRequest;
196
+ const recheckBindingPolicy = async () => {
197
+ if (!safeBinding) return;
198
+ authenticate(); signal?.throwIfAborted();
199
+ if (await this.#authorize(structuredClone(policyRequest)) !== true) {
200
+ denied = true; throw new Error('Connector binding denied');
201
+ }
202
+ authenticate(); signal?.throwIfAborted();
203
+ };
204
+ try {
205
+ authenticate(); permitted = true;
206
+ const registry = new ConnectorRegistry({authorize: async request => {
207
+ authenticate();
208
+ policyRequest = {...structuredClone(request), identity: structuredClone(identity), ...(safeBinding && {binding: structuredClone(safeBinding)})};
209
+ if (typeof this.#authorize !== 'function' || await this.#authorize(structuredClone(policyRequest)) !== true) {
210
+ denied = true; return false;
211
+ }
212
+ authenticate();
213
+ // Persist intent before external dispatch; an unavailable audit store fails closed.
214
+ await record('success', journal ? 'authorization' : 'dispatch');
215
+ authenticate();
216
+ return true;
217
+ }});
218
+ registry.register({id: connectorId, operations: operations.map(operation => ({...operation, execute: async request => {
219
+ authenticate(); signal?.throwIfAborted();
220
+ if (journal) {
221
+ const entry = await journal.prepare({...effect, subject: identity.subject, connectorId, operationId, input: request.input});
222
+ uncertain = entry.status !== 'prepared'; // Existing intent remains ambiguous even if a fresh policy recheck fails.
223
+ authenticate(); signal?.throwIfAborted();
224
+ await recheckBindingPolicy();
225
+ if (entry.status === 'confirmed') {
226
+ if (entry.receipt?.source !== 'authenticated-connector-result-v1') { uncertain = true; throw new Error('EFFECT_REPLAY_BLOCKED'); }
227
+ recovered = true; uncertain = false;
228
+ return structuredClone(entry.receipt.result);
229
+ }
230
+ if (entry.status !== 'prepared') {
231
+ uncertain = true;
232
+ throw new Error('EFFECT_REPLAY_BLOCKED');
233
+ }
234
+ await journal.markDispatched(effect);
235
+ uncertain = true; // Durable dispatch intent already blocks replay, even before adapter entry.
236
+ // Recheck after durable I/O; a revoked or cancelled attempt stays blocked.
237
+ authenticate(); signal?.throwIfAborted();
238
+ await recheckBindingPolicy();
239
+ }
240
+ dispatched = true; uncertain = false;
241
+ return operation.execute(request);
242
+ }}))});
243
+ const result = await registry.invoke({connectorId, operationId, input, actor: identity.subject, tenantId: identity.tenantId, idempotencyKey, signal});
244
+ if (journal && !recovered) await journal.confirm({...effect, receipt: {source: 'authenticated-connector-result-v1', result}});
245
+ auditingResult = true;
246
+ await record('success', recovered ? 'recovered-result' : 'result');
247
+ auditingResult = false;
248
+ authenticate();
249
+ return result;
250
+ } catch (error) {
251
+ if (journal && dispatched) {
252
+ try { await journal.requireReconciliation(effect); } catch { /* Confirmed results and unavailable stores remain fail-closed. */ }
253
+ }
254
+ if (['EFFECT_REPLAY_BLOCKED', 'EFFECT_BINDING_CONFLICT'].includes(error?.message)) uncertain = true;
255
+ const isDenied = denied || !permitted;
256
+ try { await record(isDenied ? 'denied' : 'failed', dispatched ? 'effect-or-result' : 'pre-dispatch'); } catch { /* Never dispatch or retry because recording a failure also failed. */ }
257
+ throw new ConnectorInvocationError(uncertain ? 'CONNECTOR_RECONCILIATION_REQUIRED' : isDenied ? 'CONNECTOR_DENIED' : auditingResult ? 'CONNECTOR_AUDIT_FAILED' : 'CONNECTOR_FAILED', (dispatched || uncertain || recovered) && selected.effect === 'write');
258
+ }
259
+ }
260
+ }
261
+
package/src/backups.js ADDED
@@ -0,0 +1,44 @@
1
+ import {canonicalJson as canonical} from './canonical-json.js';
2
+ import {createHash, timingSafeEqual} from 'node:crypto';
3
+ import {TransactionalStateStore} from './transactions.js';
4
+
5
+ const LIMIT = 16 * 1024 * 1024;
6
+
7
+ function snapshot(state) {
8
+ const encoded = canonical(state);
9
+ if (!state || state.version !== 1 || !state.tenants || typeof state.tenants !== 'object' || Array.isArray(state.tenants) || Object.keys(state).sort().join(',') !== 'tenants,version') throw new Error('Invalid backup state schema');
10
+ if (Buffer.byteLength(encoded) > LIMIT) throw new Error('Backup exceeds size limit');
11
+ return {state: JSON.parse(JSON.stringify(state)), digest: createHash('sha256').update(encoded).digest('hex')};
12
+ }
13
+
14
+ /** Digest is byte-integrity evidence, not a signature or writer authentication. */
15
+ export function verifyStateBackup(backup) {
16
+ canonical(backup);
17
+ if (!backup || backup.format !== 'aetheros-state-backup' || backup.version !== 1 || Object.keys(backup).sort().join(',') !== 'digest,format,state,version') throw new Error('Invalid backup envelope');
18
+ if (typeof backup.digest !== 'string' || !/^[a-f0-9]{64}$/.test(backup.digest)) throw new Error('Invalid backup digest');
19
+ const checked = snapshot(backup.state);
20
+ if (!timingSafeEqual(Buffer.from(backup.digest, 'hex'), Buffer.from(checked.digest, 'hex'))) throw new Error('Backup integrity check failed');
21
+ return checked;
22
+ }
23
+
24
+ /** Capture one atomically loaded file snapshot; the host stores the returned JSON. */
25
+ export async function createStateBackup(store) {
26
+ if (!store || typeof store.load !== 'function') throw new TypeError('State store required');
27
+ const checked = snapshot(await store.load());
28
+ return {format: 'aetheros-state-backup', version: 1, ...checked};
29
+ }
30
+
31
+ /** Replace the entire local store only if it still matches the reviewed digest. */
32
+ export async function restoreStateBackup(store, backup, {expectedCurrentDigest, beforeCommit} = {}) {
33
+ if (!(store instanceof TransactionalStateStore)) throw new TypeError('Transactional state store required');
34
+ if (typeof expectedCurrentDigest !== 'string' || !/^[a-f0-9]{64}$/.test(expectedCurrentDigest)) throw new Error('Expected current digest required');
35
+ if (beforeCommit !== undefined && typeof beforeCommit !== 'function') throw new TypeError('Invalid restore commit policy');
36
+ const checked = verifyStateBackup(backup);
37
+ await store.transaction(async current => {
38
+ const previous = snapshot(current);
39
+ if (previous.digest !== expectedCurrentDigest) throw new Error('State changed since restore approval');
40
+ if (beforeCommit && await beforeCommit({current: structuredClone(previous.state), currentDigest: previous.digest, backup: structuredClone(checked.state), backupDigest: checked.digest}) !== true) throw new Error('Restore commit policy denied');
41
+ return checked.state;
42
+ });
43
+ return {digest: checked.digest};
44
+ }
@@ -0,0 +1,22 @@
1
+ // Canonical JSON rejects values that JSON.stringify would silently discard.
2
+ export function canonicalJson(value, active = new Set()) {
3
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value);
4
+ if (typeof value === 'number' && Number.isFinite(value)) return JSON.stringify(value);
5
+ if (typeof value !== 'object' || active.has(value)) throw new Error('Backup requires plain JSON');
6
+ const prototype = Object.getPrototypeOf(value);
7
+ if (Array.isArray(value) && prototype !== Array.prototype) throw new Error('Backup requires plain JSON arrays');
8
+ if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) throw new Error('Backup requires plain JSON');
9
+ const descriptors = Object.getOwnPropertyDescriptors(value);
10
+ if (Reflect.ownKeys(value).some(key => typeof key !== 'string' || (!(Array.isArray(value) && key === 'length') && (!descriptors[key].enumerable || !Object.hasOwn(descriptors[key], 'value'))))) throw new Error('Backup requires plain JSON');
11
+ active.add(value);
12
+ let encoded;
13
+ if (Array.isArray(value)) {
14
+ if (Object.keys(value).length !== value.length || Object.keys(value).some((key, i) => key !== String(i))) throw new Error('Backup requires dense JSON arrays');
15
+ encoded = '[' + Array.prototype.map.call(value, item => canonicalJson(item, active)).join(',') + ']';
16
+ } else {
17
+ encoded = '{' + Object.keys(value).sort().map(key => JSON.stringify(key) + ':' + canonicalJson(value[key], active)).join(',') + '}';
18
+ }
19
+ active.delete(value);
20
+ return encoded;
21
+ }
22
+