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