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