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