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