muse-thin-bridge.mjs
231 lines 7.7 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Optional Muse thin bridge (Option C). Fail-closed when MUSE_URL is unset.
3 * Server-side only; never expose MUSE_API_KEY to clients.
4 */
5
6 export const DEFAULT_MAX_EXTERNAL_REF_LEN = 512;
7 export const DEFAULT_LINEAGE_TIMEOUT_MS = 5000;
8 export const DEFAULT_PROXY_MAX_BYTES = 1024 * 1024;
9
10 /** Documented operator callback path (Knowtation-defined contract). */
11 export const MUSE_LINEAGE_REF_PATH = '/knowtation/v1/lineage-ref';
12
13 /**
14 * @param {unknown} raw
15 * @param {number} [maxLen]
16 * @returns {string}
17 */
18 export function normalizeExternalRef(raw, maxLen = DEFAULT_MAX_EXTERNAL_REF_LEN) {
19 if (raw == null) return '';
20 const s = String(raw).trim();
21 if (!s) return '';
22 if (s.length > maxLen) return '';
23 for (let i = 0; i < s.length; i++) {
24 const c = s.charCodeAt(i);
25 if (c < 32 || c === 127) return '';
26 }
27 return s;
28 }
29
30 /**
31 * @param {NodeJS.ProcessEnv} [env]
32 * @returns {{ baseUrl: string, apiKey: string, lineageTimeoutMs: number, proxyMaxBytes: number } | null}
33 */
34 export function parseMuseConfigFromEnv(env = process.env) {
35 const base = String(env.MUSE_URL || '')
36 .trim()
37 .replace(/\/+$/, '');
38 if (!base) return null;
39 if (!base.startsWith('http://') && !base.startsWith('https://')) return null;
40 try {
41 void new URL(base);
42 } catch {
43 return null;
44 }
45 const apiKey =
46 env.MUSE_API_KEY != null && String(env.MUSE_API_KEY).trim()
47 ? String(env.MUSE_API_KEY).trim()
48 : '';
49 const lineageParsed = parseInt(String(env.MUSE_LINEAGE_TIMEOUT_MS || ''), 10);
50 const lineageTimeoutMs = Math.min(
51 60_000,
52 Math.max(
53 1000,
54 Number.isFinite(lineageParsed) && lineageParsed > 0 ? lineageParsed : DEFAULT_LINEAGE_TIMEOUT_MS,
55 ),
56 );
57 const proxyParsed = parseInt(String(env.MUSE_PROXY_MAX_BYTES || ''), 10);
58 const proxyMaxBytes = Math.min(
59 10 * 1024 * 1024,
60 Math.max(
61 1024,
62 Number.isFinite(proxyParsed) && proxyParsed > 0 ? proxyParsed : DEFAULT_PROXY_MAX_BYTES,
63 ),
64 );
65 return { baseUrl: base, apiKey, lineageTimeoutMs, proxyMaxBytes };
66 }
67
68 /**
69 * @param {NodeJS.ProcessEnv} [env]
70 * @returns {string[]}
71 */
72 export function parseMuseProxyPathPrefixes(env = process.env) {
73 const raw = env.MUSE_PROXY_PATH_PREFIXES || '/knowtation/v1/';
74 return raw
75 .split(',')
76 .map((s) => s.trim())
77 .filter(Boolean)
78 .map((p) => (p.startsWith('/') ? p : `/${p}`));
79 }
80
81 /**
82 * @param {string} relPath
83 * @param {string[]} prefixes
84 * @returns {boolean}
85 */
86 export function isAllowedMuseProxyPath(relPath, prefixes) {
87 const path = String(relPath || '').trim();
88 if (!path.startsWith('/')) return false;
89 if (path.includes('..')) return false;
90 let decoded;
91 try {
92 decoded = decodeURIComponent(path);
93 } catch {
94 return false;
95 }
96 if (decoded.includes('..') || decoded.includes('\\')) return false;
97 for (const pre of prefixes) {
98 const preNorm = pre.endsWith('/') ? pre.slice(0, -1) : pre;
99 if (decoded === preNorm) return true;
100 const withSlash = pre.endsWith('/') ? pre : `${pre}/`;
101 if (decoded.startsWith(withSlash)) return true;
102 }
103 return false;
104 }
105
106 function lineageRefUrl(baseUrl, proposalId, vaultId) {
107 const u = new URL(MUSE_LINEAGE_REF_PATH.replace(/^\//, ''), `${baseUrl.replace(/\/+$/, '')}/`);
108 u.searchParams.set('proposal_id', proposalId);
109 u.searchParams.set('vault_id', vaultId);
110 return u.href;
111 }
112
113 /**
114 * Prefer client-supplied ref; else optional GET lineage-ref. Never throws.
115 *
116 * @param {{
117 * clientRef: unknown,
118 * proposalId: string,
119 * vaultId: string,
120 * config: ReturnType<typeof parseMuseConfigFromEnv>,
121 * fetchFn?: typeof fetch,
122 * logWarn?: (msg: string, extra?: Record<string, unknown>) => void,
123 * }} opts
124 * @returns {Promise<string>}
125 */
126 export async function resolveExternalRefForApprove({
127 clientRef,
128 proposalId,
129 vaultId,
130 config,
131 fetchFn = globalThis.fetch,
132 logWarn = (msg, extra) => console.warn(msg, extra ?? ''),
133 }) {
134 const fromClient = normalizeExternalRef(clientRef);
135 if (fromClient) return fromClient;
136 if (!config) return '';
137 const pid = String(proposalId || '').trim();
138 const vid = String(vaultId || 'default').trim() || 'default';
139 if (!pid) return '';
140 const url = lineageRefUrl(config.baseUrl, pid, vid);
141 /** @type {Record<string, string>} */
142 const headers = { Accept: 'application/json' };
143 if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
144 const ac = new AbortController();
145 const t = setTimeout(() => ac.abort(), config.lineageTimeoutMs);
146 try {
147 const res = await fetchFn(url, { method: 'GET', headers, signal: ac.signal });
148 if (!res.ok) {
149 logWarn('[knowtation:muse-bridge] lineage-ref request failed', { status: res.status });
150 return '';
151 }
152 const text = await res.text();
153 let data;
154 try {
155 data = JSON.parse(text);
156 } catch {
157 logWarn('[knowtation:muse-bridge] lineage-ref invalid JSON', {});
158 return '';
159 }
160 const ref = data && typeof data.external_ref === 'string' ? data.external_ref : '';
161 return normalizeExternalRef(ref);
162 } catch (e) {
163 const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : String(e);
164 logWarn('[knowtation:muse-bridge] lineage-ref unreachable', { message });
165 return '';
166 } finally {
167 clearTimeout(t);
168 }
169 }
170
171 /**
172 * Admin read-only proxy: GET only, allowlisted path, size cap.
173 *
174 * @param {{
175 * config: NonNullable<ReturnType<typeof parseMuseConfigFromEnv>>,
176 * relativePath: string,
177 * fetchFn?: typeof fetch,
178 * logWarn?: (msg: string, extra?: Record<string, unknown>) => void,
179 * env?: NodeJS.ProcessEnv,
180 * }} opts
181 * @returns {Promise<{ ok: true, status: number, body: Buffer, contentType: string } | { ok: false, status: number, code: string, body: Buffer | null, contentType: string | null }>}
182 */
183 export async function fetchMuseProxiedGet({
184 config,
185 relativePath,
186 fetchFn = globalThis.fetch,
187 logWarn = (msg, extra) => console.warn(msg, extra ?? ''),
188 env = process.env,
189 }) {
190 const prefixes = parseMuseProxyPathPrefixes(env);
191 if (!isAllowedMuseProxyPath(relativePath, prefixes)) {
192 return { ok: false, status: 400, code: 'BAD_REQUEST', body: null, contentType: null };
193 }
194 const base = config.baseUrl.replace(/\/+$/, '');
195 const rel = relativePath.startsWith('/') ? relativePath : `/${relativePath}`;
196 const url = `${base}${rel}`;
197 /** @type {Record<string, string>} */
198 const headers = { Accept: '*/*' };
199 if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
200 const ac = new AbortController();
201 const t = setTimeout(() => ac.abort(), config.lineageTimeoutMs);
202 try {
203 const res = await fetchFn(url, { method: 'GET', headers, signal: ac.signal });
204 const ct = res.headers.get('content-type') || 'application/octet-stream';
205 const buf = await res.arrayBuffer();
206 if (buf.byteLength > config.proxyMaxBytes) {
207 logWarn('[knowtation:muse-bridge] proxy response too large', { bytes: buf.byteLength });
208 return { ok: false, status: 502, code: 'BAD_GATEWAY', body: null, contentType: null };
209 }
210 const body = Buffer.from(buf);
211 if (!res.ok) {
212 return { ok: false, status: res.status, code: 'UPSTREAM', body, contentType: ct };
213 }
214 return { ok: true, status: res.status, body, contentType: ct };
215 } catch (e) {
216 const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : String(e);
217 logWarn('[knowtation:muse-bridge] proxy fetch failed', { message });
218 return { ok: false, status: 502, code: 'BAD_GATEWAY', body: null, contentType: null };
219 } finally {
220 clearTimeout(t);
221 }
222 }
223
224 /**
225 * @param {string} pathOnlyForBody
226 * @returns {string | null} proposal id
227 */
228 export function proposalIdFromApprovePath(pathOnlyForBody) {
229 const m = String(pathOnlyForBody || '').match(/^\/api\/v1\/proposals\/([^/]+)\/approve\/?$/);
230 return m ? decodeURIComponent(m[1]) : null;
231 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago