server.mjs
3,365 lines 132.4 KB
Raw
sha256:6f47d53a6adbcf105ba1b9cfc126c788d6a0f461d197f84f78794914305b4bd5 fix(mcp): bound hosted discovery context Human patch 12 hours ago
1 /**
2 * Knowtation Hub Gateway — OAuth (Google/GitHub) + proxy to ICP canister with X-User-Id.
3 * For hosted product: user logs in here; all /api/* requests are proxied to canister with proof.
4 * Run: node server.mjs
5 * Env: SESSION_SECRET, CANISTER_URL, HUB_BASE_URL; optional GOOGLE_*, GITHUB_*, HUB_UI_ORIGIN, GATEWAY_PORT.
6 */
7
8 import crypto from 'crypto';
9 import fs from 'fs';
10 import path from 'path';
11 import { fileURLToPath } from 'url';
12 import dotenv from 'dotenv';
13 import express from 'express';
14 import cookieParser from 'cookie-parser';
15 import jwt from 'jsonwebtoken';
16 import passport from 'passport';
17 import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
18 import { Strategy as GitHubStrategy } from 'passport-github2';
19 import { stripeWebhookHandler, createCheckoutSession, createPortalSession } from './billing-stripe.mjs';
20 import { handleBillingSummary } from './billing-http.mjs';
21 import { isSubscriptionPriceId, isPackPriceId, priceIdFromTierShorthand, billingEnforced, MONTHLY_INCLUDED_CENTS_BY_TIER } from './billing-constants.mjs';
22 import { recordIndexingTokensAfterBridgeIndex } from './billing-index-usage.mjs';
23 import { runBillingGate } from './billing-middleware.mjs';
24 import { mergeHostedNoteBodyForCanister, isPostApiV1Notes, isNoteWriteRequest } from './apply-note-provenance.mjs';
25 import { deriveFacetsFromCanisterNotes, materializeListFrontmatter } from './note-facets.mjs';
26 import { applyGatewayCors } from './cors-middleware.mjs';
27 import { upstreamPathAndQuery, pathPartNoQuery, effectiveRequestPath } from './request-path.mjs';
28 import { applyScopeFilterToNotes } from '../lib/scope-filter.mjs';
29 import { createMetadataBulkHandlers } from './metadata-bulk-canister.mjs';
30 import { filterUpstreamResponseHeadersForDecodedBody } from './upstream-response-headers.mjs';
31 import { loadProposalRubric } from '../../lib/hub-proposal-rubric.mjs';
32 import { commitImageToRepo, validateImageExtension, validateMagicBytes } from '../../lib/github-commit-image.mjs';
33 import { parseMultipartFile } from './parse-multipart.mjs';
34 import { proposalPolicyEnvLocked } from '../../lib/hub-proposal-policy.mjs';
35 import {
36 loadHostedProposalLlmPrefs,
37 mergeHostedProposalLlmPrefs,
38 effectiveHostedEvaluationRequired,
39 effectiveHostedReviewHints,
40 effectiveHostedEnrich,
41 } from './proposal-llm-store.mjs';
42 import { augmentProposalEvaluationBodyForCanister } from './proposal-evaluation-canister-body.mjs';
43 import { augmentProposalCreateForHosted } from './proposal-create-hosted-body.mjs';
44 import { maybeScheduleHostedProposalReviewHints } from './proposal-review-hints-async.mjs';
45 import { proposalDataForHostedReviewHintsFromCreate } from './proposal-hints-create-context.mjs';
46 import { runHostedProposalEnrichAndPost } from './proposal-enrich-hosted.mjs';
47 import { isAttestationConfigured, createAttestation, verifyAttestation, verifyWithIcp, anchorPendingAttestations } from './attest-store.mjs';
48 import { loadBillingDb, mutateBillingDb } from './billing-store.mjs';
49 import { normalizeBillingUser, defaultUserRecord } from './billing-logic.mjs';
50 import {
51 mergeConsolidateRequestBodyWithBillingDefaults,
52 validateHostedSettingsConsolidationAdvanced,
53 } from '../../lib/hosted-consolidation-advanced.mjs';
54 import {
55 parseMuseConfigFromEnv,
56 resolveExternalRefForApprove,
57 proposalIdFromApprovePath,
58 fetchMuseProxiedGet,
59 } from '../../lib/muse-thin-bridge.mjs';
60 import { exportNoteRecordToContent } from '../../lib/export.mjs';
61 import { canisterAuthHeaders as canisterAuthHeadersFromEnv } from './canister-auth-headers.mjs';
62 import {
63 issueRefreshCookie,
64 createRefreshHandler,
65 createLogoutHandler,
66 refreshCookieOptions,
67 } from '../auth-session.mjs';
68 import {
69 createGatewayRefreshStore,
70 pruneRefreshTokens as pruneGatewayRefreshTokens,
71 } from './refresh-token-store.mjs';
72 import { createScoolingNoteOutlineSmokeRouter } from './scooling-note-outline-smoke.mjs';
73 import { createScoolingWriteBackSmokeRouter } from './scooling-write-back-smoke.mjs';
74 import { buildNoteOutline } from '../../lib/note-outline.mjs';
75 import { buildDocumentTree } from '../../lib/document-tree.mjs';
76 import { buildSectionSource } from '../../lib/section-source.mjs';
77 import { normalizeMetadataFacets } from '../../lib/vault.mjs';
78
79 // Safe when bundled (e.g. Netlify Functions CJS) where import.meta may be undefined
80 let projectRoot;
81 try {
82 const __dirname = path.dirname(fileURLToPath(import.meta.url));
83 projectRoot = path.resolve(__dirname, '..', '..');
84 } catch (_) {
85 projectRoot = process.cwd();
86 }
87 const envPath = path.join(projectRoot, '.env');
88 if (fs.existsSync(envPath)) dotenv.config({ path: envPath });
89
90 const PORT = parseInt(process.env.GATEWAY_PORT || process.env.PORT || '3340', 10);
91 const BASE_URL = process.env.HUB_BASE_URL || `http://localhost:${PORT}`;
92
93 // AIR Improvement D: when ATTESTATION_SECRET is set and no explicit AIR endpoint
94 // is provided, point AIR at this gateway's own /api/v1/attest route.
95 if (
96 process.env.ATTESTATION_SECRET &&
97 process.env.ATTESTATION_SECRET.length >= 32 &&
98 !process.env.KNOWTATION_AIR_ENDPOINT
99 ) {
100 process.env.KNOWTATION_AIR_ENDPOINT = `${BASE_URL}/api/v1/attest`;
101 console.log('[gateway] AIR auto-configured: KNOWTATION_AIR_ENDPOINT =', process.env.KNOWTATION_AIR_ENDPOINT);
102 }
103 const CANISTER_URL = (process.env.CANISTER_URL || '').replace(/\/$/, '');
104 const CANISTER_AUTH_SECRET = process.env.CANISTER_AUTH_SECRET || '';
105 const BRIDGE_URL = (process.env.BRIDGE_URL || '').replace(/\/$/, '');
106 if (BRIDGE_URL) {
107 try {
108 const u = new URL(BRIDGE_URL);
109 if (u.protocol !== 'http:' && u.protocol !== 'https:') {
110 throw new Error('BRIDGE_URL must use http: or https:');
111 }
112 } catch (e) {
113 console.error(
114 '[gateway] BRIDGE_URL must be an absolute URL with scheme (no path after host), e.g. https://your-bridge.netlify.app. Got:',
115 JSON.stringify(BRIDGE_URL),
116 e.message || e,
117 );
118 process.exit(1);
119 }
120 }
121 const HUB_UI_ORIGIN = (process.env.HUB_UI_ORIGIN || BASE_URL).replace(/\/$/, '');
122 const SESSION_SECRET = process.env.SESSION_SECRET || process.env.HUB_JWT_SECRET;
123 const JWT_EXPIRY = process.env.HUB_JWT_EXPIRY || '24h';
124
125 // Optional: comma-separated list of user IDs (e.g. google:123,github:456) who get role admin on hosted. Others get member.
126 const HUB_ADMIN_USER_IDS = (process.env.HUB_ADMIN_USER_IDS || '')
127 .split(',')
128 .map((s) => s.trim())
129 .filter(Boolean);
130 const adminUserIdsSet = new Set(HUB_ADMIN_USER_IDS);
131
132 function roleForSub(sub) {
133 return sub && adminUserIdsSet.has(sub) ? 'admin' : 'member';
134 }
135
136 function canisterAuthHeaders() {
137 return canisterAuthHeadersFromEnv();
138 }
139
140 passport.serializeUser((user, done) => done(null, user));
141 passport.deserializeUser((obj, done) => done(null, obj));
142
143 if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
144 passport.use(
145 new GoogleStrategy(
146 {
147 clientID: process.env.GOOGLE_CLIENT_ID,
148 clientSecret: process.env.GOOGLE_CLIENT_SECRET,
149 callbackURL: `${BASE_URL}/auth/callback/google`,
150 },
151 (_accessToken, _refreshToken, profile, done) => {
152 return done(null, { provider: 'google', id: profile.id, displayName: profile.displayName ?? '' });
153 }
154 )
155 );
156 }
157 if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
158 passport.use(
159 new GitHubStrategy(
160 {
161 clientID: process.env.GITHUB_CLIENT_ID,
162 clientSecret: process.env.GITHUB_CLIENT_SECRET,
163 callbackURL: `${BASE_URL}/auth/callback/github`,
164 },
165 (_accessToken, _refreshToken, profile, done) => {
166 return done(null, { provider: 'github', id: profile.id, displayName: profile.displayName ?? profile.username ?? '' });
167 }
168 )
169 );
170 }
171
172 function userId(user) {
173 if (!user || !user.provider || !user.id) return null;
174 return `${user.provider}:${user.id}`;
175 }
176
177 function issueToken(user) {
178 const sub = userId(user);
179 if (!sub) return null;
180 const role = roleForSub(sub);
181 return jwt.sign(
182 {
183 sub,
184 provider: user.provider,
185 id: user.id,
186 name: user.displayName ?? '',
187 role,
188 },
189 SESSION_SECRET,
190 { expiresIn: JWT_EXPIRY }
191 );
192 }
193
194 function verifyToken(token) {
195 try {
196 const payload = jwt.verify(token, SESSION_SECRET);
197 return payload.sub ?? null;
198 } catch (_) {
199 return null;
200 }
201 }
202
203 /**
204 * Verify the token and return the full decoded payload, or null if invalid/expired.
205 * Used only by the session-introspection endpoint; callers that only need `sub` use verifyToken.
206 * @param {string} token
207 * @returns {object|null}
208 */
209 function decodeVerifiedToken(token) {
210 try {
211 return jwt.verify(token, SESSION_SECRET);
212 } catch (_) {
213 return null;
214 }
215 }
216
217 /**
218 * Derive the set of API scopes from a role string.
219 * This is the C7 → C4 bridge: Scooling can read `scopes` today; when explicit per-user
220 * scope management (C4) is wired in, this function will be replaced with a real lookup
221 * without changing the C7 response shape.
222 * @param {string} role - 'admin' | 'member'
223 * @returns {string[]}
224 */
225 function scopesForRole(role) {
226 if (role === 'admin') return ['vault:read', 'vault:write', 'admin'];
227 return ['vault:read', 'vault:write'];
228 }
229
230 /**
231 * Re-mint a short-lived access token from a `sub` alone (used by POST /api/v1/auth/refresh,
232 * which only knows the user id carried by the refresh-token record). The `sub` is the canonical
233 * `provider:id`, so provider/id are reconstructed from it and the role is re-derived from the
234 * current admin allowlist — a refreshed token always reflects the latest role, exactly like
235 * login. Display name is omitted (cosmetic; the UI reads it from /settings).
236 * @param {string} sub
237 * @returns {string|null} signed JWT, or null when sub is missing
238 */
239 function issueAccessTokenForSub(sub) {
240 if (!sub || typeof sub !== 'string') return null;
241 const idx = sub.indexOf(':');
242 const provider = idx > 0 ? sub.slice(0, idx) : '';
243 const id = idx > 0 ? sub.slice(idx + 1) : sub;
244 return jwt.sign(
245 { sub, provider, id, name: '', role: roleForSub(sub) },
246 SESSION_SECRET,
247 { expiresIn: JWT_EXPIRY }
248 );
249 }
250
251 const IMAGE_PROXY_TOKEN_TTL_SECONDS = 300;
252
253 function signImageProxyToken(secret, uid) {
254 const exp = Math.floor(Date.now() / 1000) + IMAGE_PROXY_TOKEN_TTL_SECONDS;
255 const payload = `img\0${uid}\0${exp}`;
256 const sig = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
257 return `${exp}.${Buffer.from(uid).toString('base64url')}.${sig}`;
258 }
259
260 function verifyImageProxyToken(secret, token) {
261 if (typeof token !== 'string') return null;
262 const parts = token.split('.');
263 if (parts.length !== 3) return null;
264 const [expStr, uidB64, sig] = parts;
265 const exp = parseInt(expStr, 10);
266 if (!exp || Math.floor(Date.now() / 1000) > exp) return null;
267 let uid;
268 try { uid = Buffer.from(uidB64, 'base64url').toString(); } catch (_) { return null; }
269 if (!uid) return null;
270 const payload = `img\0${uid}\0${exp}`;
271 const expected = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
272 const sigBuf = Buffer.from(sig);
273 const expectedBuf = Buffer.from(expected);
274 if (sigBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(sigBuf, expectedBuf)) return null;
275 return uid;
276 }
277
278 const app = express();
279 // Trust the first downstream proxy so express-rate-limit (and any future IP-based middleware)
280 // reads the real client IP from X-Forwarded-For instead of the CDN/load-balancer address.
281 app.set('trust proxy', 1);
282
283 // Remove X-Powered-By: Express — leaking server technology is unnecessary attack surface.
284 app.disable('x-powered-by');
285
286 // Netlify rewrites /* -> /.netlify/functions/gateway/:splat, so the function may receive
287 // a path like /.netlify/functions/gateway/api/v1/notes. Express would not match /api/v1/* routes.
288 const NETLIFY_GW_PREFIX = '/.netlify/functions/gateway';
289 app.use((req, _res, next) => {
290 const raw = req.url || '/';
291 const q = raw.indexOf('?');
292 const pathPart = q >= 0 ? raw.slice(0, q) : raw;
293 const queryPart = q >= 0 ? raw.slice(q) : '';
294 if (pathPart === NETLIFY_GW_PREFIX || pathPart.startsWith(`${NETLIFY_GW_PREFIX}/`)) {
295 const rest =
296 pathPart === NETLIFY_GW_PREFIX ? '/' : pathPart.slice(NETLIFY_GW_PREFIX.length) || '/';
297 const nextUrl = rest + queryPart;
298 req.url = nextUrl;
299 // Express may set originalUrl to the internal function path; keep it aligned with req.path.
300 req.originalUrl = nextUrl;
301 delete req._parsedUrl;
302 delete req._parsedOriginalUrl;
303 }
304 next();
305 });
306
307 app.use(cookieParser());
308 app.post('/api/v1/billing/webhook', express.raw({ type: 'application/json' }), (req, res) => {
309 stripeWebhookHandler(req, res);
310 });
311 app.use(express.json({ limit: '10mb' }));
312 app.use(passport.initialize());
313
314 // CORS: production MUST set HUB_CORS_ORIGIN (apex + www) for credentialed-style responses.
315 // If unset, we use * and omit Allow-Credentials — otherwise browsers block (* + credentials = Failed to fetch).
316 // See hub/gateway/cors-middleware.mjs.
317 const corsOrigins = process.env.HUB_CORS_ORIGIN
318 ? process.env.HUB_CORS_ORIGIN.split(',').map((o) => o.trim()).filter(Boolean)
319 : [];
320 app.use((req, res, next) => {
321 applyGatewayCors(res, req.get('Origin'), corsOrigins);
322 next();
323 });
324
325 // Persistent sessions (refresh-token rotation), hosted edition. The durable, hashed refresh
326 // token is delivered as an HttpOnly cookie; the security logic lives in
327 // hub/lib/refresh-token-core.mjs via the blob-backed store below (shared with self-hosted).
328 const refreshStore = createGatewayRefreshStore();
329
330 /**
331 * Cookie policy for the hosted refresh token.
332 * - When the UI and gateway share an origin (HUB_CORS_ORIGIN unset), the cookie is first-party
333 * and SameSite=Lax is correct and most robust.
334 * - When HUB_CORS_ORIGIN is set the UI is on another origin, so the credentialed cross-site
335 * request requires SameSite=None (which forces Secure). NOTE: a cross-site cookie is only
336 * delivered reliably when the gateway is a subdomain of the UI's registrable domain (e.g.
337 * UI knowtation.store + gateway api.knowtation.store); browsers increasingly block
338 * unrelated third-party cookies. Same-origin (single origin for UI + API) is recommended.
339 * Scoped to the auth path so the cookie is only ever sent to /api/v1/auth endpoints.
340 */
341 function refreshCookiePolicy() {
342 const crossOrigin = corsOrigins.length > 0;
343 return refreshCookieOptions({
344 secure: crossOrigin || BASE_URL.startsWith('https://'),
345 sameSite: crossOrigin ? 'none' : 'lax',
346 maxAgeMs: 90 * 24 * 60 * 60 * 1000,
347 });
348 }
349
350 /**
351 * Issue the HttpOnly refresh cookie at the end of a successful OAuth login. Best-effort: a
352 * refresh-store write failure must never block login (the access token still works).
353 * @param {import('express').Response} res
354 * @param {import('express').Request} req
355 * @param {string|null} sub
356 */
357 async function issueRefreshCookieSafe(res, req, sub) {
358 if (!sub) {
359 console.warn('[gateway] refresh cookie skipped: no sub resolved from req.user');
360 return;
361 }
362 try {
363 await issueRefreshCookie(res, {
364 store: refreshStore,
365 sub,
366 cookieOptions: refreshCookiePolicy,
367 meta: { ua: String(req.headers['user-agent'] || '').slice(0, 256) },
368 });
369 console.info('[gateway] refresh cookie issued for sub=%s', sub);
370 } catch (err) {
371 // Login still proceeds with the access token even if the refresh store is unavailable, but
372 // the failure MUST be surfaced — swallowing it silently made a persistent-login outage
373 // undiagnosable. `authBlobPresent` distinguishes the two failure modes:
374 // false → the Netlify Blob was not provisioned for this invocation, so the store fell back
375 // to a file write that fails on the read-only function FS;
376 // true → the blob was provisioned but the read/write itself was rejected.
377 const authBlobPresent = Boolean(globalThis.__knowtation_gateway_auth_blob);
378 console.error(
379 '[gateway] refresh cookie FAILED for sub=%s authBlobPresent=%s: %s',
380 sub,
381 authBlobPresent,
382 err && err.stack ? err.stack : (err && err.message) || String(err),
383 );
384 }
385 }
386
387 // Authenticated Hub JSON must not be cached (browser 304 / CDN reuse shows stale frontmatter).
388 app.use('/api/v1', (req, res, next) => {
389 res.set('Cache-Control', 'private, no-store, must-revalidate');
390 next();
391 });
392
393 // Health (no auth) — returns { ok: true }. If a CDN or host wrapper returns usage_exceeded, that is outside this app (check Netlify site / account limits and which commit is deployed).
394 app.get('/health', (_req, res) => res.json({ ok: true }));
395 app.get('/api/v1/health', (_req, res) => res.json({ ok: true }));
396 app.use(createScoolingNoteOutlineSmokeRouter());
397 app.use(createScoolingWriteBackSmokeRouter());
398
399 // Which OAuth providers are configured (no auth)
400 app.get('/api/v1/auth/providers', (_req, res) => {
401 res.json({
402 google: Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET),
403 github: Boolean(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET),
404 });
405 });
406
407 // C7 Session introspection — returns the verified identity and derived scopes for the bearer.
408 // Designed for Scooling (cross-origin, Bearer auth) and the Hub UI alike.
409 // GET /api/v1/auth/session → { sub, provider, id, name, role, iat, exp, scopes }
410 // Only reads what is already in the signed JWT — no extra DB call, no data elevation.
411 app.options('/api/v1/auth/session', (_req, res) => res.status(204).end());
412 app.get('/api/v1/auth/session', (req, res) => {
413 const auth = req.headers.authorization;
414 if (!auth || !auth.startsWith('Bearer ')) {
415 return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
416 }
417 const token = auth.slice(7);
418 const payload = decodeVerifiedToken(token);
419 if (!payload || !payload.sub) {
420 return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
421 }
422 return res.json({
423 sub: payload.sub,
424 provider: payload.provider ?? '',
425 id: payload.id ?? '',
426 name: payload.name ?? '',
427 role: payload.role ?? 'member',
428 iat: payload.iat,
429 exp: payload.exp,
430 scopes: scopesForRole(payload.role ?? 'member'),
431 });
432 });
433
434 // Persistent sessions: exchange the HttpOnly refresh cookie for a fresh access token, and real
435 // server-side logout (revokes the refresh token, not just the client cookie). Mounted BEFORE the
436 // bridge/canister proxies so these are handled locally and never forwarded upstream.
437 //
438 // Rate limiting note: an in-memory express-rate-limit is ineffective on Netlify (each function
439 // invocation is isolated, no shared counter) and trips ERR_ERL_* under serverless proxies. Brute
440 // force is bounded instead by edge limits (see hub/gateway/README.md) and, more fundamentally, by
441 // the opaque high-entropy token + rotation/reuse detection in refresh-token-core.mjs.
442 app.options(['/api/v1/auth/refresh', '/api/v1/auth/logout'], (_req, res) => res.status(204).end());
443 app.post(
444 '/api/v1/auth/refresh',
445 createRefreshHandler({
446 store: refreshStore,
447 issueAccessToken: issueAccessTokenForSub,
448 cookieOptions: refreshCookiePolicy,
449 meta: (req) => ({ ua: String(req.headers['user-agent'] || '').slice(0, 256) }),
450 })
451 );
452 app.post(
453 '/api/v1/auth/logout',
454 createLogoutHandler({ store: refreshStore, cookieOptions: refreshCookiePolicy })
455 );
456 // On a persistent gateway (local/Docker/VPS) opportunistically prune dead refresh records at
457 // startup. Skipped on Netlify, where the blob store is provisioned per-invocation and a cold-start
458 // prune would add latency to the first request; rely on rotation/expiry to keep the store small.
459 if (!process.env.NETLIFY) {
460 Promise.resolve()
461 .then(() => pruneGatewayRefreshTokens())
462 .catch(() => { /* best effort; never fatal */ });
463 }
464
465 // Auth: login redirect — plan routes GET /auth/login, GET /auth/callback/google|github. Preserve invite in state for post-login redirect.
466 // Phase D3: mcp_state query param is passed through OAuth state for MCP authorization flow.
467 app.get('/auth/login', (req, res, next) => {
468 const provider = (req.query.provider || 'google').toLowerCase();
469 const invite = typeof req.query.invite === 'string' ? req.query.invite.trim() : '';
470 const mcpState = typeof req.query.mcp_state === 'string' ? req.query.mcp_state.trim() : '';
471 let state;
472 if (mcpState) {
473 state = `mcp:${mcpState}`;
474 } else {
475 state = invite || undefined;
476 }
477 if (provider === 'google' && process.env.GOOGLE_CLIENT_ID) {
478 return passport.authenticate('google', { scope: ['profile'], state })(req, res, next);
479 }
480 if (provider === 'github' && process.env.GITHUB_CLIENT_ID) {
481 return passport.authenticate('github', { scope: ['user:email'], state })(req, res, next);
482 }
483 return res.status(400).json({ error: `Unknown or disabled provider: ${provider}`, code: 'BAD_REQUEST' });
484 });
485
486 function postLoginRedirect(token, req) {
487 if (!token) return HUB_UI_ORIGIN + '/hub/?auth_error=1';
488 const invite = typeof req.query.state === 'string' ? req.query.state.trim() : '';
489 let fragment = `token=${encodeURIComponent(token)}`;
490 if (invite && invite.length > 0) fragment += '&invite=' + encodeURIComponent(invite);
491 return `${HUB_UI_ORIGIN}/hub/#${fragment}`;
492 }
493
494 app.get(
495 '/auth/callback/google',
496 passport.authenticate('google', { session: false }),
497 async (req, res) => {
498 const state = typeof req.query.state === 'string' ? req.query.state : '';
499 if (state.startsWith('mcp:') && app._mcpOAuthProvider) {
500 const sub = userId(req.user);
501 if (!sub) return res.status(401).json({ error: 'auth_failed' });
502 return app._mcpOAuthProvider.completeMcpAuthorization(state.slice(4), sub, res);
503 }
504 const token = issueToken(req.user);
505 await issueRefreshCookieSafe(res, req, userId(req.user));
506 res.redirect(postLoginRedirect(token, req));
507 }
508 );
509 app.get(
510 '/auth/callback/github',
511 passport.authenticate('github', { session: false }),
512 async (req, res) => {
513 const state = typeof req.query.state === 'string' ? req.query.state : '';
514 if (state.startsWith('mcp:') && app._mcpOAuthProvider) {
515 const sub = userId(req.user);
516 if (!sub) return res.status(401).json({ error: 'auth_failed' });
517 return app._mcpOAuthProvider.completeMcpAuthorization(state.slice(4), sub, res);
518 }
519 const token = issueToken(req.user);
520 await issueRefreshCookieSafe(res, req, userId(req.user));
521 res.redirect(postLoginRedirect(token, req));
522 }
523 );
524
525 // Hub UI may call login under /api/v1/auth for consistency — redirect to /auth (preserve invite for post-login consume)
526 app.get('/api/v1/auth/login', (req, res) => {
527 const provider = (req.query.provider || 'google').toLowerCase();
528 let url = `${BASE_URL}/auth/login?provider=${encodeURIComponent(provider)}`;
529 const invite = typeof req.query.invite === 'string' ? req.query.invite.trim() : '';
530 if (invite) url += '&invite=' + encodeURIComponent(invite);
531 res.redirect(url);
532 });
533
534 // Phase D2/D3: MCP gateway + OAuth 2.1.
535 // MCP requires stateful sessions (SSE, session pool) that are incompatible with Netlify's
536 // serverless function model (26s timeout, no shared memory between invocations).
537 // On Netlify, only the OAuth discovery endpoints are mounted (lightweight, stateless).
538 // The full /mcp session endpoint requires a persistent Express server (local dev, Docker, VPS,
539 // or a dedicated MCP host like Railway/Fly.io). See docs/AGENT-INTEGRATION.md §2 (hosted MCP).
540 if (SESSION_SECRET && !process.env.NETLIFY) {
541 import('./mcp-oauth-provider.mjs').then(async ({ KnowtationOAuthProvider }) => {
542 const { mcpAuthRouter } = await import('@modelcontextprotocol/sdk/server/auth/router.js');
543 const oauthProvider = new KnowtationOAuthProvider({
544 sessionSecret: SESSION_SECRET,
545 baseUrl: BASE_URL,
546 });
547 app._mcpOAuthProvider = oauthProvider;
548 // @modelcontextprotocol/sdk OAuth routes use express-rate-limit behind Nginx. The limiter's
549 // default validations (X-Forwarded-For vs Express trust proxy) still throw ERR_ERL_* on some
550 // Express/SDK mount combinations. Disable express-rate-limit validations for these routes only;
551 // limits stay on; edge limits remain in Nginx (gateway deploy notes in hub/gateway/README.md).
552 const mcpOAuthSdkRateLimitOpts = {
553 rateLimit: { validate: false },
554 };
555 app.use(mcpAuthRouter({
556 provider: oauthProvider,
557 issuerUrl: new URL(BASE_URL),
558 scopesSupported: ['vault:read', 'vault:write', 'vault:admin'],
559 authorizationOptions: mcpOAuthSdkRateLimitOpts,
560 tokenOptions: mcpOAuthSdkRateLimitOpts,
561 clientRegistrationOptions: mcpOAuthSdkRateLimitOpts,
562 revocationOptions: mcpOAuthSdkRateLimitOpts,
563 }));
564 console.log('[gateway] MCP OAuth 2.1 endpoints mounted');
565 }).catch((e) => {
566 console.error('[gateway] MCP OAuth router failed to load:', e.message || e);
567 });
568 } else if (SESSION_SECRET && process.env.NETLIFY) {
569 console.log('[gateway] MCP OAuth/session endpoints skipped on Netlify (stateful sessions require persistent server)');
570 }
571
572 if (BRIDGE_URL && CANISTER_URL && !process.env.NETLIFY) {
573 import('./mcp-proxy.mjs').then(({ createMcpProxyRouter }) => {
574 const mcpRouter = createMcpProxyRouter({
575 getUserId,
576 getHostedAccessContext,
577 canisterUrl: CANISTER_URL,
578 canisterAuthSecret: CANISTER_AUTH_SECRET,
579 bridgeUrl: BRIDGE_URL,
580 gatewayApiBaseUrl: BASE_URL.replace(/\/$/, ''),
581 sessionSecret: SESSION_SECRET || '',
582 });
583 app.use('/mcp', mcpRouter);
584 console.log('[gateway] MCP endpoint mounted at /mcp');
585 if (!CANISTER_AUTH_SECRET) {
586 console.warn(
587 '[gateway] MCP /mcp: CANISTER_AUTH_SECRET is empty. Direct canister HTTP calls from hosted MCP (list_notes, get_note, write, enrich; summarize note fetches) send no X-Gateway-Auth and the canister returns GATEWAY_AUTH_REQUIRED. Set the same CANISTER_AUTH_SECRET as the Netlify gateway and as configured on the canister (admin_set_gateway_auth_secret), then pm2 restart with --update-env.'
588 );
589 }
590 }).catch((e) => {
591 console.error('[gateway] MCP proxy failed to load:', e.message || e);
592 });
593 } else if (process.env.NETLIFY) {
594 app.all('/mcp', (_req, res) => {
595 res.status(503).json({
596 error: 'MCP endpoint requires a persistent server. Connect to the dedicated MCP host or use self-hosted deployment.',
597 code: 'MCP_NETLIFY_UNSUPPORTED',
598 docs: 'https://github.com/aaronrene/knowtation/blob/main/docs/AGENT-INTEGRATION.md',
599 });
600 });
601 }
602
603 // Connect GitHub + Back up now: proxy to bridge when BRIDGE_URL is set (single origin for UI)
604 if (BRIDGE_URL) {
605 app.get('/api/v1/auth/github-connect', (req, res) => {
606 const q = new URLSearchParams(req.query).toString();
607 res.redirect(`${BRIDGE_URL}/auth/github-connect${q ? '?' + q : ''}`);
608 });
609 // Browsers send OPTIONS preflight before POST with Authorization + JSON body. The bridge only
610 // registers POST /api/v1/vault/sync, so proxying OPTIONS returns 404 and surfaces as "Failed to fetch".
611 app.all('/api/v1/vault/sync', async (req, res) => {
612 if (req.method === 'OPTIONS') {
613 return res.status(204).end();
614 }
615 const url = BRIDGE_URL + '/api/v1/vault/sync' + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '');
616 await proxyTo(BRIDGE_URL, url, req, res);
617 });
618 app.all('/api/v1/vaults/:vaultId', async (req, res) => {
619 if (req.method === 'OPTIONS') {
620 return res.status(204).end();
621 }
622 if (req.method !== 'DELETE') {
623 return res.status(405).json({ error: 'Method not allowed', code: 'METHOD_NOT_ALLOWED' });
624 }
625 if (!(await runBillingGate(req, res, getUserId))) return;
626 const q = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
627 const url =
628 BRIDGE_URL + '/api/v1/vaults/' + encodeURIComponent(req.params.vaultId) + q;
629 await proxyTo(BRIDGE_URL, url, req, res);
630 });
631 app.get('/api/v1/vault/github-status', async (req, res) => {
632 const url = BRIDGE_URL + '/api/v1/vault/github-status' + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '');
633 await proxyTo(BRIDGE_URL, url, req, res);
634 });
635 app.post('/api/v1/search', async (req, res) => {
636 if (!(await runBillingGate(req, res, getUserId))) return;
637 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/search', req, res);
638 });
639 app.post('/api/v1/index', async (req, res) => {
640 if (!(await runBillingGate(req, res, getUserId))) return;
641 const uid = getUserId(req);
642 const headers = { ...req.headers, host: new URL(BRIDGE_URL).host };
643 delete headers.origin;
644 delete headers.referer;
645 const opts = { method: 'POST', headers };
646 const payload =
647 req.body === undefined ? undefined : typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
648 if (payload !== undefined) {
649 opts.body = payload;
650 stripStaleOutboundBodyHeaders(headers);
651 }
652 try {
653 const upstream = await fetch(BRIDGE_URL + '/api/v1/index', opts);
654 const body = await upstream.text();
655 if (uid) await recordIndexingTokensAfterBridgeIndex(uid, upstream.status, body);
656 const hop = filterUpstreamResponseHeadersForDecodedBody(upstream.headers.entries());
657 res.status(upstream.status).set(Object.fromEntries(hop));
658 res.send(body);
659 } catch (e) {
660 console.error('Gateway proxy (bridge) error:', e.message);
661 res.status(502).json({ error: 'Bad Gateway', code: 'BAD_GATEWAY' });
662 }
663 });
664 // GET /api/v1/index/status — read-only sidecar describing the last successful
665 // index + whether a background job is currently in flight. Added in May 2026
666 // alongside the auto-routing index path (PR #205) so the Hub UI can render
667 // `Last indexed: N minutes ago` next to the Re-index button.
668 //
669 // Auth scoping happens at the bridge (`requireBridgeAuth` + vault-scoping).
670 // We deliberately DO NOT run `runBillingGate` here — this is a passive read,
671 // not a billable index operation, and the Hub UI calls this on every page
672 // load (so charging would be both incorrect and abusive).
673 //
674 // See `test/gateway-index-status-proxy.test.mjs` for the contract test that
675 // prevents this handler from being silently removed.
676 app.get('/api/v1/index/status', async (req, res) => {
677 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/index/status', req, res);
678 });
679 // Roles & invites: proxy to bridge (bridge has persistent storage)
680 app.get('/api/v1/roles', requireAdmin, async (req, res) => {
681 await proxyTo(BRIDGE_URL, BRIDGE_URL + req.originalUrl, req, res);
682 });
683 app.post('/api/v1/roles', requireAdmin, async (req, res) => {
684 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/roles', req, res);
685 });
686 app.post('/api/v1/roles/evaluator-may-approve', requireAdmin, async (req, res) => {
687 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/roles/evaluator-may-approve', req, res);
688 });
689 app.get('/api/v1/invites', requireAdmin, async (req, res) => {
690 await proxyTo(BRIDGE_URL, BRIDGE_URL + req.originalUrl, req, res);
691 });
692 app.post('/api/v1/invites', requireAdmin, async (req, res) => {
693 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/invites', req, res);
694 });
695 app.delete('/api/v1/invites/:token', requireAdmin, async (req, res) => {
696 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/invites/' + encodeURIComponent(req.params.token), req, res);
697 });
698 app.post('/api/v1/invites/consume', (req, res, next) => {
699 const uid = getUserId(req);
700 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
701 next();
702 }, async (req, res) => {
703 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/invites/consume', req, res);
704 });
705 app.get('/api/v1/workspace', requireAdmin, async (req, res) => {
706 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/workspace', req, res);
707 });
708 app.post('/api/v1/workspace', requireAdmin, async (req, res) => {
709 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/workspace', req, res);
710 });
711 app.get('/api/v1/vault-access', requireAdmin, async (req, res) => {
712 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/vault-access', req, res);
713 });
714 app.post('/api/v1/vault-access', requireAdmin, async (req, res) => {
715 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/vault-access', req, res);
716 });
717 app.get('/api/v1/scope', requireAdmin, async (req, res) => {
718 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/scope', req, res);
719 });
720 app.post('/api/v1/scope', requireAdmin, async (req, res) => {
721 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/scope', req, res);
722 });
723 app.get('/api/v1/hosted-context', async (req, res, next) => {
724 const uid = getUserId(req);
725 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
726 next();
727 }, async (req, res) => {
728 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/hosted-context', req, res);
729 });
730
731 // Memory routes: proxy to bridge (per-user/vault isolation handled by bridge)
732 app.get('/api/v1/memory/:key', async (req, res) => {
733 const q = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
734 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/memory/' + encodeURIComponent(req.params.key) + q, req, res);
735 });
736 app.post('/api/v1/memory/store', async (req, res) => {
737 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/memory/store', req, res);
738 });
739 app.get('/api/v1/memory', async (req, res) => {
740 const q = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
741 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/memory' + q, req, res);
742 });
743 app.post('/api/v1/memory/search', async (req, res) => {
744 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/memory/search', req, res);
745 });
746 app.delete('/api/v1/memory/clear', async (req, res) => {
747 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/memory/clear', req, res);
748 });
749 app.get('/api/v1/memory-stats', async (req, res) => {
750 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/memory-stats', req, res);
751 });
752 // Consolidation routes: proxy to bridge with billing gate on POST
753 app.post('/api/v1/memory/consolidate', async (req, res) => {
754 if (!(await runBillingGate(req, res, getUserId))) return;
755 const uid = getUserId(req);
756 try {
757 const db = await loadBillingDb();
758 const raw = db.users?.[uid] || defaultUserRecord(uid);
759 const u = normalizeBillingUser(raw);
760 req.body = mergeConsolidateRequestBodyWithBillingDefaults(
761 req.body && typeof req.body === 'object' ? req.body : {},
762 u,
763 );
764 } catch (_) {
765 /* fail open: bridge merges with billing file / defaults */
766 }
767 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/memory/consolidate', req, res);
768 });
769 app.get('/api/v1/memory/consolidate/status', async (req, res) => {
770 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/memory/consolidate/status', req, res);
771 });
772
773 // Phase 18: image upload — gateway buffers the file, fetches GitHub token from bridge,
774 // then commits directly to GitHub (avoids forwarding a multipart body to another Lambda).
775 app.post(/^\/api\/v1\/notes\/(.+)\/upload-image$/, async (req, res) => {
776 const uid = getUserId(req);
777 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
778
779 // 1. Get GitHub connection (token + repo) from bridge.
780 let ghToken, ghRepo;
781 try {
782 const tokenRes = await fetch(`${BRIDGE_URL}/api/v1/vault/github-token`, {
783 headers: { authorization: req.headers.authorization || '' },
784 });
785 if (!tokenRes.ok) {
786 const errData = await tokenRes.json().catch(() => ({}));
787 return res.status(tokenRes.status).json({
788 error: errData.error || 'GitHub not connected',
789 code: errData.code || 'GITHUB_NOT_CONNECTED',
790 });
791 }
792 const data = await tokenRes.json();
793 ghToken = data.token;
794 ghRepo = data.repo;
795 } catch (e) {
796 return res.status(502).json({ error: 'Could not reach bridge', code: 'BAD_GATEWAY' });
797 }
798 if (!ghToken) return res.status(400).json({ error: 'GitHub not connected', code: 'GITHUB_NOT_CONNECTED' });
799 if (!ghRepo) return res.status(400).json({ error: 'GitHub repo not set. Back up once first to set the remote.', code: 'GITHUB_NOT_CONFIGURED' });
800
801 // 2. Buffer the uploaded file from the multipart body.
802 let fileBuffer, originalName, mimeType;
803 try {
804 const raw = await bufferImportRequestBody(req);
805 const ct = req.headers['content-type'] || '';
806 const boundaryMatch = ct.match(/boundary=([^\s;]+)/i);
807 if (!boundaryMatch) return res.status(400).json({ error: 'Content-Type boundary missing', code: 'BAD_REQUEST' });
808 const boundary = boundaryMatch[1];
809 // Parse the first file part from the multipart body manually (avoids multer dependency).
810 const parsed = parseMultipartFile(raw, boundary);
811 if (!parsed) return res.status(400).json({ error: 'image file required', code: 'BAD_REQUEST' });
812 fileBuffer = parsed.data;
813 originalName = parsed.filename || 'image.jpg';
814 mimeType = parsed.contentType || 'application/octet-stream';
815 } catch (e) {
816 return res.status(500).json({ error: 'Could not read upload body', code: 'INTERNAL_ERROR' });
817 }
818
819 // 3. Validate extension, content-type, and magic bytes.
820 try { validateImageExtension(originalName); } catch (e) {
821 return res.status(400).json({ error: e.message, code: 'BAD_REQUEST' });
822 }
823 if (!mimeType.toLowerCase().startsWith('image/')) {
824 return res.status(400).json({ error: 'File content-type must be image/*', code: 'BAD_REQUEST' });
825 }
826 const ext = originalName.split('.').pop().toLowerCase();
827 const magicOk = validateMagicBytes(fileBuffer, ext);
828 if (!magicOk) {
829 return res.status(400).json({ error: 'File content does not match declared image type', code: 'BAD_REQUEST' });
830 }
831
832 // 4. Commit to GitHub directly from the gateway.
833 try {
834 const now = new Date();
835 const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
836 const safeName = originalName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128);
837 const uniqueName = `${Date.now()}-${safeName}`;
838 const repoFilePath = `media/images/${yearMonth}/${uniqueName}`;
839 const result = await commitImageToRepo({
840 accessToken: ghToken,
841 repoUrl: ghRepo,
842 filePath: repoFilePath,
843 fileBuffer,
844 commitMessage: `Add image: ${safeName}`,
845 });
846 return res.json({
847 url: result.url,
848 inserted_markdown: `![${safeName}](${result.url})`,
849 sha: result.sha,
850 repo_path: repoFilePath,
851 repo_private: result.isPrivate === true,
852 });
853 } catch (e) {
854 const msg = e.message || String(e);
855 const clientErr = /not found|not connected|lacks permission|lacks repo|Reconnect|scope|remote/i.test(msg);
856 return res.status(clientErr ? 400 : 500).json({ error: msg, code: clientErr ? 'BAD_REQUEST' : 'RUNTIME_ERROR' });
857 }
858 });
859
860 app.get('/api/v1/vault/image-proxy-token', (req, res) => {
861 const uid = getUserId(req);
862 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
863 if (!SESSION_SECRET) return res.status(503).json({ error: 'Not configured', code: 'NOT_CONFIGURED' });
864 const token = signImageProxyToken(SESSION_SECRET, uid);
865 res.json({ token, expires_in: IMAGE_PROXY_TOKEN_TTL_SECONDS });
866 });
867
868 app.get('/api/v1/vault/image-proxy', async (req, res) => {
869 const auth = req.headers.authorization || '';
870 const headerToken = auth.startsWith('Bearer ') ? auth.slice(7) : null;
871 const queryToken = typeof req.query.token === 'string' ? req.query.token : null;
872 let uid = headerToken ? getUserId({ headers: { authorization: `Bearer ${headerToken}` } }) : null;
873 let jwtTokenForBridge = headerToken || '';
874 if (!uid && queryToken && SESSION_SECRET) {
875 uid = verifyImageProxyToken(SESSION_SECRET, queryToken);
876 }
877 // Backward compat: old hub.js sends full JWT as ?token= (pre-signed-token change).
878 if (!uid && queryToken) {
879 const fromJwt = getUserId({ headers: { authorization: `Bearer ${queryToken}` } });
880 if (fromJwt) { uid = fromJwt; jwtTokenForBridge = queryToken; }
881 }
882 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
883
884 // When uid is known but no JWT to forward (HMAC token auth path), mint a
885 // short-lived gateway JWT so the bridge can identify the user.
886 if (!jwtTokenForBridge && SESSION_SECRET) {
887 try { jwtTokenForBridge = jwt.sign({ sub: uid }, SESSION_SECRET, { expiresIn: '5m' }); } catch (_) {}
888 }
889
890 const rawUrl = typeof req.query.url === 'string' ? req.query.url : '';
891 if (!rawUrl) return res.status(400).json({ error: 'url parameter required', code: 'BAD_REQUEST' });
892
893 // Only proxy raw.githubusercontent.com URLs to prevent SSRF.
894 const rawMatch = rawUrl.match(
895 /^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/i,
896 );
897 if (!rawMatch) {
898 return res.status(400).json({ error: 'Only raw.githubusercontent.com URLs are supported', code: 'BAD_REQUEST' });
899 }
900 const [, owner, repo, ref, filePath] = rawMatch;
901
902 let ghToken = null;
903 if (jwtTokenForBridge) {
904 try {
905 const tokenRes = await fetch(`${BRIDGE_URL}/api/v1/vault/github-token`, {
906 headers: { authorization: `Bearer ${jwtTokenForBridge}` },
907 });
908 if (tokenRes.ok) {
909 const data = await tokenRes.json();
910 ghToken = data.token || null;
911 }
912 } catch (_) { /* bridge unreachable — fall through, public repos still work */ }
913 }
914
915 if (!ghToken) {
916 // No stored GitHub token — assume the repo is public and redirect directly.
917 return res.redirect(302, rawUrl);
918 }
919
920 // Use the GitHub Contents API to get a signed, short-lived download_url for the file.
921 // This avoids sending the PAT in the redirect URL while still letting private-repo images load.
922 const apiUrl =
923 `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}` +
924 `?ref=${encodeURIComponent(ref)}`;
925 try {
926 const apiRes = await fetch(apiUrl, {
927 headers: {
928 Authorization: `token ${ghToken}`,
929 Accept: 'application/vnd.github.v3+json',
930 'User-Agent': 'Knowtation-Hub/1.0',
931 },
932 });
933 if (apiRes.ok) {
934 const data = await apiRes.json();
935 const dlUrl = data.download_url || rawUrl;
936 res.setHeader('Cache-Control', 'private, max-age=300');
937 return res.redirect(302, dlUrl);
938 }
939 // GitHub returned an error (e.g. 404 file missing, 403 large-file).
940 const errBody = await apiRes.json().catch(() => ({}));
941 return res.status(apiRes.status).json({
942 error: errBody.message || 'Image not found on GitHub',
943 code: 'UPSTREAM_ERROR',
944 });
945 } catch (e) {
946 return res.status(502).json({ error: 'Failed to fetch image metadata from GitHub', code: 'BAD_GATEWAY' });
947 }
948 });
949 }
950
951 /**
952 * Safe client request headers that may be forwarded to upstream services.
953 * Using an explicit allowlist prevents host-header injection, internal proxy header leakage,
954 * and forwarding of security-sensitive headers (cookies, x-forwarded-for, etc.) to upstreams.
955 */
956 const PROXY_HEADER_ALLOWLIST = new Set([
957 'content-type',
958 'accept',
959 'accept-language',
960 'accept-encoding',
961 ]);
962
963 /**
964 * Incoming headers describe the *client* body. We often re-serialize JSON (provenance merge), so
965 * length and transfer-related headers must not be forwarded: Undici can hang or mis-send if
966 * Content-Length still matches the old, shorter body.
967 */
968 function stripStaleOutboundBodyHeaders(headers) {
969 for (const k of Object.keys(headers)) {
970 const l = k.toLowerCase();
971 if (
972 l === 'content-length' ||
973 l === 'transfer-encoding' ||
974 l === 'content-encoding'
975 ) {
976 delete headers[k];
977 }
978 }
979 }
980
981 async function proxyTo(baseUrl, url, req, res) {
982 const headers = { host: new URL(baseUrl).host };
983 // Allowlist: only forward safe headers; also forward authorization for bridge JWT auth
984 // and x-vault-id for vault routing. Never forward origin, referer, cookies, or proxy headers.
985 for (const k of PROXY_HEADER_ALLOWLIST) {
986 if (req.headers[k] !== undefined) headers[k] = req.headers[k];
987 }
988 if (req.headers.authorization) headers.authorization = req.headers.authorization;
989 if (req.headers['x-vault-id']) headers['x-vault-id'] = req.headers['x-vault-id'];
990 const opts = { method: req.method, headers };
991 if (req.method !== 'GET' && req.method !== 'HEAD' && req.body !== undefined) {
992 opts.body = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
993 stripStaleOutboundBodyHeaders(headers);
994 }
995 try {
996 const upstream = await fetch(url, opts);
997 const body = await upstream.text();
998 const hop = filterUpstreamResponseHeadersForDecodedBody(upstream.headers.entries());
999 res.status(upstream.status).set(Object.fromEntries(hop));
1000 res.send(body);
1001 } catch (e) {
1002 console.error('Gateway proxy (bridge) error:', e.message);
1003 res.status(502).json({ error: 'Bad Gateway', code: 'BAD_GATEWAY' });
1004 }
1005 }
1006
1007 /**
1008 * Read multipart/raw POST body for import proxy.
1009 * Netlify (serverless-http) attaches the Lambda body as Buffer on `req.body` and uses a synthetic stream;
1010 * `fetch(req, { duplex })` is unreliable there — always buffer then POST bytes.
1011 * @param {import('express').Request} req
1012 * @returns {Promise<Buffer>}
1013 */
1014 async function bufferImportRequestBody(req) {
1015 if (Buffer.isBuffer(req.body)) return req.body;
1016 if (req.body instanceof Uint8Array) return Buffer.from(req.body);
1017 if (typeof req.body === 'string') return Buffer.from(req.body, 'latin1');
1018 const chunks = [];
1019 for await (const chunk of req) {
1020 chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1021 }
1022 return Buffer.concat(chunks);
1023 }
1024
1025
1026 /**
1027 * Multipart import: forward body bytes to bridge (do not use proxyTo — body is not JSON in req.body).
1028 * @param {string} _baseUrl - bridge origin (reserved for diagnostics; fetch URL is `url`)
1029 * @param {string} url - full URL to bridge /api/v1/import
1030 * @param {import('express').Request} req
1031 * @param {import('express').Response} res
1032 */
1033 async function proxyImportToBridge(_baseUrl, url, req, res) {
1034 let raw;
1035 try {
1036 raw = await bufferImportRequestBody(req);
1037 } catch (e) {
1038 console.error('Gateway import proxy (read body):', e.message || e);
1039 return res.status(500).json({ error: 'Could not read upload body', code: 'INTERNAL_ERROR' });
1040 }
1041 if (!raw.length) {
1042 return res.status(400).json({ error: 'Empty upload body', code: 'BAD_REQUEST' });
1043 }
1044 // Do not set `Host` manually — undici derives it from the request URL; a wrong Host breaks some upstreams.
1045 const headers = {
1046 authorization: req.headers.authorization || '',
1047 'x-vault-id': String(req.headers['x-vault-id'] || 'default'),
1048 };
1049 const ct = req.headers['content-type'];
1050 if (ct) headers['content-type'] = ct;
1051 headers['content-length'] = String(raw.length);
1052 let upstream;
1053 try {
1054 upstream = await fetch(url, {
1055 method: 'POST',
1056 headers,
1057 body: raw,
1058 });
1059 } catch (e) {
1060 console.error('Gateway import proxy error:', e.message, e.cause);
1061 const detail = e.cause?.message || e.message || String(e);
1062 return res.status(502).json({
1063 error: 'Bad Gateway',
1064 code: 'BAD_GATEWAY',
1065 detail,
1066 });
1067 }
1068 const body = await upstream.text();
1069 const upstreamCt = upstream.headers.get('content-type') || '';
1070 if (
1071 upstream.status >= 400 &&
1072 !/application\/json/i.test(upstreamCt) &&
1073 body.trimStart().startsWith('<')
1074 ) {
1075 return res.status(upstream.status).json({
1076 error: 'Import service returned a non-JSON error (check bridge Netlify function logs).',
1077 code: 'BAD_GATEWAY',
1078 detail: `HTTP ${upstream.status}`,
1079 });
1080 }
1081 const hop = filterUpstreamResponseHeadersForDecodedBody(upstream.headers.entries());
1082 res.status(upstream.status).set(Object.fromEntries(hop));
1083 res.send(body);
1084 }
1085
1086 // Proxy /api/* to canister with X-User-Id from JWT
1087 function getUserId(req) {
1088 const auth = req.headers.authorization;
1089 const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
1090 return token ? verifyToken(token) : null;
1091 }
1092
1093 /**
1094 * Validate a hosted SectionSource note path before any upstream fetch.
1095 * @param {unknown} rawPath
1096 * @returns {string}
1097 */
1098 function normalizeGatewaySectionSourcePath(rawPath) {
1099 if (typeof rawPath !== 'string' || rawPath.trim() === '') {
1100 throw new Error('Invalid path');
1101 }
1102 const forward = rawPath.trim().replace(/\\/g, '/');
1103 if (forward.startsWith('/') || /^[A-Za-z]:\//.test(forward)) {
1104 throw new Error('Invalid path');
1105 }
1106 const parts = forward.split('/').filter(Boolean);
1107 if (parts.includes('..')) {
1108 throw new Error('Invalid path');
1109 }
1110 return parts.join('/');
1111 }
1112
1113 /**
1114 * Validate a hosted NoteOutline note path before any upstream fetch.
1115 * @param {unknown} rawPath
1116 * @returns {string}
1117 */
1118 function normalizeGatewayNoteOutlinePath(rawPath) {
1119 return normalizeGatewaySectionSourcePath(rawPath);
1120 }
1121
1122 /**
1123 * Validate a hosted DocumentTree note path before any upstream fetch.
1124 * @param {unknown} rawPath
1125 * @returns {string}
1126 */
1127 function normalizeGatewayDocumentTreePath(rawPath) {
1128 return normalizeGatewaySectionSourcePath(rawPath);
1129 }
1130
1131 /**
1132 * Validate a hosted MetadataFacets note path before any upstream fetch.
1133 * @param {unknown} rawPath
1134 * @returns {string}
1135 */
1136 function normalizeGatewayMetadataFacetsPath(rawPath) {
1137 return normalizeGatewaySectionSourcePath(rawPath);
1138 }
1139
1140 /**
1141 * @param {unknown} error
1142 */
1143 function sanitizedSectionSourceGatewayError(error) {
1144 const msg = error?.message || String(error ?? '');
1145 if (/^Invalid path\b/.test(msg)) return { status: 400, error: 'Invalid path', code: 'INVALID_PATH' };
1146 return { status: 502, error: 'Bad Gateway', code: 'BAD_GATEWAY' };
1147 }
1148
1149 /**
1150 * @param {unknown} error
1151 */
1152 function sanitizedNoteOutlineGatewayError(error) {
1153 return sanitizedSectionSourceGatewayError(error);
1154 }
1155
1156 /**
1157 * @param {unknown} error
1158 */
1159 function sanitizedDocumentTreeGatewayError(error) {
1160 return sanitizedSectionSourceGatewayError(error);
1161 }
1162
1163 /**
1164 * @param {unknown} error
1165 */
1166 function sanitizedMetadataFacetsGatewayError(error) {
1167 return sanitizedSectionSourceGatewayError(error);
1168 }
1169
1170 const hostedCtxCache = new Map();
1171 const HOSTED_CTX_TTL_MS = 60_000;
1172 const HOSTED_CONTEXT_FETCH_TIMEOUT_MS = (() => {
1173 const n = parseInt(String(process.env.HOSTED_CONTEXT_FETCH_TIMEOUT_MS || ''), 10);
1174 if (!Number.isFinite(n)) return 3000;
1175 return Math.min(10_000, Math.max(250, n));
1176 })();
1177
1178 function hostedContextAbortSignal() {
1179 return typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
1180 ? AbortSignal.timeout(HOSTED_CONTEXT_FETCH_TIMEOUT_MS)
1181 : undefined;
1182 }
1183
1184 /**
1185 * Bridge-hosted team context (vault allowlist + scope + effective canister user). Cached briefly per (sub, vaultId).
1186 * @param {import('express').Request} req
1187 * @returns {Promise<Record<string, unknown>|null>}
1188 */
1189 async function getHostedAccessContext(req) {
1190 if (!BRIDGE_URL) return null;
1191 const auth = req.headers.authorization;
1192 if (!auth || !auth.startsWith('Bearer ')) return null;
1193 const sub = getUserId(req);
1194 if (!sub) return null;
1195 const vaultId = String(req.headers['x-vault-id'] || 'default').trim() || 'default';
1196 const cacheKey = `${sub}\0${vaultId}`;
1197 const now = Date.now();
1198 const hit = hostedCtxCache.get(cacheKey);
1199 if (hit && hit.expires > now) return hit.data;
1200 try {
1201 const signal = hostedContextAbortSignal();
1202 const r = await fetch(BRIDGE_URL + '/api/v1/hosted-context', {
1203 method: 'GET',
1204 headers: {
1205 Authorization: auth,
1206 Accept: 'application/json',
1207 'X-Vault-Id': vaultId,
1208 },
1209 ...(signal ? { signal } : {}),
1210 });
1211 if (!r.ok) return null;
1212 const data = await r.json();
1213 if (data && data.error && !data.effective_canister_user_id) return null;
1214 hostedCtxCache.set(cacheKey, { expires: now + HOSTED_CTX_TTL_MS, data });
1215 return data;
1216 } catch (_) {
1217 return null;
1218 }
1219 }
1220
1221 /**
1222 * Hosted team context for an explicit vault (e.g. cross-vault copy source/target checks).
1223 * @param {string} authorization Bearer JWT
1224 * @param {string} vaultId
1225 * @returns {Promise<Record<string, unknown>|null>}
1226 */
1227 async function fetchHostedAccessContextForVault(authorization, vaultId) {
1228 if (!BRIDGE_URL || !authorization || !authorization.startsWith('Bearer ')) return null;
1229 const token = authorization.slice(7);
1230 const sub = verifyToken(token);
1231 if (!sub) return null;
1232 const vid = String(vaultId || 'default').trim() || 'default';
1233 const cacheKey = `${sub}\0${vid}`;
1234 const now = Date.now();
1235 const hit = hostedCtxCache.get(cacheKey);
1236 if (hit && hit.expires > now) return hit.data;
1237 try {
1238 const signal = hostedContextAbortSignal();
1239 const r = await fetch(BRIDGE_URL + '/api/v1/hosted-context', {
1240 method: 'GET',
1241 headers: {
1242 Authorization: authorization,
1243 Accept: 'application/json',
1244 'X-Vault-Id': vid,
1245 },
1246 ...(signal ? { signal } : {}),
1247 });
1248 if (!r.ok) return null;
1249 const data = await r.json();
1250 if (data && data.error && !data.effective_canister_user_id) return null;
1251 hostedCtxCache.set(cacheKey, { expires: now + HOSTED_CTX_TTL_MS, data });
1252 return data;
1253 } catch (_) {
1254 return null;
1255 }
1256 }
1257
1258 const metadataBulkHandlers = createMetadataBulkHandlers({
1259 CANISTER_URL,
1260 CANISTER_AUTH_SECRET,
1261 BRIDGE_URL,
1262 SESSION_SECRET: SESSION_SECRET || '',
1263 getUserId,
1264 getHostedAccessContext,
1265 });
1266
1267 app.get('/api/v1/billing/summary', (req, res) => handleBillingSummary(req, res, getUserId));
1268
1269 /**
1270 * POST /api/v1/admin/billing/repair
1271 *
1272 * Admin-only endpoint to directly write billing tier and Stripe linkage fields for a user.
1273 * Used to recover from missed or unprocessable Stripe webhook deliveries (e.g. webhook pointed
1274 * at old URL, checkout session never had user_id metadata, billing DB was empty on a new deploy).
1275 *
1276 * Auth: Bearer JWT with admin role (sub must be in HUB_ADMIN_USER_IDS env var).
1277 * Body: { uid?, tier, stripe_subscription_id?, stripe_customer_id?, has_active_subscription? }
1278 * - uid: target Knowtation user ID (defaults to the calling admin's own uid)
1279 * - tier: required — one of: free | beta | plus | growth | pro | starter | team
1280 * - stripe_subscription_id: if provided (non-null), also sets has_active_subscription = true
1281 * - has_active_subscription: optional boolean override; when omitted, defaults to true
1282 * whenever a non-null stripe_subscription_id is supplied, and no-op otherwise
1283 * - stripe_customer_id: if provided, links the user to their Stripe customer so future
1284 * webhook events (subscription.updated, etc.) can find them
1285 *
1286 * All mutations are logged. This endpoint does NOT create a Stripe subscription — it only
1287 * repairs the local billing DB record.
1288 */
1289 const VALID_REPAIR_TIERS = new Set(['free', 'beta', 'plus', 'growth', 'pro', 'starter', 'team']);
1290
1291 app.post('/api/v1/admin/billing/repair', async (req, res) => {
1292 const callerUid = getUserId(req);
1293 if (!callerUid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
1294 if (roleForSub(callerUid) !== 'admin') return res.status(403).json({ error: 'Forbidden', code: 'FORBIDDEN' });
1295
1296 const body = req.body && typeof req.body === 'object' ? req.body : {};
1297 const targetUid = typeof body.uid === 'string' && body.uid.trim() ? body.uid.trim() : callerUid;
1298 const tier = typeof body.tier === 'string' ? body.tier.trim() : '';
1299
1300 if (!VALID_REPAIR_TIERS.has(tier)) {
1301 return res.status(400).json({
1302 error: 'Invalid or missing tier',
1303 code: 'BAD_REQUEST',
1304 valid_tiers: [...VALID_REPAIR_TIERS],
1305 });
1306 }
1307
1308 const stripeSubId =
1309 typeof body.stripe_subscription_id === 'string' ? body.stripe_subscription_id.trim() || null : undefined;
1310 const stripeCustomerId =
1311 typeof body.stripe_customer_id === 'string' ? body.stripe_customer_id.trim() || null : undefined;
1312 // Explicit override: caller may pass has_active_subscription=false to deactivate.
1313 // When stripe_subscription_id is provided: truthy value → true, null (cleared) → false.
1314 // When stripe_subscription_id is omitted entirely: no-op (undefined).
1315 const hasActiveSub =
1316 typeof body.has_active_subscription === 'boolean'
1317 ? body.has_active_subscription
1318 : stripeSubId !== undefined
1319 ? (stripeSubId !== null)
1320 : undefined;
1321
1322 let before;
1323 try {
1324 await mutateBillingDb((db) => {
1325 if (!db.users[targetUid]) db.users[targetUid] = defaultUserRecord(targetUid);
1326 const u = db.users[targetUid];
1327 before = {
1328 tier: u.tier,
1329 has_active_subscription: u.has_active_subscription,
1330 stripe_subscription_id: u.stripe_subscription_id,
1331 stripe_customer_id: u.stripe_customer_id,
1332 };
1333 u.tier = tier;
1334 if (MONTHLY_INCLUDED_CENTS_BY_TIER[tier] !== undefined) {
1335 u.monthly_included_cents = MONTHLY_INCLUDED_CENTS_BY_TIER[tier];
1336 }
1337 if (stripeSubId !== undefined) u.stripe_subscription_id = stripeSubId;
1338 if (stripeCustomerId !== undefined) u.stripe_customer_id = stripeCustomerId;
1339 if (hasActiveSub !== undefined) u.has_active_subscription = hasActiveSub;
1340 });
1341 } catch (e) {
1342 console.error('[admin/billing/repair] mutateBillingDb failed:', e?.message);
1343 return res.status(500).json({ error: 'Internal Server Error', code: 'INTERNAL' });
1344 }
1345
1346 console.log(
1347 `[admin/billing/repair] caller=${callerUid} target=${targetUid}` +
1348 ` tier: ${before?.tier} → ${tier}` +
1349 (hasActiveSub !== undefined ? ` has_active_subscription: ${before?.has_active_subscription} → ${hasActiveSub}` : '') +
1350 (stripeSubId !== undefined ? ` sub: ${before?.stripe_subscription_id} → ${stripeSubId}` : '') +
1351 (stripeCustomerId !== undefined ? ` cus: ${before?.stripe_customer_id} → ${stripeCustomerId}` : ''),
1352 );
1353
1354 return res.json({
1355 ok: true,
1356 uid: targetUid,
1357 tier,
1358 has_active_subscription: hasActiveSub !== undefined ? hasActiveSub : '(unchanged)',
1359 stripe_subscription_id: stripeSubId !== undefined ? stripeSubId : '(unchanged)',
1360 stripe_customer_id: stripeCustomerId !== undefined ? stripeCustomerId : '(unchanged)',
1361 before,
1362 });
1363 });
1364
1365 /**
1366 * POST /api/v1/billing/checkout
1367 * Body: { price_id, success_url, cancel_url } OR { tier, success_url, cancel_url }
1368 * Returns: { url } — Stripe Checkout Session URL.
1369 * mode is automatically determined: subscription for tiers, payment for token packs.
1370 */
1371 app.post('/api/v1/billing/checkout', async (req, res) => {
1372 const uid = getUserId(req);
1373 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
1374
1375 const body = req.body && typeof req.body === 'object' ? req.body : {};
1376 let priceId = typeof body.price_id === 'string' ? body.price_id.trim() : null;
1377
1378 if (!priceId && typeof body.tier === 'string') {
1379 priceId = priceIdFromTierShorthand(body.tier.trim());
1380 if (!priceId) {
1381 return res.status(400).json({
1382 error: `Unknown tier '${body.tier}' or Stripe price env var not configured.`,
1383 code: 'BAD_REQUEST',
1384 });
1385 }
1386 }
1387
1388 if (!priceId && typeof body.pack_size === 'string') {
1389 const packSizeMap = {
1390 small: process.env.STRIPE_PRICE_PACK_10 || null,
1391 medium: process.env.STRIPE_PRICE_PACK_25 || null,
1392 large: process.env.STRIPE_PRICE_PACK_50 || null,
1393 };
1394 priceId = packSizeMap[body.pack_size.toLowerCase()] || null;
1395 if (!priceId) {
1396 return res.status(400).json({
1397 error: `Unknown pack_size '${body.pack_size}' or Stripe pack price env var not configured.`,
1398 code: 'BAD_REQUEST',
1399 });
1400 }
1401 }
1402
1403 if (!priceId) {
1404 return res.status(400).json({ error: 'price_id, tier, or pack_size is required', code: 'BAD_REQUEST' });
1405 }
1406
1407 const isSub = isSubscriptionPriceId(priceId);
1408 const isPack = isPackPriceId(priceId);
1409
1410 if (!isSub && !isPack) {
1411 return res.status(400).json({
1412 error: 'price_id is not a recognised Knowtation subscription or token pack price.',
1413 code: 'BAD_REQUEST',
1414 });
1415 }
1416
1417 const mode = isSub ? 'subscription' : 'payment';
1418
1419 const rawSuccessUrl = typeof body.success_url === 'string' ? body.success_url.trim() : '';
1420 const rawCancelUrl = typeof body.cancel_url === 'string' ? body.cancel_url.trim() : '';
1421
1422 const fallbackBase = HUB_UI_ORIGIN || BASE_URL;
1423 const successUrl = rawSuccessUrl || `${fallbackBase}/hub/#settings`;
1424 const cancelUrl = rawCancelUrl || `${fallbackBase}/hub/#settings`;
1425
1426 try {
1427 const { url } = await createCheckoutSession({
1428 priceId,
1429 userId: uid,
1430 successUrl,
1431 cancelUrl,
1432 mode,
1433 stripeCustomerId: null,
1434 });
1435 return res.json({ url });
1436 } catch (e) {
1437 const code = e.code || 'STRIPE_ERROR';
1438 if (code === 'NOT_CONFIGURED') {
1439 return res.status(503).json({ error: e.message, code });
1440 }
1441 console.error('[billing/checkout] Stripe error:', e.message);
1442 return res.status(502).json({ error: e.message || 'Stripe checkout failed', code });
1443 }
1444 });
1445
1446 /**
1447 * POST /api/v1/billing/portal
1448 * Body: { return_url? }
1449 * Returns: { url } — Stripe Billing Portal session URL.
1450 */
1451 app.post('/api/v1/billing/portal', async (req, res) => {
1452 const uid = getUserId(req);
1453 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
1454
1455 const body = req.body && typeof req.body === 'object' ? req.body : {};
1456 const rawReturnUrl = typeof body.return_url === 'string' ? body.return_url.trim() : '';
1457 const fallbackBase = HUB_UI_ORIGIN || BASE_URL;
1458 const returnUrl = rawReturnUrl || `${fallbackBase}/hub/#settings`;
1459
1460 try {
1461 const { url } = await createPortalSession({ userId: uid, returnUrl });
1462 return res.json({ url });
1463 } catch (e) {
1464 const code = e.code || 'STRIPE_ERROR';
1465 if (code === 'NOT_CONFIGURED') {
1466 return res.status(503).json({ error: e.message, code });
1467 }
1468 console.error('[billing/portal] Stripe error:', e.message);
1469 return res.status(502).json({ error: e.message || 'Stripe portal failed', code });
1470 }
1471 });
1472
1473 // GET /api/v1/settings and GET /api/v1/setup — hosted: vault_list from canister; bridge fields when BRIDGE_URL set
1474 app.get('/api/v1/settings', async (req, res) => {
1475 const uid = getUserId(req);
1476 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
1477 let vault_list = [{ id: 'default', label: 'Default' }];
1478 let allowed_vault_ids = ['default'];
1479 let canisterVaultUserId = uid;
1480 /** @type {string|null} */
1481 let workspace_owner_id = null;
1482 let hosted_delegating = false;
1483 /** @type {string[]|null} */
1484 let allowedFromBridge = null;
1485 if (BRIDGE_URL && req.headers.authorization) {
1486 try {
1487 const signal = hostedContextAbortSignal();
1488 const hRes = await fetch(BRIDGE_URL + '/api/v1/hosted-context/settings', {
1489 method: 'GET',
1490 headers: {
1491 Authorization: req.headers.authorization,
1492 Accept: 'application/json',
1493 },
1494 ...(signal ? { signal } : {}),
1495 });
1496 if (hRes.ok) {
1497 const hc = await hRes.json();
1498 if (hc.effective_canister_user_id && typeof hc.effective_canister_user_id === 'string') {
1499 canisterVaultUserId = hc.effective_canister_user_id;
1500 }
1501 if (Array.isArray(hc.allowed_vault_ids) && hc.allowed_vault_ids.length > 0) {
1502 allowedFromBridge = hc.allowed_vault_ids.map((x) => String(x));
1503 }
1504 if (hc.workspace_owner_id != null && String(hc.workspace_owner_id).trim() !== '') {
1505 workspace_owner_id = String(hc.workspace_owner_id).trim();
1506 }
1507 if (hc.delegating === true) hosted_delegating = true;
1508 } else if (hRes.status === 403) {
1509 allowedFromBridge = [];
1510 }
1511 } catch (_) {
1512 /* use uid-only fallback */
1513 }
1514 }
1515 if (CANISTER_URL) {
1516 try {
1517 const signal = hostedContextAbortSignal();
1518 const vRes = await fetch(CANISTER_URL + '/api/v1/vaults', {
1519 method: 'GET',
1520 headers: { 'X-User-Id': canisterVaultUserId, Accept: 'application/json', ...canisterAuthHeaders() },
1521 ...(signal ? { signal } : {}),
1522 });
1523 if (vRes.ok) {
1524 const data = await vRes.json();
1525 const vaults = Array.isArray(data.vaults) ? data.vaults : [];
1526 if (vaults.length > 0) {
1527 const mapped = vaults.map((v) => ({
1528 id: String(v.id || 'default'),
1529 label: String(v.label != null && v.label !== '' ? v.label : v.id || 'default'),
1530 }));
1531 if (allowedFromBridge !== null) {
1532 allowed_vault_ids = allowedFromBridge.filter((id) => mapped.some((m) => m.id === id));
1533 vault_list = allowed_vault_ids.map((id) => {
1534 const m = mapped.find((x) => x.id === id);
1535 return m || { id, label: id };
1536 });
1537 } else {
1538 vault_list = mapped;
1539 allowed_vault_ids = vault_list.map((v) => v.id);
1540 }
1541 } else if (allowedFromBridge && allowedFromBridge.length > 0) {
1542 allowed_vault_ids = [...allowedFromBridge];
1543 vault_list = allowedFromBridge.map((id) => ({ id, label: id }));
1544 }
1545 } else {
1546 console.warn('[gateway] canister vaults non-ok', vRes.status);
1547 }
1548 } catch (e) {
1549 console.warn('[gateway] canister vaults unreachable', e?.message || String(e));
1550 }
1551 }
1552 let github_connected = false;
1553 let github_repo = null;
1554 let role = roleForSub(uid);
1555 let hub_evaluator_may_approve = process.env.HUB_EVALUATOR_MAY_APPROVE === '1';
1556 if (BRIDGE_URL && req.headers.authorization) {
1557 try {
1558 const ghRes = await fetch(BRIDGE_URL + '/api/v1/vault/github-status', {
1559 method: 'GET',
1560 headers: { Authorization: req.headers.authorization, Accept: 'application/json' },
1561 });
1562 if (ghRes.ok) {
1563 const data = await ghRes.json();
1564 github_connected = Boolean(data.github_connected);
1565 github_repo = data.repo || null;
1566 } else {
1567 console.warn('[gateway] bridge github-status non-ok', ghRes.status);
1568 }
1569 const roleRes = await fetch(BRIDGE_URL + '/api/v1/role', {
1570 method: 'GET',
1571 headers: { Authorization: req.headers.authorization, Accept: 'application/json' },
1572 });
1573 if (roleRes.ok) {
1574 const data = await roleRes.json();
1575 if (data.role) role = data.role;
1576 if (typeof data.may_approve_proposals === 'boolean') hub_evaluator_may_approve = data.may_approve_proposals;
1577 }
1578 } catch (e) {
1579 console.warn('[gateway] bridge unreachable', e?.message || String(e));
1580 }
1581 }
1582 const vault_git = {
1583 enabled: github_connected,
1584 has_remote: Boolean(github_repo),
1585 auto_commit: false,
1586 auto_push: false,
1587 };
1588 const dataDir = path.join(projectRoot, 'data');
1589 const llmPrefs = await loadHostedProposalLlmPrefs();
1590 res.json({
1591 role,
1592 user_id: uid,
1593 vault_id: 'default',
1594 vault_list,
1595 allowed_vault_ids,
1596 vault_path_display: 'Canister',
1597 vault_git,
1598 github_connect_available: Boolean(BRIDGE_URL),
1599 github_connected,
1600 repo: github_repo,
1601 workspace_owner_id,
1602 hosted_delegating,
1603 embedding_display: { provider: '—', model: '—', ollama_url: '—' },
1604 proposal_enrich_enabled: effectiveHostedEnrich(llmPrefs),
1605 proposal_evaluation_required: effectiveHostedEvaluationRequired(llmPrefs, dataDir),
1606 proposal_review_hints_enabled: effectiveHostedReviewHints(llmPrefs),
1607 proposal_policy_stored: {
1608 proposal_evaluation_required: llmPrefs.proposal_evaluation_required,
1609 review_hints_enabled: llmPrefs.review_hints_enabled,
1610 enrich_enabled: llmPrefs.enrich_enabled,
1611 },
1612 proposal_policy_env_locked: proposalPolicyEnvLocked(),
1613 hub_evaluator_may_approve,
1614 proposal_rubric: loadProposalRubric(path.join(projectRoot, 'data')),
1615 daemon: await (async () => {
1616 try {
1617 const db = await loadBillingDb();
1618 const raw = db.users?.[uid] || defaultUserRecord(uid);
1619 const u = normalizeBillingUser(raw);
1620 return {
1621 enabled: false,
1622 interval_minutes: u.consolidation_interval_minutes || 120,
1623 idle_only: true,
1624 idle_threshold_minutes: 15,
1625 run_on_start: false,
1626 max_cost_per_day_usd: null,
1627 passes: u.consolidation_passes,
1628 lookback_hours: u.consolidation_lookback_hours,
1629 max_events_per_pass: u.consolidation_max_events_per_pass,
1630 max_topics_per_pass: u.consolidation_max_topics_per_pass,
1631 llm: {
1632 provider: '',
1633 model: '',
1634 base_url: '',
1635 max_tokens: u.consolidation_llm_max_tokens,
1636 },
1637 hosted_enabled: u.consolidation_enabled,
1638 };
1639 } catch (_) {
1640 return {
1641 enabled: false,
1642 interval_minutes: 120,
1643 idle_only: true,
1644 idle_threshold_minutes: 15,
1645 run_on_start: false,
1646 max_cost_per_day_usd: null,
1647 passes: { consolidate: true, verify: true, discover: false },
1648 lookback_hours: 24,
1649 max_events_per_pass: 200,
1650 max_topics_per_pass: 10,
1651 llm: { provider: '', model: '', base_url: '', max_tokens: 1024 },
1652 hosted_enabled: false,
1653 };
1654 }
1655 })(),
1656 muse_bridge: (() => {
1657 const envOverride = process.env.MUSE_URL != null && String(process.env.MUSE_URL).trim() !== '';
1658 const mc = parseMuseConfigFromEnv();
1659 let origin = null;
1660 if (mc) {
1661 try {
1662 origin = new URL(mc.baseUrl).origin;
1663 } catch (_) {
1664 /* ignore */
1665 }
1666 }
1667 return {
1668 enabled: Boolean(mc),
1669 origin,
1670 source: envOverride ? 'env' : 'none',
1671 env_override_active: envOverride,
1672 url_editable: false,
1673 yaml_url_for_edit: '',
1674 };
1675 })(),
1676 });
1677 });
1678
1679 /** Hosted: Muse base URL is operator env only (not writable from Hub Settings). */
1680 app.post('/api/v1/settings/muse', express.json(), (req, res) => {
1681 res.status(501).json({
1682 error: 'Knowtation Cloud configures the optional Muse link on the server; it cannot be set from this screen.',
1683 code: 'NOT_IMPLEMENTED',
1684 });
1685 });
1686
1687 /**
1688 * POST /api/v1/settings/consolidation
1689 * Hosted mode: save consolidation schedule + pass preferences to the billing store.
1690 * Self-hosted daemon settings are not writable here; respond with an appropriate note.
1691 */
1692 app.post('/api/v1/settings/consolidation', express.json(), async (req, res) => {
1693 const uid = getUserId(req);
1694 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
1695 const body = req.body && typeof req.body === 'object' ? req.body : {};
1696 const mode = typeof body.mode === 'string' ? body.mode : (body.enabled ? 'daemon' : 'hosted');
1697 const advCheck = validateHostedSettingsConsolidationAdvanced(body);
1698 if (!advCheck.ok) {
1699 return res.status(400).json({ error: advCheck.error, code: advCheck.code });
1700 }
1701 try {
1702 let saved = {};
1703 await mutateBillingDb((db) => {
1704 if (!db.users) db.users = {};
1705 if (!db.users[uid]) db.users[uid] = defaultUserRecord(uid);
1706 const u = normalizeBillingUser(db.users[uid]);
1707 if (mode === 'off') {
1708 u.consolidation_enabled = false;
1709 } else {
1710 u.consolidation_enabled = true;
1711 const iv = Math.floor(Number(body.interval_minutes) || 120);
1712 if (iv >= 1 && iv <= 43200) u.consolidation_interval_minutes = iv;
1713 }
1714 if (body.passes && typeof body.passes === 'object') {
1715 u.consolidation_passes = {
1716 consolidate: body.passes.consolidate !== false,
1717 verify: body.passes.verify !== false,
1718 discover: Boolean(body.passes.discover),
1719 };
1720 }
1721 if (body.lookback_hours !== undefined) {
1722 u.consolidation_lookback_hours = Math.floor(Number(body.lookback_hours));
1723 }
1724 if (body.max_events_per_pass !== undefined) {
1725 u.consolidation_max_events_per_pass = Math.floor(Number(body.max_events_per_pass));
1726 }
1727 if (body.max_topics_per_pass !== undefined) {
1728 u.consolidation_max_topics_per_pass = Math.floor(Number(body.max_topics_per_pass));
1729 }
1730 if (body.llm !== undefined && typeof body.llm === 'object' && body.llm.max_tokens !== undefined) {
1731 u.consolidation_llm_max_tokens = Math.floor(Number(body.llm.max_tokens));
1732 }
1733 normalizeBillingUser(u);
1734 saved = {
1735 hosted_enabled: u.consolidation_enabled,
1736 interval_minutes: u.consolidation_interval_minutes,
1737 passes: u.consolidation_passes,
1738 lookback_hours: u.consolidation_lookback_hours,
1739 max_events_per_pass: u.consolidation_max_events_per_pass,
1740 max_topics_per_pass: u.consolidation_max_topics_per_pass,
1741 llm: {
1742 provider: '',
1743 model: '',
1744 base_url: '',
1745 max_tokens: u.consolidation_llm_max_tokens,
1746 },
1747 };
1748 });
1749 res.json({ ok: true, hosted: true, daemon: { enabled: false, ...saved } });
1750 } catch (e) {
1751 res.status(500).json({ error: e.message || 'Failed to save', code: 'RUNTIME_ERROR' });
1752 }
1753 });
1754
1755 app.post('/api/v1/settings/proposal-policy', requireAdmin, async (req, res) => {
1756 try {
1757 const body = req.body && typeof req.body === 'object' ? req.body : {};
1758 await mergeHostedProposalLlmPrefs({
1759 proposal_evaluation_required: body.proposal_evaluation_required,
1760 review_hints_enabled: body.review_hints_enabled,
1761 enrich_enabled: body.enrich_enabled,
1762 });
1763 res.json({ ok: true });
1764 } catch (e) {
1765 res.status(500).json({ error: e.message, code: 'RUNTIME_ERROR' });
1766 }
1767 });
1768
1769 app.get('/api/v1/setup', (req, res) => {
1770 const uid = getUserId(req);
1771 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
1772 res.json({
1773 vault_path: '',
1774 vault_git: { enabled: false, remote: '' },
1775 });
1776 });
1777
1778 // --- Admin routes: HUB_ADMIN_USER_IDS, or bridge GET /api/v1/role → role "admin" (Team tab) ---
1779 function requireAdmin(req, res, next) {
1780 const uid = getUserId(req);
1781 if (!uid) {
1782 res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
1783 return;
1784 }
1785 if (roleForSub(uid) === 'admin') {
1786 next();
1787 return;
1788 }
1789 if (!BRIDGE_URL || !req.headers.authorization) {
1790 res.status(403).json({ error: 'Admin only', code: 'FORBIDDEN' });
1791 return;
1792 }
1793 void (async () => {
1794 try {
1795 const roleRes = await fetch(BRIDGE_URL + '/api/v1/role', {
1796 method: 'GET',
1797 headers: { Authorization: req.headers.authorization, Accept: 'application/json' },
1798 });
1799 if (!roleRes.ok) {
1800 if (!res.headersSent) res.status(403).json({ error: 'Admin only', code: 'FORBIDDEN' });
1801 return;
1802 }
1803 const data = await roleRes.json();
1804 if (data && data.role === 'admin') {
1805 next();
1806 return;
1807 }
1808 if (!res.headersSent) res.status(403).json({ error: 'Admin only', code: 'FORBIDDEN' });
1809 } catch (e) {
1810 console.warn('[gateway] requireAdmin bridge /role', e?.message || String(e));
1811 if (!res.headersSent) res.status(403).json({ error: 'Admin only', code: 'FORBIDDEN' });
1812 }
1813 })();
1814 }
1815
1816 if (!BRIDGE_URL) {
1817 app.get('/api/v1/workspace', requireAdmin, (_req, res) => {
1818 res.json({ owner_user_id: null });
1819 });
1820 app.post('/api/v1/workspace', requireAdmin, (_req, res) => {
1821 res.status(503).json({ error: 'Workspace owner requires bridge (BRIDGE_URL).', code: 'NOT_AVAILABLE' });
1822 });
1823 app.get('/api/v1/vault-access', requireAdmin, (_req, res) => {
1824 res.json({ access: {} });
1825 });
1826 app.post('/api/v1/vault-access', requireAdmin, (_req, res) => {
1827 res.status(503).json({ error: 'Vault access requires bridge (BRIDGE_URL).', code: 'NOT_AVAILABLE' });
1828 });
1829 app.get('/api/v1/scope', requireAdmin, (_req, res) => {
1830 res.json({ scope: {} });
1831 });
1832 app.post('/api/v1/scope', requireAdmin, (_req, res) => {
1833 res.status(503).json({ error: 'Scope requires bridge (BRIDGE_URL).', code: 'NOT_AVAILABLE' });
1834 });
1835 app.get('/api/v1/hosted-context', (req, res) => {
1836 const uid = getUserId(req);
1837 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
1838 res.status(503).json({ error: 'Hosted context requires bridge (BRIDGE_URL).', code: 'NOT_AVAILABLE' });
1839 });
1840 }
1841
1842 // Hosted: vault list is derived from the canister; YAML vault editor is self-hosted only
1843 app.post('/api/v1/vaults', requireAdmin, (_req, res) => {
1844 res.status(501).json({
1845 error:
1846 'Editing the vault list in Settings is not available on hosted. Vaults appear when you add notes; use the vault switcher or API with X-Vault-Id.',
1847 code: 'NOT_AVAILABLE',
1848 });
1849 });
1850
1851 // GET /api/v1/roles — hosted stub: no role store; admin sees empty list (parity: only admins can open Team)
1852 app.get('/api/v1/roles', requireAdmin, (_req, res) => {
1853 res.json({ roles: [] });
1854 });
1855
1856 // POST /api/v1/roles — no-op on hosted (no persistent role store yet)
1857 app.post('/api/v1/roles', requireAdmin, (_req, res) => {
1858 res.json({ ok: true });
1859 });
1860
1861 // GET /api/v1/invites — hosted stub: no invite store
1862 app.get('/api/v1/invites', requireAdmin, (_req, res) => {
1863 res.json({ invites: [] });
1864 });
1865
1866 // POST /api/v1/invites — not supported on hosted (no invite store; full parity in Phase 2)
1867 app.post('/api/v1/invites', requireAdmin, (_req, res) => {
1868 res.status(400).json({
1869 error: 'Invites are not supported on hosted yet. Use self-hosted Hub for team invites, or wait for Phase 2.',
1870 code: 'NOT_SUPPORTED',
1871 });
1872 });
1873
1874 // Optional Muse read-only proxy (admin; Option C). 404 when MUSE_URL unset.
1875 app.get(
1876 '/api/v1/operator/muse/proxy',
1877 (req, res, next) => {
1878 if (!parseMuseConfigFromEnv()) {
1879 return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
1880 }
1881 requireAdmin(req, res, next);
1882 },
1883 async (req, res) => {
1884 const cfg = parseMuseConfigFromEnv();
1885 if (!cfg) return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
1886 const rel = typeof req.query.path === 'string' ? req.query.path.trim() : '';
1887 if (!rel) return res.status(400).json({ error: 'path query required', code: 'BAD_REQUEST' });
1888 const result = await fetchMuseProxiedGet({ config: cfg, relativePath: rel });
1889 if (!result.ok && result.code === 'BAD_REQUEST') {
1890 return res.status(400).json({ error: 'Invalid path', code: 'BAD_REQUEST' });
1891 }
1892 if (!result.ok && !result.body) {
1893 return res.status(result.status).json({ error: 'Bad gateway', code: result.code });
1894 }
1895 if (!result.ok && result.body && result.contentType) {
1896 res.status(result.status).set('Content-Type', result.contentType);
1897 res.set('X-Content-Type-Options', 'nosniff');
1898 return res.send(result.body);
1899 }
1900 if (result.ok && result.body) {
1901 res.status(200).set('Content-Type', result.contentType);
1902 res.set('X-Content-Type-Options', 'nosniff');
1903 return res.send(result.body);
1904 }
1905 return res.status(502).json({ error: 'Bad gateway', code: 'BAD_GATEWAY' });
1906 },
1907 );
1908
1909 // DELETE /api/v1/invites/:token — no-op on hosted
1910 app.delete('/api/v1/invites/:token', requireAdmin, (_req, res) => {
1911 res.json({ ok: true });
1912 });
1913
1914 // POST /api/v1/setup — no-op on hosted (vault is canister; nothing to persist)
1915 app.post('/api/v1/setup', (req, res) => {
1916 const uid = getUserId(req);
1917 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
1918 res.json({ ok: true });
1919 });
1920
1921 // POST /api/v1/import — bridge runs importers and writes notes to canister when BRIDGE_URL is set
1922 app.post('/api/v1/import', async (req, res) => {
1923 const uid = getUserId(req);
1924 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
1925 if (BRIDGE_URL) {
1926 if (!(await runBillingGate(req, res, getUserId))) return;
1927 const q = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '';
1928 await proxyImportToBridge(BRIDGE_URL, BRIDGE_URL + '/api/v1/import' + q, req, res);
1929 return;
1930 }
1931 res.status(501).json({
1932 error: 'Import is not yet available on hosted (set BRIDGE_URL for bridge-backed import).',
1933 code: 'NOT_AVAILABLE',
1934 });
1935 });
1936
1937 // POST /api/v1/import-url — JSON body; bridge runs URL importer (same auth as import).
1938 app.post('/api/v1/import-url', async (req, res) => {
1939 const uid = getUserId(req);
1940 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
1941 if (BRIDGE_URL) {
1942 if (!(await runBillingGate(req, res, getUserId))) return;
1943 await proxyTo(BRIDGE_URL, BRIDGE_URL + '/api/v1/import-url', req, res);
1944 return;
1945 }
1946 res.status(501).json({
1947 error: 'Import URL is not available on hosted (set BRIDGE_URL for bridge-backed import).',
1948 code: 'NOT_AVAILABLE',
1949 });
1950 });
1951
1952 // GET /api/v1/notes/facets — aggregate from canister list (Hub filter dropdowns / overview parity)
1953 app.get('/api/v1/notes/facets', async (req, res) => {
1954 const uid = getUserId(req);
1955 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
1956 if (!CANISTER_URL) {
1957 return res.json({ projects: [], tags: [], folders: [] });
1958 }
1959 const vaultId = String(req.headers['x-vault-id'] || 'default').trim() || 'default';
1960 const hctx = await getHostedAccessContext(req);
1961 const effective = (hctx && hctx.effective_canister_user_id) || uid;
1962 if (hctx && Array.isArray(hctx.allowed_vault_ids) && !hctx.allowed_vault_ids.includes(vaultId)) {
1963 return res.status(403).json({ error: 'Access to this vault is not allowed.', code: 'FORBIDDEN' });
1964 }
1965 try {
1966 const url = `${CANISTER_URL}/api/v1/notes`;
1967 const upstream = await fetch(url, {
1968 method: 'GET',
1969 headers: {
1970 Accept: 'application/json',
1971 'x-user-id': effective,
1972 'x-actor-id': uid,
1973 'x-vault-id': vaultId,
1974 },
1975 });
1976 const text = await upstream.text();
1977 if (!upstream.ok) {
1978 console.warn('[gateway] facets canister list non-ok', upstream.status);
1979 return res.json({ projects: [], tags: [], folders: [] });
1980 }
1981 let data;
1982 try {
1983 data = text ? JSON.parse(text) : {};
1984 } catch (e) {
1985 console.warn('[gateway] facets canister list JSON parse', e?.message || String(e));
1986 return res.json({ projects: [], tags: [], folders: [] });
1987 }
1988 const rows = Array.isArray(data.notes) ? data.notes : [];
1989 let notesForFacets = rows;
1990 const scope = hctx && hctx.scope && typeof hctx.scope === 'object' ? hctx.scope : null;
1991 if (scope && (scope.projects?.length || scope.folders?.length)) {
1992 const withProj = rows.map((n) => ({
1993 path: n.path,
1994 project: materializeListFrontmatter(n.frontmatter).project ?? null,
1995 }));
1996 const scoped = applyScopeFilterToNotes(withProj, scope);
1997 const pathSet = new Set(scoped.map((n) => n.path).filter(Boolean));
1998 notesForFacets = rows.filter((n) => pathSet.has(n.path));
1999 }
2000 const facets = deriveFacetsFromCanisterNotes(notesForFacets);
2001 res.json(facets);
2002 } catch (e) {
2003 console.warn('[gateway] facets error', e?.message || String(e));
2004 res.json({ projects: [], tags: [], folders: [] });
2005 }
2006 });
2007
2008 // GET /api/v1/vault/folders — no canister filesystem; UI falls back to inbox + custom path
2009 app.get('/api/v1/vault/folders', async (req, res) => {
2010 const uid = getUserId(req);
2011 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
2012 if (!CANISTER_URL) {
2013 return res.json({ folders: ['inbox'] });
2014 }
2015 const vaultId = String(req.headers['x-vault-id'] || 'default').trim() || 'default';
2016 const hctx = await getHostedAccessContext(req);
2017 if (hctx && Array.isArray(hctx.allowed_vault_ids) && !hctx.allowed_vault_ids.includes(vaultId)) {
2018 return res.status(403).json({ error: 'Access to this vault is not allowed.', code: 'FORBIDDEN' });
2019 }
2020 res.json({ folders: ['inbox'] });
2021 });
2022
2023 app.get('/api/v1/note-outline', async (req, res) => {
2024 const uid = getUserId(req);
2025 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
2026 if (!CANISTER_URL) {
2027 return res.status(503).json({ error: 'Hosted NoteOutline is not configured', code: 'SERVICE_UNAVAILABLE' });
2028 }
2029
2030 let requestedPath;
2031 try {
2032 requestedPath = normalizeGatewayNoteOutlinePath(req.query.path);
2033 } catch (e) {
2034 const err = sanitizedNoteOutlineGatewayError(e);
2035 return res.status(err.status).json({ error: err.error, code: err.code });
2036 }
2037
2038 const vaultId = String(req.headers['x-vault-id'] || 'default').trim() || 'default';
2039 const hctx = await getHostedAccessContext(req);
2040 if (hctx && Array.isArray(hctx.allowed_vault_ids) && !hctx.allowed_vault_ids.includes(vaultId)) {
2041 return res.status(403).json({ error: 'Access to this vault is not allowed.', code: 'FORBIDDEN' });
2042 }
2043 const effective =
2044 hctx && typeof hctx.effective_canister_user_id === 'string' && hctx.effective_canister_user_id
2045 ? hctx.effective_canister_user_id
2046 : uid;
2047 const url = `${CANISTER_URL}/api/v1/notes/${encodeURIComponent(requestedPath)}`;
2048
2049 try {
2050 const upstream = await fetch(url, {
2051 method: 'GET',
2052 headers: {
2053 Accept: 'application/json',
2054 'x-user-id': effective,
2055 'x-actor-id': uid,
2056 'x-vault-id': vaultId,
2057 ...canisterAuthHeaders(),
2058 },
2059 });
2060 const text = await upstream.text();
2061 if (upstream.status === 401 || upstream.status === 403) {
2062 return res.status(403).json({ error: 'Forbidden', code: 'FORBIDDEN' });
2063 }
2064 if (upstream.status === 404) {
2065 return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
2066 }
2067 if (!upstream.ok) {
2068 return res.status(502).json({ error: `Upstream ${upstream.status}`, code: 'BAD_GATEWAY' });
2069 }
2070
2071 let note;
2072 try {
2073 note = text ? JSON.parse(text) : {};
2074 } catch {
2075 return res.status(502).json({ error: 'Invalid note response', code: 'BAD_GATEWAY' });
2076 }
2077
2078 const frontmatter = materializeListFrontmatter(note.frontmatter);
2079 const scope = scopeActiveForGateway(hctx) ? hctx.scope : null;
2080 if (scope) {
2081 const scoped = applyScopeFilterToNotes(
2082 [
2083 {
2084 path: requestedPath,
2085 project: frontmatter.project ?? null,
2086 },
2087 ],
2088 scope
2089 );
2090 if (scoped.length === 0) {
2091 return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
2092 }
2093 }
2094
2095 return res.json(
2096 buildNoteOutline({
2097 path: requestedPath,
2098 frontmatter,
2099 body: note.body != null ? String(note.body) : '',
2100 })
2101 );
2102 } catch (e) {
2103 const err = sanitizedNoteOutlineGatewayError(e);
2104 return res.status(err.status).json({ error: err.error, code: err.code });
2105 }
2106 });
2107
2108 app.get('/api/v1/document-tree', async (req, res) => {
2109 const uid = getUserId(req);
2110 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
2111 if (!CANISTER_URL) {
2112 return res.status(503).json({ error: 'Hosted DocumentTree is not configured', code: 'SERVICE_UNAVAILABLE' });
2113 }
2114
2115 let requestedPath;
2116 try {
2117 requestedPath = normalizeGatewayDocumentTreePath(req.query.path);
2118 } catch (e) {
2119 const err = sanitizedDocumentTreeGatewayError(e);
2120 return res.status(err.status).json({ error: err.error, code: err.code });
2121 }
2122
2123 const vaultId = String(req.headers['x-vault-id'] || 'default').trim() || 'default';
2124 const hctx = await getHostedAccessContext(req);
2125 if (hctx && Array.isArray(hctx.allowed_vault_ids) && !hctx.allowed_vault_ids.includes(vaultId)) {
2126 return res.status(403).json({ error: 'Access to this vault is not allowed.', code: 'FORBIDDEN' });
2127 }
2128 const effective =
2129 hctx && typeof hctx.effective_canister_user_id === 'string' && hctx.effective_canister_user_id
2130 ? hctx.effective_canister_user_id
2131 : uid;
2132 const url = `${CANISTER_URL}/api/v1/notes/${encodeURIComponent(requestedPath)}`;
2133
2134 try {
2135 const upstream = await fetch(url, {
2136 method: 'GET',
2137 headers: {
2138 Accept: 'application/json',
2139 'x-user-id': effective,
2140 'x-actor-id': uid,
2141 'x-vault-id': vaultId,
2142 ...canisterAuthHeaders(),
2143 },
2144 });
2145 const text = await upstream.text();
2146 if (upstream.status === 401 || upstream.status === 403) {
2147 return res.status(403).json({ error: 'Forbidden', code: 'FORBIDDEN' });
2148 }
2149 if (upstream.status === 404) {
2150 return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
2151 }
2152 if (!upstream.ok) {
2153 return res.status(502).json({ error: `Upstream ${upstream.status}`, code: 'BAD_GATEWAY' });
2154 }
2155
2156 let note;
2157 try {
2158 note = text ? JSON.parse(text) : {};
2159 } catch {
2160 return res.status(502).json({ error: 'Invalid note response', code: 'BAD_GATEWAY' });
2161 }
2162
2163 const frontmatter = materializeListFrontmatter(note.frontmatter);
2164 const scope = scopeActiveForGateway(hctx) ? hctx.scope : null;
2165 if (scope) {
2166 const scoped = applyScopeFilterToNotes(
2167 [
2168 {
2169 path: requestedPath,
2170 project: frontmatter.project ?? null,
2171 },
2172 ],
2173 scope
2174 );
2175 if (scoped.length === 0) {
2176 return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
2177 }
2178 }
2179
2180 return res.json(
2181 buildDocumentTree({
2182 path: requestedPath,
2183 frontmatter,
2184 body: note.body != null ? String(note.body) : '',
2185 })
2186 );
2187 } catch (e) {
2188 const err = sanitizedDocumentTreeGatewayError(e);
2189 return res.status(err.status).json({ error: err.error, code: err.code });
2190 }
2191 });
2192
2193 app.get('/api/v1/metadata-facets', async (req, res) => {
2194 const uid = getUserId(req);
2195 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
2196 if (!CANISTER_URL) {
2197 return res.status(503).json({ error: 'Hosted MetadataFacets is not configured', code: 'SERVICE_UNAVAILABLE' });
2198 }
2199
2200 let requestedPath;
2201 try {
2202 requestedPath = normalizeGatewayMetadataFacetsPath(req.query.path);
2203 } catch (e) {
2204 const err = sanitizedMetadataFacetsGatewayError(e);
2205 return res.status(err.status).json({ error: err.error, code: err.code });
2206 }
2207
2208 const vaultId = String(req.headers['x-vault-id'] || 'default').trim() || 'default';
2209 const hctx = await getHostedAccessContext(req);
2210 if (hctx && Array.isArray(hctx.allowed_vault_ids) && !hctx.allowed_vault_ids.includes(vaultId)) {
2211 return res.status(403).json({ error: 'Access to this vault is not allowed.', code: 'FORBIDDEN' });
2212 }
2213 const effective =
2214 hctx && typeof hctx.effective_canister_user_id === 'string' && hctx.effective_canister_user_id
2215 ? hctx.effective_canister_user_id
2216 : uid;
2217 const url = `${CANISTER_URL}/api/v1/notes/${encodeURIComponent(requestedPath)}`;
2218
2219 try {
2220 const upstream = await fetch(url, {
2221 method: 'GET',
2222 headers: {
2223 Accept: 'application/json',
2224 'x-user-id': effective,
2225 'x-actor-id': uid,
2226 'x-vault-id': vaultId,
2227 ...canisterAuthHeaders(),
2228 },
2229 });
2230 const text = await upstream.text();
2231 if (upstream.status === 401 || upstream.status === 403) {
2232 return res.status(403).json({ error: 'Forbidden', code: 'FORBIDDEN' });
2233 }
2234 if (upstream.status === 404) {
2235 return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
2236 }
2237 if (!upstream.ok) {
2238 return res.status(502).json({ error: `Upstream ${upstream.status}`, code: 'BAD_GATEWAY' });
2239 }
2240
2241 let note;
2242 try {
2243 note = text ? JSON.parse(text) : {};
2244 } catch {
2245 return res.status(502).json({ error: 'Invalid note response', code: 'BAD_GATEWAY' });
2246 }
2247
2248 const frontmatter = materializeListFrontmatter(note.frontmatter);
2249 const scope = scopeActiveForGateway(hctx) ? hctx.scope : null;
2250 if (scope) {
2251 const scoped = applyScopeFilterToNotes(
2252 [
2253 {
2254 path: requestedPath,
2255 project: frontmatter.project ?? null,
2256 },
2257 ],
2258 scope
2259 );
2260 if (scoped.length === 0) {
2261 return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
2262 }
2263 }
2264
2265 return res.json(normalizeMetadataFacets(requestedPath, frontmatter));
2266 } catch (e) {
2267 const err = sanitizedMetadataFacetsGatewayError(e);
2268 return res.status(err.status).json({ error: err.error, code: err.code });
2269 }
2270 });
2271
2272 app.get('/api/v1/section-source', async (req, res) => {
2273 const uid = getUserId(req);
2274 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
2275 if (!CANISTER_URL) {
2276 return res.status(503).json({ error: 'Hosted SectionSource is not configured', code: 'SERVICE_UNAVAILABLE' });
2277 }
2278
2279 let requestedPath;
2280 try {
2281 requestedPath = normalizeGatewaySectionSourcePath(req.query.path);
2282 } catch (e) {
2283 const err = sanitizedSectionSourceGatewayError(e);
2284 return res.status(err.status).json({ error: err.error, code: err.code });
2285 }
2286
2287 const vaultId = String(req.headers['x-vault-id'] || 'default').trim() || 'default';
2288 const hctx = await getHostedAccessContext(req);
2289 if (hctx && Array.isArray(hctx.allowed_vault_ids) && !hctx.allowed_vault_ids.includes(vaultId)) {
2290 return res.status(403).json({ error: 'Access to this vault is not allowed.', code: 'FORBIDDEN' });
2291 }
2292 const effective =
2293 hctx && typeof hctx.effective_canister_user_id === 'string' && hctx.effective_canister_user_id
2294 ? hctx.effective_canister_user_id
2295 : uid;
2296 const url = `${CANISTER_URL}/api/v1/notes/${encodeURIComponent(requestedPath)}`;
2297
2298 try {
2299 const upstream = await fetch(url, {
2300 method: 'GET',
2301 headers: {
2302 Accept: 'application/json',
2303 'x-user-id': effective,
2304 'x-actor-id': uid,
2305 'x-vault-id': vaultId,
2306 ...canisterAuthHeaders(),
2307 },
2308 });
2309 const text = await upstream.text();
2310 if (upstream.status === 401 || upstream.status === 403) {
2311 return res.status(403).json({ error: 'Forbidden', code: 'FORBIDDEN' });
2312 }
2313 if (upstream.status === 404) {
2314 return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
2315 }
2316 if (!upstream.ok) {
2317 return res.status(502).json({ error: `Upstream ${upstream.status}`, code: 'BAD_GATEWAY' });
2318 }
2319
2320 let note;
2321 try {
2322 note = text ? JSON.parse(text) : {};
2323 } catch {
2324 return res.status(502).json({ error: 'Invalid note response', code: 'BAD_GATEWAY' });
2325 }
2326
2327 const frontmatter = materializeListFrontmatter(note.frontmatter);
2328 const scope = scopeActiveForGateway(hctx) ? hctx.scope : null;
2329 if (scope) {
2330 const scoped = applyScopeFilterToNotes(
2331 [
2332 {
2333 path: requestedPath,
2334 project: frontmatter.project ?? null,
2335 },
2336 ],
2337 scope
2338 );
2339 if (scoped.length === 0) {
2340 return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
2341 }
2342 }
2343
2344 return res.json(
2345 buildSectionSource({
2346 path: requestedPath,
2347 frontmatter,
2348 body: note.body != null ? String(note.body) : '',
2349 })
2350 );
2351 } catch (e) {
2352 const err = sanitizedSectionSourceGatewayError(e);
2353 return res.status(err.status).json({ error: err.error, code: err.code });
2354 }
2355 });
2356
2357 /**
2358 * @param {Record<string, unknown>|null} hctx
2359 */
2360 function scopeActiveForGateway(hctx) {
2361 const s = hctx && hctx.scope && typeof hctx.scope === 'object' ? hctx.scope : null;
2362 return Boolean(s && (s.projects?.length || s.folders?.length));
2363 }
2364
2365 async function gatewayProxyGetNotesList(req, res, uid, effective, hctx) {
2366 const vaultId = String(req.headers['x-vault-id'] || 'default').trim() || 'default';
2367 const raw = upstreamPathAndQuery(req);
2368 const qIdx = raw.indexOf('?');
2369 const searchPart = qIdx >= 0 ? raw.slice(qIdx + 1) : '';
2370 const params = new URLSearchParams(searchPart);
2371 const limit = Math.min(100, Math.max(0, parseInt(params.get('limit') || '20', 10) || 20));
2372 const offset = Math.max(0, parseInt(params.get('offset') || '0', 10) || 0);
2373 const scope = scopeActiveForGateway(hctx) ? hctx.scope : null;
2374 // Phase 12 — blockchain filters applied client-side (canister stores frontmatter as opaque JSON)
2375 const filterNetwork = (params.get('network') || '').trim().toLowerCase();
2376 const filterWallet = (params.get('wallet_address') || '').trim().toLowerCase();
2377 const filterPaymentStatus = (params.get('payment_status') || '').trim().toLowerCase();
2378 const needsClientFilter = Boolean(scope || filterNetwork || filterWallet || filterPaymentStatus);
2379 if (needsClientFilter) {
2380 params.set('limit', '10000');
2381 params.set('offset', '0');
2382 }
2383 // Remove Phase 12 params before forwarding to canister (canister ignores them, but keep URL clean)
2384 params.delete('network');
2385 params.delete('wallet_address');
2386 params.delete('payment_status');
2387 const fetchUrl = `${CANISTER_URL}/api/v1/notes${params.toString() ? `?${params.toString()}` : ''}`;
2388 try {
2389 const upstream = await fetch(fetchUrl, {
2390 method: 'GET',
2391 headers: {
2392 Accept: 'application/json',
2393 'x-user-id': effective,
2394 'x-actor-id': uid,
2395 'x-vault-id': vaultId,
2396 ...canisterAuthHeaders(),
2397 },
2398 });
2399 const text = await upstream.text();
2400 const hop = filterUpstreamResponseHeadersForDecodedBody(upstream.headers.entries()).filter(
2401 ([k]) => !['cache-control', 'etag', 'last-modified'].includes(k.toLowerCase()),
2402 );
2403 res.status(upstream.status).set(Object.fromEntries(hop));
2404 res.set('Cache-Control', 'private, no-store, must-revalidate');
2405 if (!upstream.ok || !text) {
2406 res.send(text);
2407 return;
2408 }
2409 let data;
2410 try {
2411 data = JSON.parse(text);
2412 } catch (_) {
2413 res.send(text);
2414 return;
2415 }
2416 if (needsClientFilter && Array.isArray(data.notes)) {
2417 let filtered = data.notes;
2418 // Scope filter (project/folder access control)
2419 if (scope) {
2420 const withProj = filtered.map((n) => ({
2421 path: n.path,
2422 project: materializeListFrontmatter(n.frontmatter).project ?? null,
2423 }));
2424 const kept = applyScopeFilterToNotes(withProj, scope);
2425 const keptPaths = new Set(kept.map((r) => r.path).filter(Boolean));
2426 filtered = filtered.filter((n) => n.path && keptPaths.has(n.path));
2427 }
2428 // Phase 12 blockchain filters
2429 if (filterNetwork || filterWallet || filterPaymentStatus) {
2430 filtered = filtered.filter((n) => {
2431 const fm = materializeListFrontmatter(n.frontmatter);
2432 if (filterNetwork && String(fm.network ?? '').trim().toLowerCase() !== filterNetwork) return false;
2433 if (filterWallet && String(fm.wallet_address ?? '').trim().toLowerCase() !== filterWallet) return false;
2434 if (filterPaymentStatus && String(fm.payment_status ?? '').trim().toLowerCase() !== filterPaymentStatus) return false;
2435 return true;
2436 });
2437 }
2438 const total = filtered.length;
2439 const page = filtered.slice(offset, offset + limit);
2440 res.json({ notes: page, total });
2441 return;
2442 }
2443 res.send(text);
2444 } catch (e) {
2445 console.error('Gateway GET notes list error:', e.message);
2446 res.status(502).json({ error: 'Bad Gateway', code: 'BAD_GATEWAY' });
2447 }
2448 }
2449
2450 async function gatewayProxyGetNoteOne(req, res, uid, effective, hctx) {
2451 const vaultId = String(req.headers['x-vault-id'] || 'default').trim() || 'default';
2452 const url = CANISTER_URL + upstreamPathAndQuery(req);
2453 const scope = scopeActiveForGateway(hctx) ? hctx.scope : null;
2454 try {
2455 const upstream = await fetch(url, {
2456 method: 'GET',
2457 headers: {
2458 Accept: 'application/json',
2459 'x-user-id': effective,
2460 'x-actor-id': uid,
2461 'x-vault-id': vaultId,
2462 ...canisterAuthHeaders(),
2463 },
2464 });
2465 const body = await upstream.text();
2466 if (upstream.status >= 400) {
2467 console.warn('[gateway] canister GET note:', upstream.status, 'url:', url.slice(0, 120));
2468 }
2469 const hop = filterUpstreamResponseHeadersForDecodedBody(upstream.headers.entries()).filter(
2470 ([k]) => !['cache-control', 'etag', 'last-modified'].includes(k.toLowerCase()),
2471 );
2472 res.status(upstream.status).set(Object.fromEntries(hop));
2473 res.set('Cache-Control', 'private, no-store, must-revalidate');
2474 if (!scope || upstream.status !== 200 || !body) {
2475 res.send(body);
2476 return;
2477 }
2478 try {
2479 const note = JSON.parse(body);
2480 const withProj = {
2481 path: note.path,
2482 project: materializeListFrontmatter(note.frontmatter).project ?? null,
2483 };
2484 const filtered = applyScopeFilterToNotes([withProj], scope);
2485 if (filtered.length === 0) {
2486 res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
2487 return;
2488 }
2489 } catch (_) {
2490 res.send(body);
2491 return;
2492 }
2493 res.send(body);
2494 } catch (e) {
2495 console.error('Gateway GET note error:', e.message);
2496 res.status(502).json({ error: 'Bad Gateway', code: 'BAD_GATEWAY' });
2497 }
2498 }
2499
2500 const PROPOSAL_APPROVE_OR_DISCARD_RE = /^\/api\/v1\/proposals\/[^/]+\/(approve|discard)\/?$/;
2501
2502 /**
2503 * Bridge / JWT actor role for proposal RBAC (canister only sees effective X-User-Id).
2504 * @param {import('express').Request} req
2505 * @param {Record<string, unknown>|null} hctx
2506 * @returns {Promise<{ role: string, mayApproveProposals: boolean }>}
2507 */
2508 async function resolveHostedActorRole(req, hctx) {
2509 const envFallback = process.env.HUB_EVALUATOR_MAY_APPROVE === '1';
2510 let role = 'member';
2511 let mayApproveProposals = false;
2512 if (hctx && typeof hctx.role === 'string') {
2513 role = hctx.role;
2514 if (typeof hctx.may_approve_proposals === 'boolean') {
2515 mayApproveProposals = hctx.may_approve_proposals;
2516 } else if (role === 'evaluator') {
2517 mayApproveProposals = envFallback;
2518 }
2519 } else if (BRIDGE_URL && req.headers.authorization) {
2520 try {
2521 const roleRes = await fetch(BRIDGE_URL + '/api/v1/role', {
2522 method: 'GET',
2523 headers: { Authorization: req.headers.authorization, Accept: 'application/json' },
2524 });
2525 if (roleRes.ok) {
2526 const data = await roleRes.json();
2527 if (data.role) role = data.role;
2528 if (typeof data.may_approve_proposals === 'boolean') {
2529 mayApproveProposals = data.may_approve_proposals;
2530 } else if (role === 'evaluator') {
2531 mayApproveProposals = envFallback;
2532 }
2533 }
2534 } catch (_) {}
2535 } else {
2536 try {
2537 const auth = req.headers.authorization;
2538 const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
2539 if (token && SESSION_SECRET) {
2540 const payload = jwt.verify(token, SESSION_SECRET);
2541 role = payload.role || roleForSub(payload.sub);
2542 mayApproveProposals = role === 'admin' || (role === 'evaluator' && envFallback);
2543 }
2544 } catch (_) {}
2545 }
2546 return { role, mayApproveProposals };
2547 }
2548
2549 /**
2550 * Approve/discard: enforce actor role on gateway (canister only sees effective X-User-Id).
2551 * @param {import('express').Request} req
2552 * @param {import('express').Response} res
2553 * @param {string} pathNoQuery
2554 * @param {string} method
2555 * @param {Record<string, unknown>|null} hctx from getHostedAccessContext (null if no bridge / not delegated)
2556 */
2557 async function assertHostedProposalApproveDiscard(req, res, pathNoQuery, method, hctx) {
2558 if (method !== 'POST' || !PROPOSAL_APPROVE_OR_DISCARD_RE.test(pathNoQuery)) return true;
2559
2560 const uid = getUserId(req);
2561 if (!uid) {
2562 res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
2563 return false;
2564 }
2565
2566 const { role, mayApproveProposals } = await resolveHostedActorRole(req, hctx);
2567
2568 if (/\/discard\/?$/.test(pathNoQuery)) {
2569 if (role !== 'admin') {
2570 res.status(403).json({ error: 'Discard requires admin.', code: 'FORBIDDEN' });
2571 return false;
2572 }
2573 return true;
2574 }
2575
2576 const canApprove = role === 'admin' || (role === 'evaluator' && mayApproveProposals);
2577 if (!canApprove) {
2578 res.status(403).json({
2579 error:
2580 'Approve requires admin, or an evaluator with approve permission (per-user in Team, or HUB_EVALUATOR_MAY_APPROVE=1 when no per-user value).',
2581 code: 'FORBIDDEN',
2582 });
2583 return false;
2584 }
2585 return true;
2586 }
2587
2588 /**
2589 * Fetch the current note count for a user from the canister.
2590 * Used by the billing storage cap gate before note CREATE.
2591 * Fails open — returns 0 on any error so the gate never blocks due to a canister outage.
2592 *
2593 * @param {string} userId
2594 * @param {import('express').Request} req
2595 * @returns {Promise<number>}
2596 */
2597 async function getNoteCountForUser(userId, req) {
2598 if (!CANISTER_URL) return 0;
2599 try {
2600 let vaultId = String(req.headers['x-vault-id'] || 'default').trim() || 'default';
2601 const pathOnly = effectiveRequestPath(req).replace(/\/+$/, '') || '/';
2602 if (req.method === 'POST' && pathOnly === '/api/v1/notes/copy') {
2603 const b = req.body && typeof req.body === 'object' ? req.body : {};
2604 const toV = typeof b.to_vault_id === 'string' ? b.to_vault_id.replace(/\\/g, '/').trim() : '';
2605 if (toV) vaultId = toV;
2606 }
2607 const hctx = await getHostedAccessContext(req);
2608 const effective =
2609 hctx && typeof hctx.effective_canister_user_id === 'string' && hctx.effective_canister_user_id
2610 ? hctx.effective_canister_user_id
2611 : userId;
2612 const url = `${CANISTER_URL}/api/v1/notes?limit=1&offset=0`;
2613 const upstream = await fetch(url, {
2614 method: 'GET',
2615 headers: {
2616 Accept: 'application/json',
2617 'x-user-id': effective,
2618 'x-actor-id': userId,
2619 'x-vault-id': vaultId,
2620 ...canisterAuthHeaders(),
2621 },
2622 });
2623 if (!upstream.ok) return 0;
2624 const data = await upstream.json();
2625 const total = typeof data.total === 'number' ? data.total : (Array.isArray(data.notes) ? data.notes.length : 0);
2626 return Math.max(0, Math.floor(total));
2627 } catch (_) {
2628 return 0;
2629 }
2630 }
2631
2632 async function proxyToCanister(req, res) {
2633 const uid = getUserId(req);
2634 if (!uid) {
2635 return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
2636 }
2637 const pathOnly = effectiveRequestPath(req);
2638 const pathNoQuery = pathPartNoQuery(req);
2639 const vaultId = String(req.headers['x-vault-id'] || 'default').trim() || 'default';
2640 const hctx = await getHostedAccessContext(req);
2641 if (!(await assertHostedProposalApproveDiscard(req, res, pathNoQuery, req.method, hctx))) return;
2642 const effective =
2643 hctx && typeof hctx.effective_canister_user_id === 'string' && hctx.effective_canister_user_id
2644 ? hctx.effective_canister_user_id
2645 : uid;
2646 if (hctx && Array.isArray(hctx.allowed_vault_ids) && !hctx.allowed_vault_ids.includes(vaultId)) {
2647 return res.status(403).json({ error: 'Access to this vault is not allowed.', code: 'FORBIDDEN' });
2648 }
2649
2650 if (req.method === 'GET' && pathOnly === '/api/v1/notes') {
2651 return gatewayProxyGetNotesList(req, res, uid, effective, hctx);
2652 }
2653 const noteSubPrefix = '/api/v1/notes/';
2654 if (
2655 req.method === 'GET' &&
2656 pathOnly.startsWith(noteSubPrefix) &&
2657 pathOnly !== '/api/v1/notes/facets'
2658 ) {
2659 const rest = pathOnly.slice(noteSubPrefix.length);
2660 if (rest) {
2661 return gatewayProxyGetNoteOne(req, res, uid, effective, hctx);
2662 }
2663 }
2664
2665 const url = CANISTER_URL + upstreamPathAndQuery(req);
2666 const headers = {
2667 host: new URL(CANISTER_URL).host,
2668 'x-user-id': effective,
2669 'x-actor-id': uid,
2670 'x-vault-id': req.headers['x-vault-id'] || 'default',
2671 ...canisterAuthHeaders(),
2672 };
2673 // Allowlist: only forward safe body/content headers; canister auth is via x-user-id + x-gateway-auth.
2674 // Never forward origin, referer, cookies, authorization, or other proxy headers to the canister.
2675 for (const k of PROXY_HEADER_ALLOWLIST) {
2676 if (req.headers[k] !== undefined) headers[k] = req.headers[k];
2677 }
2678 const opts = { method: req.method, headers };
2679 let bodyOut = req.body;
2680 const pathOnlyForBody = pathPartNoQuery(req);
2681 const dataDir = path.join(projectRoot, 'data');
2682 let hostedLlmPrefs = null;
2683 if (
2684 req.method === 'POST' &&
2685 (pathOnlyForBody === '/api/v1/proposals' || pathOnlyForBody === '/api/v1/proposals/')
2686 ) {
2687 hostedLlmPrefs = await loadHostedProposalLlmPrefs();
2688 }
2689 // Improvement B: AIR attestation for hosted gateway note writes.
2690 // Guarded by KNOWTATION_AIR_ENDPOINT being set; always non-blocking (gateway has no air.required config).
2691 let gatewayAirId = null;
2692 if (
2693 process.env.KNOWTATION_AIR_ENDPOINT &&
2694 bodyOut !== undefined &&
2695 typeof bodyOut === 'object' &&
2696 !Buffer.isBuffer(bodyOut) &&
2697 isNoteWriteRequest(req.method, pathOnlyForBody)
2698 ) {
2699 try {
2700 const notePath =
2701 req.method === 'POST'
2702 ? (typeof bodyOut.path === 'string' ? bodyOut.path.replace(/\\/g, '/') : '')
2703 : pathOnlyForBody
2704 .slice('/api/v1/notes/'.length)
2705 .split('/')
2706 .map(decodeURIComponent)
2707 .join('/');
2708 const { attestBeforeWrite: gwAttest } = await import('../../lib/air.mjs');
2709 const airId = await gwAttest(
2710 { air: { enabled: true, required: false, endpoint: process.env.KNOWTATION_AIR_ENDPOINT } },
2711 notePath
2712 );
2713 if (airId && airId !== 'air-placeholder-write') {
2714 gatewayAirId = airId;
2715 }
2716 } catch (e) {
2717 // Never let an AIR failure block a hosted write; log and continue.
2718 console.error('[gateway] AIR attestation error (non-fatal):', e?.message || String(e));
2719 }
2720 }
2721
2722 if (
2723 bodyOut !== undefined &&
2724 typeof bodyOut === 'object' &&
2725 !Buffer.isBuffer(bodyOut) &&
2726 isPostApiV1Notes(req.method, pathOnlyForBody)
2727 ) {
2728 bodyOut = mergeHostedNoteBodyForCanister(bodyOut, uid, gatewayAirId);
2729 } else if (
2730 gatewayAirId &&
2731 bodyOut !== undefined &&
2732 typeof bodyOut === 'object' &&
2733 !Buffer.isBuffer(bodyOut) &&
2734 req.method === 'PUT' &&
2735 pathOnlyForBody.startsWith('/api/v1/notes/')
2736 ) {
2737 // PUT note write: inject air_id into frontmatter alongside existing fields
2738 bodyOut = mergeHostedNoteBodyForCanister(bodyOut, uid, gatewayAirId);
2739 }
2740 if (bodyOut !== undefined && typeof bodyOut === 'object' && !Buffer.isBuffer(bodyOut)) {
2741 bodyOut = augmentProposalEvaluationBodyForCanister(req.method, pathOnlyForBody, bodyOut);
2742 const policyOpts =
2743 hostedLlmPrefs != null
2744 ? { evaluationRequired: effectiveHostedEvaluationRequired(hostedLlmPrefs, dataDir) }
2745 : {};
2746 bodyOut = augmentProposalCreateForHosted(req.method, pathOnlyForBody, bodyOut, dataDir, policyOpts);
2747 if (req.method === 'POST') {
2748 const approveId = proposalIdFromApprovePath(pathOnlyForBody);
2749 if (approveId) {
2750 try {
2751 const museCfg = parseMuseConfigFromEnv();
2752 const resolved = await resolveExternalRefForApprove({
2753 clientRef: bodyOut.external_ref,
2754 proposalId: approveId,
2755 vaultId,
2756 config: museCfg,
2757 logWarn: (msg, extra) => console.warn(msg, extra != null ? JSON.stringify(extra) : ''),
2758 });
2759 if (resolved) {
2760 bodyOut = { ...bodyOut, external_ref: resolved };
2761 }
2762 } catch (e) {
2763 console.warn('[gateway] muse approve merge (non-fatal):', e?.message || String(e));
2764 }
2765 }
2766 }
2767 }
2768 if (req.method !== 'GET' && req.method !== 'HEAD' && bodyOut !== undefined) {
2769 opts.body = typeof bodyOut === 'string' ? bodyOut : JSON.stringify(bodyOut);
2770 stripStaleOutboundBodyHeaders(headers);
2771 }
2772 try {
2773 const upstream = await fetch(url, opts);
2774 const body = await upstream.text();
2775 // For a successful proposal CREATE, extract path+body so the hints job can skip
2776 // its own canister GET (saves one ICP round trip, ~1–3 s, from the hints path).
2777 let parsedProposalData = null;
2778 if (
2779 req.method === 'POST' &&
2780 (pathOnlyForBody === '/api/v1/proposals' || pathOnlyForBody === '/api/v1/proposals/') &&
2781 upstream.status >= 200 && upstream.status < 300
2782 ) {
2783 try {
2784 const j = JSON.parse(body);
2785 const merged = proposalDataForHostedReviewHintsFromCreate(j, bodyOut);
2786 if (merged) parsedProposalData = merged;
2787 } catch (_) {}
2788 }
2789 try {
2790 await maybeScheduleHostedProposalReviewHints({
2791 method: req.method,
2792 pathOnly: pathOnlyForBody,
2793 upstreamStatus: upstream.status,
2794 responseText: body,
2795 canisterUrl: CANISTER_URL,
2796 effectiveUserId: effective,
2797 actorUserId: uid,
2798 vaultId,
2799 hintsEnabled: hostedLlmPrefs ? effectiveHostedReviewHints(hostedLlmPrefs) : false,
2800 proposalData: parsedProposalData,
2801 });
2802 } catch (e) {
2803 // Never let a hints failure affect the primary proxy response.
2804 console.error('[gateway] hints exception (non-fatal):', e?.message || String(e));
2805 }
2806 if (upstream.status >= 400 && req.method === 'GET' && url.includes('/api/v1/notes/')) {
2807 console.warn('[gateway] canister GET note:', upstream.status, 'url:', url.slice(0, 120));
2808 }
2809 if (
2810 upstream.status === 404 &&
2811 req.method === 'POST' &&
2812 /\/api\/v1\/proposals\/[^/]+\/evaluation\/?(\?|$)/.test(pathOnlyForBody)
2813 ) {
2814 console.warn(
2815 '[gateway] canister returned 404 for POST …/evaluation. If the body is {"error":"Not found","code":"NOT_FOUND"}, the hub canister on mainnet likely predates the evaluation route or HTTP upgrade for it — redeploy `hub` from this repo (`hub/icp/README.md` §ICP HTTP gateway behavior).',
2816 );
2817 }
2818 const hop = filterUpstreamResponseHeadersForDecodedBody(upstream.headers.entries()).filter(
2819 ([k]) => !['cache-control', 'etag', 'last-modified'].includes(k.toLowerCase()),
2820 );
2821 res.status(upstream.status).set(Object.fromEntries(hop));
2822 res.set('Cache-Control', 'private, no-store, must-revalidate');
2823 res.send(body);
2824 } catch (e) {
2825 console.error('Gateway proxy error:', e.message);
2826 res.status(502).json({ error: 'Bad Gateway', code: 'BAD_GATEWAY' });
2827 }
2828 }
2829
2830 // Bulk metadata by effective project slug (canister orchestration; not a canister route)
2831 app.post('/api/v1/notes/delete-by-project', async (req, res) => {
2832 if (!(await runBillingGate(req, res, getUserId))) return;
2833 return metadataBulkHandlers.deleteByProject(req, res);
2834 });
2835 app.post('/api/v1/notes/rename-project', async (req, res) => {
2836 if (!(await runBillingGate(req, res, getUserId))) return;
2837 return metadataBulkHandlers.renameProject(req, res);
2838 });
2839
2840 /** Hosted Enrich: gateway runs LLM and POSTs to canister (not proxied as opaque POST). */
2841 app.post('/api/v1/proposals/:proposalId/enrich', async (req, res) => {
2842 // Express 4 does not auto-catch async route handler exceptions; wrap everything so a
2843 // rejected promise never leaves the request hanging until Netlify's Lambda timeout.
2844 try {
2845 if (!(await runBillingGate(req, res, getUserId))) return;
2846 const uid = getUserId(req);
2847 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
2848 const proposalId = req.params.proposalId;
2849 const vaultId = String(req.headers['x-vault-id'] || 'default').trim() || 'default';
2850 const hctx = await getHostedAccessContext(req);
2851 if (hctx && Array.isArray(hctx.allowed_vault_ids) && !hctx.allowed_vault_ids.includes(vaultId)) {
2852 return res.status(403).json({ error: 'Access to this vault is not allowed.', code: 'FORBIDDEN' });
2853 }
2854 const { role } = await resolveHostedActorRole(req, hctx);
2855 if (role === 'viewer') {
2856 return res.status(403).json({ error: 'This action requires a different role.', code: 'FORBIDDEN' });
2857 }
2858 const effective =
2859 hctx && typeof hctx.effective_canister_user_id === 'string' && hctx.effective_canister_user_id
2860 ? hctx.effective_canister_user_id
2861 : uid;
2862 const llmPrefs = await loadHostedProposalLlmPrefs();
2863 const enrichEnabled = effectiveHostedEnrich(llmPrefs);
2864 // Diagnostic: log which LLM provider will be used (visible in Netlify function logs).
2865 console.log(
2866 '[gateway/enrich] proposalId=%s enrichEnabled=%s provider=%s',
2867 proposalId,
2868 enrichEnabled,
2869 process.env.OPENAI_API_KEY ? 'openai' : process.env.ANTHROPIC_API_KEY ? 'anthropic' : 'ollama(NO KEY)',
2870 );
2871 const out = await runHostedProposalEnrichAndPost({
2872 canisterUrl: CANISTER_URL,
2873 effectiveUserId: effective,
2874 actorUserId: uid,
2875 vaultId,
2876 proposalId,
2877 enrichEnabled,
2878 });
2879 if (!out.ok) {
2880 if (out.status === 404 && out.code === 'NOT_FOUND') {
2881 return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
2882 }
2883 if (out.status === 404) {
2884 return res.status(404).json({ error: 'Proposal not found', code: 'NOT_FOUND' });
2885 }
2886 if (out.status === 400) {
2887 return res.status(400).json({ error: out.detail || 'Bad request', code: out.code || 'BAD_REQUEST' });
2888 }
2889 return res.status(out.status || 500).json({
2890 error: out.detail || out.code || 'Enrich failed',
2891 code: out.code || 'RUNTIME_ERROR',
2892 });
2893 }
2894 // Return immediately — the frontend calls openProposal() + loadProposals() after this
2895 // which re-fetches the updated proposal. Eliminating the extra canister GET here removes
2896 // one full ICP round trip (~1–3 s) from the critical path and prevents Netlify timeout.
2897 return res.set('Cache-Control', 'private, no-store, must-revalidate').json({ ok: true });
2898 } catch (e) {
2899 console.error('[gateway/enrich] unhandled exception:', e?.stack || e?.message || e);
2900 if (!res.headersSent) {
2901 res.status(500).json({ error: e?.message || 'Internal error', code: 'INTERNAL_ERROR' });
2902 }
2903 }
2904 });
2905
2906 // ---------------------------------------------------------------------------
2907 // AIR Improvement D — built-in attestation endpoint
2908 // ---------------------------------------------------------------------------
2909
2910 app.post('/api/v1/attest', async (req, res) => {
2911 const uid = getUserId(req);
2912 if (!uid) {
2913 return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
2914 }
2915 if (!isAttestationConfigured()) {
2916 return res.status(503).json({
2917 error: 'Attestation service not configured (ATTESTATION_SECRET missing or too short).',
2918 code: 'NOT_CONFIGURED',
2919 });
2920 }
2921 const body = req.body && typeof req.body === 'object' ? req.body : {};
2922 const action = typeof body.action === 'string' ? body.action.trim() : '';
2923 if (!action) {
2924 return res.status(400).json({ error: 'action is required', code: 'BAD_REQUEST' });
2925 }
2926 const notePath = typeof body.path === 'string' ? body.path : '';
2927 const contentHash = typeof body.content_hash === 'string' ? body.content_hash : null;
2928 try {
2929 const result = await createAttestation(action, notePath, contentHash);
2930 return res.json(result);
2931 } catch (e) {
2932 console.error('[gateway] POST /api/v1/attest error:', e?.message || e);
2933 return res.status(500).json({ error: 'Attestation failed', code: 'INTERNAL_ERROR' });
2934 }
2935 });
2936
2937 app.get('/api/v1/attest/:id', async (req, res) => {
2938 if (!isAttestationConfigured()) {
2939 return res.status(503).json({
2940 error: 'Attestation service not configured (ATTESTATION_SECRET missing or too short).',
2941 code: 'NOT_CONFIGURED',
2942 });
2943 }
2944 const id = req.params.id;
2945 if (!id || !id.startsWith('air-')) {
2946 return res.status(400).json({ error: 'Invalid attestation id format', code: 'BAD_REQUEST' });
2947 }
2948 try {
2949 const result = await verifyAttestation(id);
2950 if (!result.record) {
2951 return res.status(404).json({ error: 'Attestation not found', code: 'NOT_FOUND' });
2952 }
2953 return res.json(result);
2954 } catch (e) {
2955 console.error('[gateway] GET /api/v1/attest/:id error:', e?.message || e);
2956 return res.status(500).json({ error: 'Verification failed', code: 'INTERNAL_ERROR' });
2957 }
2958 });
2959
2960 // ---------------------------------------------------------------------------
2961 // AIR Improvement E — ICP blockchain anchor verification + reconciliation
2962 // ---------------------------------------------------------------------------
2963
2964 app.get('/api/v1/attest/:id/verify', async (req, res) => {
2965 if (!isAttestationConfigured()) {
2966 return res.status(503).json({
2967 error: 'Attestation service not configured (ATTESTATION_SECRET missing or too short).',
2968 code: 'NOT_CONFIGURED',
2969 });
2970 }
2971 const id = req.params.id;
2972 if (!id || !id.startsWith('air-')) {
2973 return res.status(400).json({ error: 'Invalid attestation id format', code: 'BAD_REQUEST' });
2974 }
2975 try {
2976 const result = await verifyWithIcp(id);
2977 if (!result.sources.blobs.found && !result.sources.icp.found) {
2978 return res.status(404).json({ error: 'Attestation not found', code: 'NOT_FOUND', ...result });
2979 }
2980 return res.json(result);
2981 } catch (e) {
2982 console.error('[gateway] GET /api/v1/attest/:id/verify error:', e?.message || e);
2983 return res.status(500).json({ error: 'Verification failed', code: 'INTERNAL_ERROR' });
2984 }
2985 });
2986
2987 app.post('/api/v1/attest/anchor-pending', requireAdmin, async (req, res) => {
2988 if (!isAttestationConfigured()) {
2989 return res.status(503).json({
2990 error: 'Attestation service not configured (ATTESTATION_SECRET missing or too short).',
2991 code: 'NOT_CONFIGURED',
2992 });
2993 }
2994 const body = req.body && typeof req.body === 'object' ? req.body : {};
2995 const ids = Array.isArray(body.ids) ? body.ids.filter((x) => typeof x === 'string' && x.startsWith('air-')) : [];
2996 if (ids.length === 0) {
2997 return res.status(400).json({ error: 'ids array with air-* entries is required', code: 'BAD_REQUEST' });
2998 }
2999 if (ids.length > 100) {
3000 return res.status(400).json({ error: 'Maximum 100 IDs per batch', code: 'BAD_REQUEST' });
3001 }
3002 try {
3003 const result = await anchorPendingAttestations(ids);
3004 return res.json(result);
3005 } catch (e) {
3006 console.error('[gateway] POST /api/v1/attest/anchor-pending error:', e?.message || e);
3007 return res.status(500).json({ error: 'Anchor failed', code: 'INTERNAL_ERROR' });
3008 }
3009 });
3010
3011 /**
3012 * Hosted: single-note export for Hub UI (POST /api/v1/export). Self-hosted Node Hub implements
3013 * this with filesystem; the ICP canister only supports GET /api/v1/export (full vault JSON), so
3014 * POST was returning 404 from the canister. We fetch the note and build the same download payload
3015 * as lib/export.mjs.
3016 */
3017 app.post('/api/v1/export', async (req, res) => {
3018 if (!CANISTER_URL) {
3019 return res.status(503).json({ error: 'Hosted export not configured', code: 'SERVICE_UNAVAILABLE' });
3020 }
3021 if (!(await runBillingGate(req, res, getUserId, { getNoteCount: getNoteCountForUser }))) return;
3022 const uid = getUserId(req);
3023 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
3024 const body = req.body && typeof req.body === 'object' ? req.body : {};
3025 const notePath = typeof body.path === 'string' ? body.path.replace(/\\/g, '/').trim() : '';
3026 const fmt = body.format === 'html' ? 'html' : 'md';
3027 if (!notePath || notePath.includes('..') || notePath.startsWith('/')) {
3028 return res.status(400).json({ error: 'path required', code: 'BAD_REQUEST' });
3029 }
3030 const vaultId = String(req.headers['x-vault-id'] || 'default').trim() || 'default';
3031 const hctx = await getHostedAccessContext(req);
3032 if (hctx && Array.isArray(hctx.allowed_vault_ids) && !hctx.allowed_vault_ids.includes(vaultId)) {
3033 return res.status(403).json({ error: 'Access to this vault is not allowed.', code: 'FORBIDDEN' });
3034 }
3035 const effective =
3036 hctx && typeof hctx.effective_canister_user_id === 'string' && hctx.effective_canister_user_id
3037 ? hctx.effective_canister_user_id
3038 : uid;
3039 const enc = notePath.split('/').map(encodeURIComponent).join('/');
3040 const url = `${CANISTER_URL}/api/v1/notes/${enc}`;
3041 let upstream;
3042 try {
3043 upstream = await fetch(url, {
3044 method: 'GET',
3045 headers: {
3046 Accept: 'application/json',
3047 'x-user-id': effective,
3048 'x-actor-id': uid,
3049 'x-vault-id': vaultId,
3050 ...canisterAuthHeaders(),
3051 },
3052 });
3053 } catch (e) {
3054 console.error('[gateway] export fetch note:', e?.message || e);
3055 return res.status(502).json({ error: 'Bad Gateway', code: 'BAD_GATEWAY' });
3056 }
3057 const text = await upstream.text();
3058 if (upstream.status === 404) {
3059 return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
3060 }
3061 if (!upstream.ok) {
3062 return res.status(upstream.status).type('application/json').send(text);
3063 }
3064 let note;
3065 try {
3066 note = JSON.parse(text);
3067 } catch {
3068 return res.status(502).json({ error: 'Invalid note response', code: 'BAD_GATEWAY' });
3069 }
3070 const scope = scopeActiveForGateway(hctx) ? hctx.scope : null;
3071 if (scope) {
3072 const withProj = {
3073 path: note.path,
3074 project: materializeListFrontmatter(note.frontmatter).project ?? null,
3075 };
3076 const filtered = applyScopeFilterToNotes([withProj], scope);
3077 if (filtered.length === 0) {
3078 return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
3079 }
3080 }
3081 const fm = materializeListFrontmatter(note.frontmatter);
3082 const { content, filename } = exportNoteRecordToContent(
3083 { body: note.body != null ? String(note.body) : '', frontmatter: fm },
3084 note.path || notePath,
3085 { format: fmt },
3086 );
3087 res.set('Cache-Control', 'private, no-store, must-revalidate');
3088 return res.json({ content, filename });
3089 });
3090
3091 /**
3092 * Cross-vault copy/move (hosted gateway): GET note from canister vault A, POST to vault B, optional DELETE on A.
3093 * Conflicts: if `path` already exists in the target vault, the write **overwrites** (same as POST /notes).
3094 * After success, triggers bridge **Re-index** for the target vault and, when moving, the source vault (fire-and-forget).
3095 */
3096 app.post('/api/v1/notes/copy', async (req, res) => {
3097 if (!CANISTER_URL) {
3098 return res.status(503).json({ error: 'Hosted copy not configured', code: 'SERVICE_UNAVAILABLE' });
3099 }
3100 if (!(await runBillingGate(req, res, getUserId, { getNoteCount: getNoteCountForUser }))) return;
3101 const uid = getUserId(req);
3102 if (!uid) return res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
3103 const authHeader = req.headers.authorization || '';
3104 const body = req.body && typeof req.body === 'object' ? req.body : {};
3105 const fromVault = typeof body.from_vault_id === 'string' ? body.from_vault_id.replace(/\\/g, '/').trim() : '';
3106 const toVault = typeof body.to_vault_id === 'string' ? body.to_vault_id.replace(/\\/g, '/').trim() : '';
3107 const notePath = typeof body.path === 'string' ? body.path.replace(/\\/g, '/').trim() : '';
3108 const deleteSource = body.delete_source === true;
3109 if (!fromVault || !toVault || !notePath || notePath.includes('..') || notePath.startsWith('/')) {
3110 return res.status(400).json({
3111 error: 'from_vault_id, to_vault_id, and path are required (vault-relative path)',
3112 code: 'BAD_REQUEST',
3113 });
3114 }
3115 if (fromVault === toVault) {
3116 return res.status(400).json({ error: 'from_vault_id and to_vault_id must differ', code: 'BAD_REQUEST' });
3117 }
3118 /** @type {Record<string, unknown>|null} */
3119 let hctxFrom = null;
3120 if (BRIDGE_URL) {
3121 hctxFrom = await fetchHostedAccessContextForVault(authHeader, fromVault);
3122 if (!hctxFrom) {
3123 return res.status(403).json({ error: 'Hosted workspace context unavailable.', code: 'FORBIDDEN' });
3124 }
3125 if (Array.isArray(hctxFrom.allowed_vault_ids)) {
3126 if (!hctxFrom.allowed_vault_ids.includes(fromVault) || !hctxFrom.allowed_vault_ids.includes(toVault)) {
3127 return res.status(403).json({ error: 'Access to this vault is not allowed.', code: 'FORBIDDEN' });
3128 }
3129 }
3130 }
3131 const { role } = await resolveHostedActorRole(req, hctxFrom);
3132 if (role === 'viewer') {
3133 return res.status(403).json({ error: 'This action requires editor or admin.', code: 'FORBIDDEN' });
3134 }
3135 const effective =
3136 hctxFrom && typeof hctxFrom.effective_canister_user_id === 'string' && hctxFrom.effective_canister_user_id
3137 ? hctxFrom.effective_canister_user_id
3138 : uid;
3139 const enc = notePath.split('/').map(encodeURIComponent).join('/');
3140 const getUrl = `${CANISTER_URL}/api/v1/notes/${enc}`;
3141 let upstream;
3142 try {
3143 upstream = await fetch(getUrl, {
3144 method: 'GET',
3145 headers: {
3146 Accept: 'application/json',
3147 'x-user-id': effective,
3148 'x-actor-id': uid,
3149 'x-vault-id': fromVault,
3150 ...canisterAuthHeaders(),
3151 },
3152 });
3153 } catch (e) {
3154 console.error('[gateway] notes/copy fetch source:', e?.message || e);
3155 return res.status(502).json({ error: 'Bad Gateway', code: 'BAD_GATEWAY' });
3156 }
3157 const getText = await upstream.text();
3158 if (upstream.status === 404) {
3159 return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
3160 }
3161 if (!upstream.ok) {
3162 return res.status(upstream.status).type('application/json').send(getText);
3163 }
3164 let note;
3165 try {
3166 note = JSON.parse(getText);
3167 } catch {
3168 return res.status(502).json({ error: 'Invalid note response', code: 'BAD_GATEWAY' });
3169 }
3170 const scope = scopeActiveForGateway(hctxFrom) ? hctxFrom.scope : null;
3171 if (scope) {
3172 const withProj = {
3173 path: note.path,
3174 project: materializeListFrontmatter(note.frontmatter).project ?? null,
3175 };
3176 const filtered = applyScopeFilterToNotes([withProj], scope);
3177 if (filtered.length === 0) {
3178 return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
3179 }
3180 }
3181 const outPath = note.path || notePath;
3182 let fmRaw = note.frontmatter;
3183 if (typeof fmRaw === 'string') {
3184 try {
3185 fmRaw = fmRaw.trim() ? JSON.parse(fmRaw) : {};
3186 } catch {
3187 fmRaw = {};
3188 }
3189 }
3190 if (!fmRaw || typeof fmRaw !== 'object' || Array.isArray(fmRaw)) {
3191 fmRaw = {};
3192 }
3193 let gatewayAirId = null;
3194 if (process.env.KNOWTATION_AIR_ENDPOINT) {
3195 try {
3196 const { attestBeforeWrite: gwAttest } = await import('../../lib/air.mjs');
3197 const airId = await gwAttest(
3198 { air: { enabled: true, required: false, endpoint: process.env.KNOWTATION_AIR_ENDPOINT } },
3199 outPath,
3200 );
3201 if (airId && airId !== 'air-placeholder-write') {
3202 gatewayAirId = airId;
3203 }
3204 } catch (e) {
3205 console.error('[gateway] AIR attestation (copy, non-fatal):', e?.message || String(e));
3206 }
3207 }
3208 const postBody = mergeHostedNoteBodyForCanister(
3209 {
3210 path: outPath,
3211 body: note.body != null ? String(note.body) : '',
3212 frontmatter: fmRaw,
3213 },
3214 uid,
3215 gatewayAirId,
3216 );
3217 const postHeaders = {
3218 'Content-Type': 'application/json',
3219 Accept: 'application/json',
3220 host: new URL(CANISTER_URL).host,
3221 'x-user-id': effective,
3222 'x-actor-id': uid,
3223 'x-vault-id': toVault,
3224 ...canisterAuthHeaders(),
3225 };
3226 const postOpts = { method: 'POST', headers: postHeaders, body: JSON.stringify(postBody) };
3227 stripStaleOutboundBodyHeaders(postHeaders);
3228 let postUpstream;
3229 try {
3230 postUpstream = await fetch(`${CANISTER_URL}/api/v1/notes`, postOpts);
3231 } catch (e) {
3232 console.error('[gateway] notes/copy post target:', e?.message || e);
3233 return res.status(502).json({ error: 'Bad Gateway', code: 'BAD_GATEWAY' });
3234 }
3235 const postText = await postUpstream.text();
3236 if (!postUpstream.ok) {
3237 return res.status(postUpstream.status).type('application/json').send(postText);
3238 }
3239 if (deleteSource) {
3240 let delUpstream;
3241 try {
3242 delUpstream = await fetch(getUrl, {
3243 method: 'DELETE',
3244 headers: {
3245 Accept: 'application/json',
3246 'x-user-id': effective,
3247 'x-actor-id': uid,
3248 'x-vault-id': fromVault,
3249 ...canisterAuthHeaders(),
3250 },
3251 });
3252 } catch (e) {
3253 console.error('[gateway] notes/copy delete source:', e?.message || e);
3254 return res.status(502).json({
3255 error:
3256 'Note was copied to the target vault but deleting the source failed. Remove the duplicate from the target vault if you retry.',
3257 code: 'DELETE_FAILED',
3258 });
3259 }
3260 const delText = await delUpstream.text();
3261 if (!delUpstream.ok && delUpstream.status !== 404) {
3262 return res.status(delUpstream.status).json({
3263 error: 'Note was copied to the target vault but deleting the source failed.',
3264 code: 'DELETE_FAILED',
3265 detail: typeof delText === 'string' ? delText.slice(0, 500) : '',
3266 });
3267 }
3268 }
3269 const reindexVaults = deleteSource ? [toVault, fromVault] : [toVault];
3270 void (async () => {
3271 if (!BRIDGE_URL) return;
3272 for (const vid of reindexVaults) {
3273 try {
3274 const idxRes = await fetch(BRIDGE_URL + '/api/v1/index', {
3275 method: 'POST',
3276 headers: {
3277 Authorization: authHeader,
3278 Accept: 'application/json',
3279 'Content-Type': 'application/json',
3280 'X-Vault-Id': String(vid || 'default').trim() || 'default',
3281 },
3282 body: '{}',
3283 });
3284 const idxText = await idxRes.text();
3285 await recordIndexingTokensAfterBridgeIndex(uid, idxRes.status, idxText);
3286 } catch (e) {
3287 console.warn('[gateway] notes/copy reindex:', e?.message || e);
3288 }
3289 }
3290 })();
3291 res.set('Cache-Control', 'private, no-store, must-revalidate');
3292 return res.json({
3293 ok: true,
3294 path: outPath,
3295 from_vault_id: fromVault,
3296 to_vault_id: toVault,
3297 moved: deleteSource,
3298 });
3299 });
3300
3301 app.use('/api/v1', async (req, res) => {
3302 if (req.method === 'OPTIONS') return res.status(204).end();
3303 if (!(await runBillingGate(req, res, getUserId, { getNoteCount: getNoteCountForUser }))) return;
3304 return proxyToCanister(req, res);
3305 });
3306
3307 // Health from canister if UI calls /health via same origin
3308 app.get('/api/v1/health-canister', async (_req, res) => {
3309 try {
3310 const r = await fetch(CANISTER_URL + '/health');
3311 const body = await r.text();
3312 res.status(r.status).set('Content-Type', 'application/json').send(body);
3313 } catch (e) {
3314 res.status(502).json({ ok: false, error: e.message });
3315 }
3316 });
3317
3318 app.use((err, req, res, next) => {
3319 if (res.headersSent) return next(err);
3320 console.error('[gateway] unhandled error:', err?.stack || err?.message || err);
3321 const status =
3322 typeof err.status === 'number' && err.status >= 400 && err.status < 600
3323 ? err.status
3324 : typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 600
3325 ? err.statusCode
3326 : 500;
3327 res.status(status).json({
3328 error: err.message || 'Internal error',
3329 code: err.code || 'INTERNAL_ERROR',
3330 });
3331 });
3332
3333 // When running on Netlify, the app is imported by netlify/functions/gateway.mjs and not started here.
3334 if (!process.env.NETLIFY) {
3335 if (!CANISTER_URL) {
3336 console.error('Gateway: CANISTER_URL is required (e.g. https://<canister-id>.ic0.app)');
3337 process.exit(1);
3338 }
3339 if (!SESSION_SECRET) {
3340 console.error('Gateway: SESSION_SECRET or HUB_JWT_SECRET is required');
3341 process.exit(1);
3342 }
3343 if (!CANISTER_AUTH_SECRET && CANISTER_URL) {
3344 console.warn(
3345 '\x1b[33m[SECURITY] CANISTER_AUTH_SECRET is not set. ' +
3346 'The canister will not verify gateway identity. ' +
3347 'Set CANISTER_AUTH_SECRET and call admin_set_gateway_auth_secret on the canister before public launch.\x1b[0m'
3348 );
3349 }
3350 if (CANISTER_URL && !billingEnforced()) {
3351 console.warn(
3352 '\x1b[33m[SECURITY] BILLING_ENFORCE is not set to true. ' +
3353 'Billing limits (storage cap, usage gates) are not enforced. ' +
3354 'Set BILLING_ENFORCE=true before public launch on hosted deployment.\x1b[0m'
3355 );
3356 }
3357 app.listen(PORT, () => {
3358 console.log(`Knowtation Hub Gateway listening on http://localhost:${PORT}`);
3359 console.log(' Canister: ' + CANISTER_URL);
3360 console.log(' UI origin: ' + HUB_UI_ORIGIN);
3361 console.log(' Login: GET /auth/login?provider=google|github');
3362 });
3363 }
3364
3365 export { app };
File History 9 commits
sha256:6f47d53a6adbcf105ba1b9cfc126c788d6a0f461d197f84f78794914305b4bd5 fix(mcp): bound hosted discovery context Human patch 12 hours ago
sha256:2827ba9e7632a4b141c50caf1e8f7d77abbc3515be20e7465f2bccb0ac4edf91 fix: repair endpoint now sets has_active_subscription when … Human minor 5 days ago
sha256:75b9b3ba09df2ea3ca5f64ae1b823c3b157771ef12447a0fc89b806d9e9ea050 Add admin billing repair endpoint with 7-tier tests Human minor 5 days ago
sha256:8d46372e39d2d5a54fd93a8b1c27922fe0d9b22a72197345f1d2c71701cc4ce2 feat(auth): persistent login system + C7 session introspection Human minor 6 days ago
sha256:733a05568d553ecc0c294f0048a2c8a7d0a4e6538d3b5618cc3c93fa55472757 Add structural REST bridges Human minor 13 days ago
sha256:d2f3bd616fe6df6241df70aad61b452c736422931fc6a20518a344f33337e619 Add NoteOutline REST bridge Human minor 13 days ago
sha256:a1fb9436e34e4a809871df484bffcc498b4f6ddf661d2e5079600b0bc7b6727a Add SectionSource Hub REST runtime Human patch 14 days ago
sha256:2967f1973b9f865fe5fd4945e35d3e4b43847fbdcee8c7ab4d41f0fd57b5282a feat(gateway): add Scooling write-back smoke endpoint Human minor 16 days ago
sha256:aeff9d7c077329f4ec5e6617ecf10b1fcb7d08b110a3018b315ef974f8e01cb4 fix(hub): restore note detail actions for viewer and evaluator Human minor 25 days ago