air.mjs file-level

at sha256:8 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:9 feat(calendar): hosted bridge/gateway route parity and timeline noteRec… · aaronrene · Jun 19, 2026
1 /**
2 * AIR (attestation) hook: call before write (non-inbox) and before export when air.enabled.
3 * SPEC §7. Phase 4: hook point; optional endpoint call.
4 */
5
6 /**
7 * Thrown when air.required=true and attestation fails (endpoint unreachable or returns non-OK).
8 * Callers should surface this as a hard write rejection — the write must not proceed.
9 */
10 export class AttestationRequiredError extends Error {
11 constructor(message) {
12 super(message);
13 this.name = 'AttestationRequiredError';
14 this.code = 'ATTESTATION_REQUIRED';
15 }
16 }
17
18 /**
19 * Return true if path is under vault inbox (global or project inbox).
20 * @param {string} vaultRelativePath
21 * @returns {boolean}
22 */
23 function isInboxPath(vaultRelativePath) {
24 const n = vaultRelativePath.replace(/\\/g, '/');
25 return n === 'inbox' || n.startsWith('inbox/') || /^projects\/[^/]+\/inbox(\/|$)/.test(n);
26 }
27
28 /**
29 * Build the canonical AIR request body — exact byte-for-byte parity with the
30 * Python port in `muse/plugins/knowtation/attestation.py::build_air_request_body`.
31 *
32 * Both implementations MUST produce identical UTF-8 bytes for the same inputs
33 * so that the cross-language parity test in
34 * `gabriel-muse/tests/test_knowtation_attestation.py` passes.
35 *
36 * The body is a JSON object with EXACTLY three keys, always present, in
37 * canonical alphabetical order: `action`, `content_hash`, `path`.
38 *
39 * @param {string} action
40 * @param {string} path
41 * @param {string} contentHash - lowercase SHA-256 hex; pass '' for legacy callers
42 * @returns {string} - JSON body string ready for fetch()
43 */
44 export function buildAirRequestBody(action, path, contentHash) {
45 for (const [name, val] of [['action', action], ['path', path], ['content_hash', contentHash]]) {
46 if (typeof val !== 'string') {
47 throw new TypeError(`${name} must be a string`);
48 }
49 if (val.includes('\u0000')) {
50 throw new TypeError(`${name} must not contain embedded null bytes`);
51 }
52 }
53 // Canonical alphabetical order: action, content_hash, path. JSON.stringify
54 // with no replacer + key list preserves insertion order, so we construct
55 // the object in alphabetical order explicitly.
56 return JSON.stringify({ action, content_hash: contentHash, path });
57 }
58
59 /**
60 * If AIR is enabled and path is outside inbox, obtain attestation before write.
61 * When air.required=true, a failed endpoint call throws AttestationRequiredError instead of
62 * returning a placeholder — the write must not proceed.
63 * @param {{ air?: { enabled?: boolean, required?: boolean, endpoint?: string } }} config - from loadConfig()
64 * @param {string} vaultRelativePath
65 * @param {string} [contentHash=''] - lowercase SHA-256 hex of the content being attested
66 * @returns {Promise<string|null>} - AIR id if attestation obtained, null if skipped
67 * @throws {AttestationRequiredError} when air.required=true and attestation cannot be completed
68 */
69 export async function attestBeforeWrite(config, vaultRelativePath, contentHash = '') {
70 if (!config.air?.enabled) return null;
71 if (isInboxPath(vaultRelativePath)) return null;
72
73 const required = config.air.required === true;
74 const endpoint = config.air.endpoint || process.env.KNOWTATION_AIR_ENDPOINT;
75
76 if (endpoint) {
77 try {
78 const res = await fetch(endpoint, {
79 method: 'POST',
80 headers: { 'Content-Type': 'application/json' },
81 body: buildAirRequestBody('write', vaultRelativePath, contentHash),
82 });
83 if (res.ok) {
84 const data = await res.json().catch(() => ({}));
85 return data.id || data.air_id || 'air-write-ok';
86 }
87 if (required) {
88 throw new AttestationRequiredError(
89 `knowtation: AIR endpoint returned ${res.status} and air.required=true; write rejected.`
90 );
91 }
92 } catch (e) {
93 if (e instanceof AttestationRequiredError) throw e;
94 if (required) {
95 throw new AttestationRequiredError(
96 `knowtation: AIR endpoint unreachable and air.required=true; write rejected. (${e.message})`
97 );
98 }
99 }
100 } else if (required) {
101 throw new AttestationRequiredError(
102 'knowtation: air.required=true but no AIR endpoint is configured; write rejected.'
103 );
104 }
105
106 // No endpoint or call failed and not required: log and return placeholder so write can proceed
107 console.error('knowtation: AIR enabled but endpoint not configured or unreachable; logging placeholder.');
108 return 'air-placeholder-write';
109 }
110
111 /**
112 * If AIR is enabled, obtain attestation before export.
113 * When air.required=true, a failed endpoint call throws AttestationRequiredError instead of
114 * returning a placeholder — the export must not proceed.
115 * @param {{ air?: { enabled?: boolean, required?: boolean, endpoint?: string } }} config
116 * @param {string[]} sourcePaths
117 * @returns {Promise<string|null>}
118 * @throws {AttestationRequiredError} when air.required=true and attestation cannot be completed
119 */
120 export async function attestBeforeExport(config, sourcePaths) {
121 if (!config.air?.enabled) return null;
122
123 const required = config.air.required === true;
124 const endpoint = config.air.endpoint || process.env.KNOWTATION_AIR_ENDPOINT;
125
126 if (endpoint) {
127 try {
128 const res = await fetch(endpoint, {
129 method: 'POST',
130 headers: { 'Content-Type': 'application/json' },
131 body: JSON.stringify({ action: 'export', source_notes: sourcePaths }),
132 });
133 if (res.ok) {
134 const data = await res.json().catch(() => ({}));
135 return data.id || data.air_id || 'air-export-ok';
136 }
137 if (required) {
138 throw new AttestationRequiredError(
139 `knowtation: AIR endpoint returned ${res.status} and air.required=true; export rejected.`
140 );
141 }
142 } catch (e) {
143 if (e instanceof AttestationRequiredError) throw e;
144 if (required) {
145 throw new AttestationRequiredError(
146 `knowtation: AIR endpoint unreachable and air.required=true; export rejected. (${e.message})`
147 );
148 }
149 }
150 } else if (required) {
151 throw new AttestationRequiredError(
152 'knowtation: air.required=true but no AIR endpoint is configured; export rejected.'
153 );
154 }
155
156 console.error('knowtation: AIR enabled but endpoint not configured or unreachable; logging placeholder.');
157 return 'air-placeholder-export';
158 }