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