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