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