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