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