@kb-labs/gateway-app 2.94.0 → 2.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,55 +1,30 @@
1
1
  import { logDiagnosticEvent } from '@kb-labs/core-platform';
2
- import { createServiceBootstrap, platform, getPlatformRoot } from '@kb-labs/core-runtime';
2
+ import { createInMemoryDocumentDatabase, createInMemoryKVStore } from '@kb-labs/core-platform/inmemory';
3
+ import { createServiceBootstrap, platform, getPlatformRoot, getProjectRoot, getAdapterStatus } from '@kb-labs/core-runtime';
4
+ import { makeAssemblyHook } from '@kb-labs/plugin-runtime';
3
5
  import { createCorrelatedLogger, registerOpenAPI, createServiceReadyResponse, OperationMetricsTracker, createServiceObservabilityDescribe, createServiceObservabilityHealth } from '@kb-labs/shared-http';
4
- import { SqliteHostStore, globalDispatcher, AdaptiveBuffer } from '@kb-labs/gateway-core';
5
- import { findNearestConfig, readJsonWithDiagnostics } from '@kb-labs/core-config';
6
+ import { HostStore, globalDispatcher, AdaptiveBuffer } from '@kb-labs/gateway-core';
7
+ import { UsersStore, CredentialsStore, MembershipsStore, SessionsStore, InvitesStore, loadIdentityProviders, createPasswordPolicy, createStubPDP, createTenantResolver, createUserAuthService, ensureBootstrapAdmin, createRateLimiter, OAuthStateStore, AuthService, verifyUserAccessToken, AuthError, getClientByHandle, issueCsrfToken, verifyCsrfToken, getClientByHostId } from '@kb-labs/gateway-auth';
8
+ import { createRegistry, mergeOpenAPISpecs } from '@kb-labs/core-registry';
9
+ import { loadEffectiveConfig } from '@kb-labs/core-config';
6
10
  import { GatewayConfigSchema, HostRegistrationSchema, RegisterRequestSchema, TokenRequestSchema, RefreshRequestSchema, ExecuteRequestSchema, ChatCompletionRequestSchema, TelemetryIngestRequestSchema, PlatformCallRequestSchema, HelloMessageSchema, SUPPORTED_PROTOCOL_VERSIONS, HostCapabilitySchema, AdapterCallMessageSchema, AdapterNameSchema, ClientHelloSchema, ClientCancelSchema, ClientUnsubscribeSchema, ClientSubscribeSchema, CLIENT_PROTOCOL_VERSION } from '@kb-labs/gateway-contracts';
7
11
  import Fastify from 'fastify';
12
+ import fastifyCookie from '@fastify/cookie';
8
13
  import fastifyCors from '@fastify/cors';
9
14
  import fastifyHttpProxy from '@fastify/http-proxy';
10
- import { AuthService, getClientByHostId } from '@kb-labs/gateway-auth';
11
- import { randomUUID } from 'crypto';
12
- import { mergeOpenAPISpecs } from '@kb-labs/core-registry';
13
- import { WebSocketServer } from 'ws';
14
- import { CANONICAL_OBSERVABILITY_METRICS, OBSERVABILITY_CONTRACT_VERSION, OBSERVABILITY_SCHEMA } from '@kb-labs/core-contracts';
15
+ import { PERMISSIONS, CANONICAL_OBSERVABILITY_METRICS, OBSERVABILITY_CONTRACT_VERSION, OBSERVABILITY_SCHEMA } from '@kb-labs/core-contracts';
16
+ import { randomBytes, randomUUID, createHmac, timingSafeEqual } from 'crypto';
17
+ import { WebSocketServer, WebSocket } from 'ws';
15
18
  import { hostname } from 'os';
16
19
  import { monitorEventLoopDelay, performance } from 'perf_hooks';
20
+ import { Readable } from 'stream';
17
21
 
18
22
  // src/bootstrap.ts
19
- var CONFIG_FILENAMES = [
20
- ".kb/kb.config.jsonc",
21
- ".kb/kb.config.json",
22
- "kb.config.jsonc",
23
- "kb.config.json"
24
- ];
25
- async function tryLoadGatewayFromDir(dir) {
26
- for (const filename of CONFIG_FILENAMES) {
27
- const { path: configPath } = await findNearestConfig({
28
- startDir: dir,
29
- stopDir: dir,
30
- filenames: [filename]
31
- });
32
- if (!configPath) {
33
- continue;
34
- }
35
- const result = await readJsonWithDiagnostics(configPath);
36
- if (!result.ok || !result.data.gateway) {
37
- continue;
38
- }
39
- return GatewayConfigSchema.parse(result.data.gateway);
40
- }
41
- return null;
42
- }
43
23
  async function loadGatewayConfig(repoRoot, platformRoot) {
44
- const fromProject = await tryLoadGatewayFromDir(repoRoot);
45
- if (fromProject) {
46
- return fromProject;
47
- }
48
- if (platformRoot && platformRoot !== repoRoot) {
49
- const fromPlatform = await tryLoadGatewayFromDir(platformRoot);
50
- if (fromPlatform) {
51
- return fromPlatform;
52
- }
24
+ const merged = await loadEffectiveConfig(repoRoot, { platformRoot });
25
+ const gateway = merged?.data?.gateway;
26
+ if (gateway && typeof gateway === "object" && !Array.isArray(gateway)) {
27
+ return GatewayConfigSchema.parse(gateway);
53
28
  }
54
29
  return GatewayConfigSchema.parse({});
55
30
  }
@@ -59,16 +34,16 @@ async function resolveToken(token, cache, jwtConfig) {
59
34
  if (jwtContext) {
60
35
  return jwtContext;
61
36
  }
62
- const machineEntry = await cache.get(
37
+ const hostEntry = await cache.get(
63
38
  `host:token:${token}`
64
39
  );
65
- if (machineEntry) {
40
+ if (hostEntry) {
66
41
  return {
67
42
  type: "machine",
68
- userId: machineEntry.hostId,
69
- namespaceId: machineEntry.namespaceId,
43
+ userId: hostEntry.hostId,
44
+ namespaceId: hostEntry.namespaceId,
70
45
  tier: "free",
71
- permissions: ["host:connect"]
46
+ permissions: hostEntry.permissions ?? ["host:connect"]
72
47
  };
73
48
  }
74
49
  return null;
@@ -82,29 +57,53 @@ function extractBearerToken(authHeader) {
82
57
  }
83
58
 
84
59
  // src/auth/middleware.ts
60
+ var LOCAL_ADMIN_CONTEXT = {
61
+ type: "machine",
62
+ userId: "local-admin",
63
+ namespaceId: "local",
64
+ tier: "enterprise",
65
+ permissions: Object.values(PERMISSIONS)
66
+ };
85
67
  var PUBLIC_ROUTES = /* @__PURE__ */ new Set([
86
68
  "/health",
69
+ "/health/adapters",
87
70
  "/ready",
88
71
  "/hosts/register",
89
72
  // /hosts/connect and /clients/connect are handled at the HTTP upgrade level
90
73
  // by gateway-ws.ts (raw ws) — they never reach Fastify routing.
91
- "/auth/register",
74
+ // NOTE: /auth/register is NOT public — it requires MACHINE_REGISTER permission
75
+ // (cookie-auth admin or Bearer machine with that permission).
92
76
  "/auth/token",
93
77
  "/auth/refresh",
78
+ // User-auth public endpoints (ADR-0020, Phase 1.16).
79
+ "/auth/login",
80
+ "/auth/activate",
81
+ "/auth/providers",
94
82
  "/internal/dispatch",
95
83
  // has its own x-internal-secret auth
96
84
  "/internal/resolve-host"
97
85
  // has its own x-internal-secret auth
98
86
  ]);
99
- function createAuthMiddleware(cache, jwtConfig) {
87
+ function createAuthMiddleware(cache, jwtConfig, options = {}) {
88
+ const authEnabled = options.authEnabled !== false;
100
89
  return async function authMiddleware(request, reply) {
90
+ if (!authEnabled) {
91
+ request.authContext = LOCAL_ADMIN_CONTEXT;
92
+ return;
93
+ }
101
94
  const rawPath = new URL(request.url, "http://localhost").pathname;
102
95
  const routePath = rawPath.replace(/\/+/g, "/").replace(/\/+$/, "") || "/";
103
96
  if (PUBLIC_ROUTES.has(routePath)) {
104
97
  return;
105
98
  }
99
+ if (routePath.startsWith("/auth/oauth/")) {
100
+ return;
101
+ }
106
102
  const queryToken = request.query["access_token"];
107
103
  const token = extractBearerToken(request.headers.authorization) ?? queryToken ?? null;
104
+ if (!token && request.userAuthContext) {
105
+ return;
106
+ }
108
107
  if (!token) {
109
108
  return reply.code(401).send({ error: "Unauthorized", message: "Missing Authorization header" });
110
109
  }
@@ -115,15 +114,95 @@ function createAuthMiddleware(cache, jwtConfig) {
115
114
  request.authContext = authContext;
116
115
  };
117
116
  }
118
- function registerAuthRoutes(app, authService) {
117
+
118
+ // src/auth/user-cookies.ts
119
+ var COOKIE_ACCESS = "kb_access";
120
+ var COOKIE_REFRESH = "kb_refresh";
121
+ var COOKIE_CSRF = "kb_csrf";
122
+ var COOKIE_OAUTH_STATE = "kb_oauth_state";
123
+ var REFRESH_COOKIE_PATH = "/api/auth/refresh";
124
+ var OAUTH_COOKIE_PATH = "/api/auth/oauth";
125
+ var baseAttrs = (cookieSecure, ttlSec) => ({
126
+ httpOnly: true,
127
+ secure: cookieSecure,
128
+ sameSite: "strict",
129
+ maxAge: ttlSec
130
+ });
131
+ var setSessionCookies = (reply, input, opts) => {
132
+ reply.setCookie(COOKIE_ACCESS, input.accessToken, {
133
+ ...baseAttrs(opts.cookieSecure, input.accessTtlSec),
134
+ path: "/"
135
+ });
136
+ reply.setCookie(COOKIE_REFRESH, input.refreshToken, {
137
+ ...baseAttrs(opts.cookieSecure, input.refreshTtlSec),
138
+ path: REFRESH_COOKIE_PATH
139
+ });
140
+ reply.setCookie(COOKIE_CSRF, input.csrfToken, {
141
+ // CSRF cookie is NON-HttpOnly so JS can read it.
142
+ httpOnly: false,
143
+ secure: opts.cookieSecure,
144
+ sameSite: "strict",
145
+ maxAge: input.refreshTtlSec,
146
+ path: "/"
147
+ });
148
+ };
149
+ var setOAuthStateCookie = (reply, state, opts, ttlSec) => {
150
+ reply.setCookie(COOKIE_OAUTH_STATE, state, {
151
+ httpOnly: true,
152
+ secure: opts.cookieSecure,
153
+ sameSite: "lax",
154
+ maxAge: ttlSec,
155
+ path: OAUTH_COOKIE_PATH
156
+ });
157
+ };
158
+ var clearOAuthStateCookie = (reply, opts) => {
159
+ reply.clearCookie(COOKIE_OAUTH_STATE, {
160
+ secure: opts.cookieSecure,
161
+ sameSite: "lax",
162
+ path: OAUTH_COOKIE_PATH
163
+ });
164
+ };
165
+ var clearSessionCookies = (reply, opts) => {
166
+ const common = { secure: opts.cookieSecure, sameSite: "strict" };
167
+ reply.clearCookie(COOKIE_ACCESS, { ...common, path: "/" });
168
+ reply.clearCookie(COOKIE_REFRESH, { ...common, path: REFRESH_COOKIE_PATH });
169
+ reply.clearCookie(COOKIE_CSRF, { ...common, path: "/" });
170
+ };
171
+
172
+ // src/auth/routes.ts
173
+ function registerAuthRoutes(app, authService, userExt) {
119
174
  app.post("/auth/register", { schema: { tags: ["Auth"], summary: "Register new agent and get credentials" } }, async (request, reply) => {
175
+ const userCtx = request.userAuthContext;
176
+ const machineCtx = request.authContext;
177
+ if (userCtx && userExt?.pdp) {
178
+ const decision = await userExt.pdp.check(
179
+ { userId: userCtx.userId, tenantId: userCtx.tenantId, type: "user" },
180
+ PERMISSIONS.MACHINE_REGISTER
181
+ );
182
+ if (!decision.allow) {
183
+ return reply.code(403).send({ error: "Forbidden", message: `Permission denied: ${PERMISSIONS.MACHINE_REGISTER}` });
184
+ }
185
+ } else if (machineCtx) {
186
+ if (!machineCtx.permissions.includes(PERMISSIONS.MACHINE_REGISTER)) {
187
+ return reply.code(403).send({ error: "Forbidden", message: `Permission denied: ${PERMISSIONS.MACHINE_REGISTER}` });
188
+ }
189
+ } else {
190
+ return reply.code(401).send({ error: "Unauthorized", message: "Authentication required" });
191
+ }
120
192
  const parsed = RegisterRequestSchema.safeParse(request.body);
121
193
  if (!parsed.success) {
122
194
  return reply.code(400).send({ error: "Bad Request", issues: parsed.error.issues });
123
195
  }
124
- const { name, capabilities, publicKey } = parsed.data;
125
- const result = await authService.register({ name, capabilities, publicKey });
126
- return reply.code(201).send(result);
196
+ const { name, capabilities, publicKey, handle, email } = parsed.data;
197
+ try {
198
+ const result = await authService.register({ name, capabilities, publicKey, handle, email });
199
+ return reply.code(200).send(result);
200
+ } catch (err) {
201
+ if (err instanceof Error && err.code === "HANDLE_TAKEN") {
202
+ return reply.code(409).send({ error: "Conflict", message: err.message });
203
+ }
204
+ throw err;
205
+ }
127
206
  });
128
207
  app.post("/auth/token", { schema: { tags: ["Auth"], summary: "Issue JWT token pair" } }, async (request, reply) => {
129
208
  const parsed = TokenRequestSchema.safeParse(request.body);
@@ -136,7 +215,15 @@ function registerAuthRoutes(app, authService) {
136
215
  }
137
216
  return reply.send(tokens);
138
217
  });
139
- app.post("/auth/refresh", { schema: { tags: ["Auth"], summary: "Refresh JWT token pair" } }, async (request, reply) => {
218
+ app.post("/auth/refresh", { schema: { tags: ["Auth"], summary: "Refresh JWT token pair (machine or user)" } }, async (request, reply) => {
219
+ const cookies = request.cookies;
220
+ const refreshCookie = cookies?.[COOKIE_REFRESH];
221
+ if (refreshCookie && userExt) {
222
+ const handled = await userExt.userRefreshFn(request, reply);
223
+ if (handled) {
224
+ return;
225
+ }
226
+ }
140
227
  const parsed = RefreshRequestSchema.safeParse(request.body);
141
228
  if (!parsed.success) {
142
229
  return reply.code(400).send({ error: "Bad Request", issues: parsed.error.issues });
@@ -148,6 +235,625 @@ function registerAuthRoutes(app, authService) {
148
235
  return reply.send(tokens);
149
236
  });
150
237
  }
238
+ var sendUnauthorized = (reply, message) => reply.code(401).send({ error: "Unauthorized", message });
239
+ var createUserAuthMiddleware = (deps) => {
240
+ return async function userAuthMiddleware(request, reply) {
241
+ const cookies = request.cookies;
242
+ const token = cookies?.[COOKIE_ACCESS];
243
+ if (!token) {
244
+ return;
245
+ }
246
+ const payload = await verifyUserAccessToken(token, deps.jwtConfig);
247
+ if (!payload) {
248
+ return sendUnauthorized(reply, "Invalid session");
249
+ }
250
+ const hostHeader = request.headers["host"];
251
+ const resolvedTenant = deps.tenantResolver.resolve(
252
+ typeof hostHeader === "string" ? hostHeader : void 0
253
+ );
254
+ if (resolvedTenant !== null && resolvedTenant !== payload.tenantId) {
255
+ return sendUnauthorized(reply, "Tenant mismatch");
256
+ }
257
+ const user = await deps.users.getById(payload.userId);
258
+ if (!user || user.status !== "active") {
259
+ return sendUnauthorized(reply, "Session no longer valid");
260
+ }
261
+ request.userAuthContext = {
262
+ type: "user",
263
+ userId: payload.userId,
264
+ tenantId: payload.tenantId,
265
+ familyId: payload.familyId
266
+ };
267
+ };
268
+ };
269
+
270
+ // src/auth/oauth-return-to.ts
271
+ var SAFE_RELATIVE_PATH = /^\/(?![/\\])/;
272
+ function validateReturnTo(raw) {
273
+ if (typeof raw !== "string" || raw.length === 0) {
274
+ return "/";
275
+ }
276
+ if (!SAFE_RELATIVE_PATH.test(raw)) {
277
+ return "/";
278
+ }
279
+ return raw;
280
+ }
281
+
282
+ // src/auth/oauth-routes.ts
283
+ var OAUTH_STATE_TTL_SEC = 10 * 60;
284
+ function getCookies(request) {
285
+ return request.cookies ?? {};
286
+ }
287
+ function callbackUrl(request, id, secure) {
288
+ const host = typeof request.headers.host === "string" ? request.headers.host : "localhost";
289
+ const proto = secure ? "https" : "http";
290
+ return `${proto}://${host}/api/auth/oauth/${id}/callback`;
291
+ }
292
+ function isRedirectProvider(p) {
293
+ return !!p && p.kind === "redirect";
294
+ }
295
+ function accessTtl(result) {
296
+ return result.access.expiresInSec;
297
+ }
298
+ function refreshTtl(result) {
299
+ return result.refresh.expiresInSec;
300
+ }
301
+ function registerOAuthRoutes(app, deps) {
302
+ const { userAuthService, providers, tenantResolver, oauthState, cookieOpts, rateLimiter } = deps;
303
+ const perIpPerMinute = deps.oauthCallbackPerIpPerMinute ?? 60;
304
+ function rateLimitGate(bucket) {
305
+ return async (request, reply) => {
306
+ if (!rateLimiter) {
307
+ return;
308
+ }
309
+ const r = await rateLimiter.check(`rl:oauth:${bucket}:ip:${request.ip}`, {
310
+ max: perIpPerMinute,
311
+ windowMs: 6e4
312
+ });
313
+ if (!r.allowed) {
314
+ reply.header("Retry-After", String(r.retryAfterSec));
315
+ await reply.code(429).send({ error: "Too Many Requests", message: "Rate limit exceeded", retryAfterSec: r.retryAfterSec });
316
+ }
317
+ };
318
+ }
319
+ function failCallback(reply) {
320
+ clearOAuthStateCookie(reply, cookieOpts);
321
+ return reply.redirect("/login?error=oauth", 302);
322
+ }
323
+ app.get(
324
+ "/auth/oauth/:id/start",
325
+ {
326
+ schema: { tags: ["Auth"], summary: "Begin a redirect/OAuth login flow" },
327
+ preHandler: rateLimitGate("start")
328
+ },
329
+ async (request, reply) => {
330
+ const { id } = request.params;
331
+ const provider = providers.get(id);
332
+ if (!isRedirectProvider(provider)) {
333
+ return reply.code(400).send({ error: "Bad Request", message: "Unknown or non-redirect provider" });
334
+ }
335
+ const host = typeof request.headers.host === "string" ? request.headers.host : "";
336
+ const tenantId = tenantResolver.resolve(host) ?? "";
337
+ const returnTo = validateReturnTo(request.query?.returnTo);
338
+ const state = issueCsrfToken();
339
+ const redirectUri = callbackUrl(request, id, cookieOpts.cookieSecure);
340
+ const startResult = await provider.startAuthorization({ state, redirectUri });
341
+ await oauthState.put(state, {
342
+ providerId: id,
343
+ tenantId,
344
+ returnTo,
345
+ session: startResult.session,
346
+ createdAt: Date.now()
347
+ });
348
+ setOAuthStateCookie(reply, state, cookieOpts, OAUTH_STATE_TTL_SEC);
349
+ return reply.redirect(startResult.redirectUrl, 302);
350
+ }
351
+ );
352
+ app.get(
353
+ "/auth/oauth/:id/callback",
354
+ {
355
+ schema: { tags: ["Auth"], summary: "Complete a redirect/OAuth login flow" },
356
+ preHandler: rateLimitGate("cb")
357
+ },
358
+ async (request, reply) => {
359
+ const { id } = request.params;
360
+ const { state, code, error } = request.query ?? {};
361
+ if (error) {
362
+ return failCallback(reply);
363
+ }
364
+ const cookieState = getCookies(request)[COOKIE_OAUTH_STATE];
365
+ if (!state || !cookieState || state !== cookieState) {
366
+ return failCallback(reply);
367
+ }
368
+ const record = await oauthState.consume(state);
369
+ if (!record) {
370
+ return failCallback(reply);
371
+ }
372
+ if (record.providerId !== id) {
373
+ return failCallback(reply);
374
+ }
375
+ const host = typeof request.headers.host === "string" ? request.headers.host : "";
376
+ const callbackTenant = tenantResolver.resolve(host);
377
+ if (callbackTenant !== null && callbackTenant !== record.tenantId) {
378
+ return failCallback(reply);
379
+ }
380
+ const provider = providers.get(record.providerId);
381
+ if (!isRedirectProvider(provider)) {
382
+ return failCallback(reply);
383
+ }
384
+ const redirectUri = callbackUrl(request, record.providerId, cookieOpts.cookieSecure);
385
+ try {
386
+ const result = await userAuthService.login(
387
+ {
388
+ providerId: record.providerId,
389
+ input: { code, state, session: record.session, redirectUri }
390
+ },
391
+ record.tenantId,
392
+ { ip: request.ip, userAgent: request.headers["user-agent"] }
393
+ );
394
+ setSessionCookies(reply, {
395
+ accessToken: result.access.token,
396
+ accessTtlSec: accessTtl(result),
397
+ refreshToken: result.refresh.token,
398
+ refreshTtlSec: refreshTtl(result),
399
+ csrfToken: result.csrf
400
+ }, cookieOpts);
401
+ clearOAuthStateCookie(reply, cookieOpts);
402
+ return reply.redirect(record.returnTo, 302);
403
+ } catch (err) {
404
+ if (err instanceof AuthError) {
405
+ return failCallback(reply);
406
+ }
407
+ throw err;
408
+ }
409
+ }
410
+ );
411
+ }
412
+ function getCookies2(request) {
413
+ return request.cookies ?? {};
414
+ }
415
+ function requireUser(request, reply) {
416
+ const ctx = request.userAuthContext;
417
+ if (!ctx) {
418
+ void reply.code(401).send({ error: "Unauthorized", message: "No active user session" });
419
+ return null;
420
+ }
421
+ return ctx;
422
+ }
423
+ function checkCsrf(request, reply) {
424
+ const cookieVal = getCookies2(request)[COOKIE_CSRF];
425
+ const headerVal = request.headers["x-csrf-token"];
426
+ const headerStr = typeof headerVal === "string" ? headerVal : void 0;
427
+ if (!verifyCsrfToken(cookieVal, headerStr)) {
428
+ void reply.code(403).send({ error: "Forbidden", message: "CSRF token mismatch" });
429
+ return false;
430
+ }
431
+ return true;
432
+ }
433
+ function accessTtl2(result) {
434
+ return result.access.expiresInSec;
435
+ }
436
+ function refreshTtl2(result) {
437
+ return result.refresh.expiresInSec;
438
+ }
439
+ function createUserRefreshFn(deps) {
440
+ const { userAuthService, cookieOpts } = deps;
441
+ return async function userRefreshFn(request, reply) {
442
+ const refreshCookie = getCookies2(request)[COOKIE_REFRESH];
443
+ if (!refreshCookie) {
444
+ return false;
445
+ }
446
+ try {
447
+ const result = await userAuthService.refresh(refreshCookie);
448
+ setSessionCookies(reply, {
449
+ accessToken: result.access.token,
450
+ accessTtlSec: accessTtl2(result),
451
+ refreshToken: result.refresh.token,
452
+ refreshTtlSec: refreshTtl2(result),
453
+ csrfToken: result.csrf
454
+ }, cookieOpts);
455
+ void reply.send({ ok: true });
456
+ return true;
457
+ } catch (err) {
458
+ if (err instanceof AuthError) {
459
+ clearSessionCookies(reply, cookieOpts);
460
+ void reply.code(401).send({ error: "Unauthorized", message: "Session expired or revoked" });
461
+ return true;
462
+ }
463
+ throw err;
464
+ }
465
+ };
466
+ }
467
+ function registerUserAuthRoutes(app, deps) {
468
+ const { userAuthService, users, sessions, invites, providers, pdp, tenantResolver, cookieOpts, inviteTtlMs, rateLimiter, authRateLimit, oauthState, oauthCallbackPerIpPerMinute } = deps;
469
+ const authRateLimitCfg = { loginPerIpPerMinute: 10, loginPerEmailPerMinute: 5, ...authRateLimit };
470
+ if (oauthState) {
471
+ registerOAuthRoutes(app, {
472
+ userAuthService,
473
+ providers,
474
+ tenantResolver,
475
+ oauthState,
476
+ cookieOpts,
477
+ rateLimiter,
478
+ oauthCallbackPerIpPerMinute
479
+ });
480
+ }
481
+ app.get("/auth/me", {
482
+ schema: { tags: ["Auth"], summary: "Get profile for the authenticated caller" }
483
+ }, async (request, reply) => {
484
+ if (request.userAuthContext) {
485
+ const { userId, tenantId } = request.userAuthContext;
486
+ const user = await users.getById(userId);
487
+ if (!user) {
488
+ return reply.code(404).send({ error: "Not found", message: "User record not found" });
489
+ }
490
+ return reply.send({ userId: user.userId, email: user.email, tenantId });
491
+ }
492
+ const auth = request.authContext;
493
+ if (!auth) {
494
+ return reply.code(401).send({ error: "Unauthorized", message: "Not authenticated" });
495
+ }
496
+ return reply.send({
497
+ userId: auth.userId,
498
+ namespaceId: auth.namespaceId,
499
+ email: auth.userId,
500
+ tenantId: auth.namespaceId
501
+ });
502
+ });
503
+ app.get("/auth/providers", {
504
+ schema: { tags: ["Auth"], summary: "List registered identity providers" }
505
+ }, async (_request, reply) => {
506
+ return reply.send({ providers: providers.list() });
507
+ });
508
+ app.post("/auth/login", {
509
+ schema: { tags: ["Auth"], summary: "Login with email/password (sets session cookies)" }
510
+ }, async (request, reply) => {
511
+ const { email, password, providerId, tenantId: bodyTenantId } = request.body ?? {};
512
+ if (typeof email !== "string" || !email) {
513
+ return reply.code(400).send({ error: "Bad Request", message: "email is required" });
514
+ }
515
+ if (typeof password !== "string" || !password) {
516
+ return reply.code(400).send({ error: "Bad Request", message: "password is required" });
517
+ }
518
+ const host = typeof request.headers.host === "string" ? request.headers.host : "";
519
+ const hostTenant = tenantResolver.resolve(host);
520
+ const tenantId = hostTenant ?? bodyTenantId ?? "";
521
+ if (rateLimiter) {
522
+ const emailNorm = email.toLowerCase().trim();
523
+ const [ipPeek, emailPeek] = await Promise.all([
524
+ rateLimiter.peek(`rl:login:ip:${request.ip}`, { max: authRateLimitCfg.loginPerIpPerMinute, windowMs: 6e4 }),
525
+ rateLimiter.peek(`rl:login:email:${emailNorm}`, { max: authRateLimitCfg.loginPerEmailPerMinute, windowMs: 6e4 })
526
+ ]);
527
+ if (!ipPeek.allowed || !emailPeek.allowed) {
528
+ const retryAfter = !ipPeek.allowed ? ipPeek.retryAfterSec : !emailPeek.allowed ? emailPeek.retryAfterSec : 60;
529
+ reply.header("Retry-After", String(retryAfter));
530
+ return reply.code(429).send({ error: "Too Many Requests", message: "Rate limit exceeded", retryAfterSec: retryAfter });
531
+ }
532
+ }
533
+ try {
534
+ const result = await userAuthService.login(
535
+ { providerId: providerId ?? "email-password", input: { email, password } },
536
+ tenantId,
537
+ { ip: request.ip, userAgent: request.headers["user-agent"] }
538
+ );
539
+ setSessionCookies(reply, {
540
+ accessToken: result.access.token,
541
+ accessTtlSec: accessTtl2(result),
542
+ refreshToken: result.refresh.token,
543
+ refreshTtlSec: refreshTtl2(result),
544
+ csrfToken: result.csrf
545
+ }, cookieOpts);
546
+ return reply.send({ ok: true });
547
+ } catch (err) {
548
+ if (err instanceof AuthError) {
549
+ if (rateLimiter) {
550
+ const perIpMax = authRateLimitCfg.loginPerIpPerMinute;
551
+ const perEmailMax = authRateLimitCfg.loginPerEmailPerMinute;
552
+ const emailNorm = email.toLowerCase().trim();
553
+ const [ipResult, emailResult] = await Promise.all([
554
+ rateLimiter.check(`rl:login:ip:${request.ip}`, { max: perIpMax, windowMs: 6e4 }),
555
+ rateLimiter.check(`rl:login:email:${emailNorm}`, { max: perEmailMax, windowMs: 6e4 })
556
+ ]);
557
+ if (!ipResult.allowed || !emailResult.allowed) {
558
+ const retryAfter = !ipResult.allowed ? ipResult.retryAfterSec : !emailResult.allowed ? emailResult.retryAfterSec : 60;
559
+ reply.header("Retry-After", String(retryAfter));
560
+ return reply.code(429).send({ error: "Too Many Requests", message: "Rate limit exceeded", retryAfterSec: retryAfter });
561
+ }
562
+ }
563
+ return reply.code(401).send({ error: "invalid_credentials" });
564
+ }
565
+ throw err;
566
+ }
567
+ });
568
+ app.post("/auth/logout", {
569
+ schema: { tags: ["Auth"], summary: "Logout and clear session cookies" }
570
+ }, async (request, reply) => {
571
+ const ctx = requireUser(request, reply);
572
+ if (!ctx) {
573
+ return;
574
+ }
575
+ if (!checkCsrf(request, reply)) {
576
+ return;
577
+ }
578
+ const refreshCookie = getCookies2(request)[COOKIE_REFRESH];
579
+ if (refreshCookie) {
580
+ await userAuthService.logout(refreshCookie).catch(() => {
581
+ });
582
+ }
583
+ clearSessionCookies(reply, cookieOpts);
584
+ return reply.send({ ok: true });
585
+ });
586
+ app.get("/auth/permissions", {
587
+ schema: { tags: ["Auth"], summary: "List permissions for the authenticated user" }
588
+ }, async (request, reply) => {
589
+ if (request.userAuthContext) {
590
+ const ctx = request.userAuthContext;
591
+ const permissions = await pdp.enumeratePermissions({
592
+ userId: ctx.userId,
593
+ tenantId: ctx.tenantId,
594
+ type: "user"
595
+ });
596
+ return reply.send({ permissions });
597
+ }
598
+ const auth = request.authContext;
599
+ if (!auth) {
600
+ return reply.code(401).send({ error: "Unauthorized", message: "No active session" });
601
+ }
602
+ return reply.send({ permissions: auth.permissions });
603
+ });
604
+ app.post("/auth/activate", {
605
+ schema: { tags: ["Auth"], summary: "Activate invite and create account (auto-login)" }
606
+ }, async (request, reply) => {
607
+ const { token, password } = request.body ?? {};
608
+ if (typeof token !== "string" || !token) {
609
+ return reply.code(400).send({ error: "Bad Request", message: "token is required" });
610
+ }
611
+ if (typeof password !== "string" || !password) {
612
+ return reply.code(400).send({ error: "Bad Request", message: "password is required" });
613
+ }
614
+ try {
615
+ const result = await userAuthService.activate({
616
+ activationToken: token,
617
+ password,
618
+ deviceCtx: {
619
+ ip: request.ip,
620
+ userAgent: request.headers["user-agent"]
621
+ }
622
+ });
623
+ setSessionCookies(reply, {
624
+ accessToken: result.access.token,
625
+ accessTtlSec: accessTtl2(result),
626
+ refreshToken: result.refresh.token,
627
+ refreshTtlSec: refreshTtl2(result),
628
+ csrfToken: result.csrf
629
+ }, cookieOpts);
630
+ return reply.send({ ok: true });
631
+ } catch (err) {
632
+ if (err instanceof AuthError) {
633
+ const code = err.code;
634
+ if (code === "unknown_invite") {
635
+ return reply.code(401).send({ error: "invalid_invite", message: "Invalid or expired invite" });
636
+ }
637
+ if (code === "invalid_invite") {
638
+ return reply.code(422).send({ error: "invalid_invite", message: "Invalid or expired invite" });
639
+ }
640
+ return reply.code(400).send({ error: "Bad Request", message: err.message });
641
+ }
642
+ throw err;
643
+ }
644
+ });
645
+ app.post("/auth/password/change", {
646
+ schema: { tags: ["Auth"], summary: "Change password (revokes all other sessions)" }
647
+ }, async (request, reply) => {
648
+ const ctx = requireUser(request, reply);
649
+ if (!ctx) {
650
+ return;
651
+ }
652
+ if (!checkCsrf(request, reply)) {
653
+ return;
654
+ }
655
+ const { currentPassword, newPassword } = request.body ?? {};
656
+ if (!currentPassword || !newPassword) {
657
+ return reply.code(400).send({
658
+ error: "Bad Request",
659
+ message: "currentPassword and newPassword are required"
660
+ });
661
+ }
662
+ try {
663
+ await userAuthService.changePassword({
664
+ userId: ctx.userId,
665
+ currentFamilyId: ctx.familyId,
666
+ currentPassword,
667
+ newPassword
668
+ });
669
+ return reply.send({ ok: true });
670
+ } catch (err) {
671
+ if (err instanceof AuthError) {
672
+ const code = err.code;
673
+ if (code === "invalid_current_password") {
674
+ return reply.code(400).send({ error: "Bad Request", message: "Current password is incorrect" });
675
+ }
676
+ if (code === "weak_password") {
677
+ return reply.code(400).send({ error: "Bad Request", message: err.message });
678
+ }
679
+ }
680
+ throw err;
681
+ }
682
+ });
683
+ app.get("/auth/sessions", {
684
+ schema: { tags: ["Auth"], summary: "List active sessions for the authenticated user" }
685
+ }, async (request, reply) => {
686
+ const ctx = requireUser(request, reply);
687
+ if (!ctx) {
688
+ return;
689
+ }
690
+ const families = await sessions.listFamiliesByUser(ctx.userId);
691
+ return reply.send({
692
+ sessions: families.map((f) => ({
693
+ familyId: f.familyId,
694
+ createdAt: f.createdAt,
695
+ lastUsedAt: f.lastUsedAt,
696
+ userAgent: f.userAgent,
697
+ ipFirst: f.ipFirst,
698
+ isCurrent: f.familyId === ctx.familyId
699
+ }))
700
+ });
701
+ });
702
+ app.post("/auth/sessions/revoke-all", {
703
+ schema: { tags: ["Auth"], summary: "Revoke all sessions except the current one" }
704
+ }, async (request, reply) => {
705
+ const ctx = requireUser(request, reply);
706
+ if (!ctx) {
707
+ return;
708
+ }
709
+ if (!checkCsrf(request, reply)) {
710
+ return;
711
+ }
712
+ await sessions.revokeAllUserSessionsExcept(ctx.userId, ctx.familyId);
713
+ return reply.send({ ok: true });
714
+ });
715
+ app.post("/auth/sessions/:fam/revoke", {
716
+ schema: { tags: ["Auth"], summary: "Revoke a specific session family" }
717
+ }, async (request, reply) => {
718
+ const ctx = requireUser(request, reply);
719
+ if (!ctx) {
720
+ return;
721
+ }
722
+ if (!checkCsrf(request, reply)) {
723
+ return;
724
+ }
725
+ const { fam } = request.params;
726
+ const ownFamilies = await sessions.listFamiliesByUser(ctx.userId);
727
+ if (!ownFamilies.some((f) => f.familyId === fam)) {
728
+ return reply.code(404).send({ error: "Not found", message: "Session not found" });
729
+ }
730
+ await sessions.revokeFamily(fam);
731
+ return reply.send({ ok: true });
732
+ });
733
+ async function requirePermission(request, reply, permission) {
734
+ const ctx = requireUser(request, reply);
735
+ if (!ctx) {
736
+ return false;
737
+ }
738
+ const decision = await pdp.check(
739
+ { userId: ctx.userId, tenantId: ctx.tenantId, type: "user" },
740
+ permission
741
+ );
742
+ if (!decision.allow) {
743
+ void reply.code(403).send({ error: "Forbidden", message: `Permission denied: ${permission}` });
744
+ return false;
745
+ }
746
+ return true;
747
+ }
748
+ app.post("/auth/invites", {
749
+ schema: { tags: ["Auth"], summary: "Create an invite for a new user (admin only)" }
750
+ }, async (request, reply) => {
751
+ if (!await requirePermission(request, reply, PERMISSIONS.INVITES_WRITE)) {
752
+ return;
753
+ }
754
+ if (!checkCsrf(request, reply)) {
755
+ return;
756
+ }
757
+ const ctx = request.userAuthContext;
758
+ const { email, groupId, ttlMs: bodyTtlMs } = request.body ?? {};
759
+ if (!email || !groupId) {
760
+ return reply.code(400).send({ error: "Bad Request", message: "email and groupId are required" });
761
+ }
762
+ const effectiveTtlMs = typeof bodyTtlMs === "number" && bodyTtlMs > 0 ? bodyTtlMs : inviteTtlMs;
763
+ const result = await invites.createInvite({
764
+ email,
765
+ tenantId: ctx.tenantId,
766
+ groupId,
767
+ createdBy: ctx.userId,
768
+ ttlMs: effectiveTtlMs
769
+ });
770
+ const host = typeof request.headers.host === "string" ? request.headers.host : "localhost";
771
+ const protocol = cookieOpts.cookieSecure ? "https" : "http";
772
+ const activationUrl = `${protocol}://${host}/activate?token=${result.activationToken}`;
773
+ return reply.code(201).send({ inviteId: result.inviteId, activationUrl });
774
+ });
775
+ app.post("/auth/invites/:id/revoke", {
776
+ schema: { tags: ["Auth"], summary: "Revoke a pending invite (admin only)" }
777
+ }, async (request, reply) => {
778
+ if (!await requirePermission(request, reply, PERMISSIONS.INVITES_WRITE)) {
779
+ return;
780
+ }
781
+ if (!checkCsrf(request, reply)) {
782
+ return;
783
+ }
784
+ const ctx = request.userAuthContext;
785
+ const invite = await invites.findById(request.params.id);
786
+ if (!invite || invite.tenantId !== ctx.tenantId) {
787
+ return reply.code(404).send({ error: "Not found", message: "Invite not found" });
788
+ }
789
+ await invites.revoke(request.params.id);
790
+ return reply.send({ ok: true });
791
+ });
792
+ app.get("/auth/invites", {
793
+ schema: { tags: ["Auth"], summary: "List invites for the tenant (admin only)" }
794
+ }, async (request, reply) => {
795
+ if (!await requirePermission(request, reply, PERMISSIONS.INVITES_READ)) {
796
+ return;
797
+ }
798
+ const ctx = request.userAuthContext;
799
+ const all = await invites.listByTenant(ctx.tenantId);
800
+ return reply.send({ invites: all });
801
+ });
802
+ app.get("/auth/users", {
803
+ schema: { tags: ["Auth"], summary: "List users for the tenant (admin only)" }
804
+ }, async (request, reply) => {
805
+ if (!await requirePermission(request, reply, PERMISSIONS.USERS_READ)) {
806
+ return;
807
+ }
808
+ const ctx = request.userAuthContext;
809
+ const all = await users.listByTenant(ctx.tenantId);
810
+ return reply.send({
811
+ users: all.map((u) => ({
812
+ userId: u.userId,
813
+ email: u.email,
814
+ tenantId: u.tenantId,
815
+ status: u.status
816
+ }))
817
+ });
818
+ });
819
+ app.post("/auth/users/:id/disable", {
820
+ schema: { tags: ["Auth"], summary: "Disable a user account (immediate via CD-1)" }
821
+ }, async (request, reply) => {
822
+ if (!await requirePermission(request, reply, PERMISSIONS.USERS_WRITE)) {
823
+ return;
824
+ }
825
+ if (!checkCsrf(request, reply)) {
826
+ return;
827
+ }
828
+ const ctx = request.userAuthContext;
829
+ const { id } = request.params;
830
+ const target = await users.getById(id);
831
+ if (!target || target.tenantId !== ctx.tenantId) {
832
+ return reply.code(404).send({ error: "Not found", message: "User not found" });
833
+ }
834
+ await users.setStatus(id, "disabled");
835
+ await sessions.revokeAllUserSessions(id);
836
+ return reply.send({ ok: true });
837
+ });
838
+ app.post("/auth/users/:id/enable", {
839
+ schema: { tags: ["Auth"], summary: "Re-enable a disabled user account" }
840
+ }, async (request, reply) => {
841
+ if (!await requirePermission(request, reply, PERMISSIONS.USERS_WRITE)) {
842
+ return;
843
+ }
844
+ if (!checkCsrf(request, reply)) {
845
+ return;
846
+ }
847
+ const ctx = request.userAuthContext;
848
+ const { id } = request.params;
849
+ const target = await users.getById(id);
850
+ if (!target || target.tenantId !== ctx.tenantId) {
851
+ return reply.code(404).send({ error: "Not found", message: "User not found" });
852
+ }
853
+ await users.setStatus(id, "active");
854
+ return reply.send({ ok: true });
855
+ });
856
+ }
151
857
 
152
858
  // src/execute/execution-registry.ts
153
859
  var ExecutionRegistry = class {
@@ -591,7 +1297,7 @@ async function resolveLLMForTier(tier) {
591
1297
  return llm;
592
1298
  }
593
1299
  function registerLLMGatewayRoutes(app, logger) {
594
- app.post("/llm/v1/chat/completions", { schema: { tags: ["LLM"], summary: "OpenAI-compatible chat completions", hide: true } }, async (request, reply) => {
1300
+ app.post("/api/v1/llm/chat/completions", { schema: { tags: ["LLM"], summary: "OpenAI-compatible chat completions", hide: true } }, async (request, reply) => {
595
1301
  const auth = request.authContext;
596
1302
  if (!auth) {
597
1303
  return reply.code(401).send({ error: "Unauthorized" });
@@ -900,8 +1606,36 @@ var ALLOWED_METHODS = {
900
1606
  embeddings: /* @__PURE__ */ new Set(["embed"]),
901
1607
  storage: /* @__PURE__ */ new Set(["read", "write", "delete", "list", "exists"]),
902
1608
  eventBus: /* @__PURE__ */ new Set(["publish", "subscribe"]),
903
- sqlDatabase: /* @__PURE__ */ new Set(["query", "execute"]),
904
- documentDatabase: /* @__PURE__ */ new Set(["find", "findOne", "insert", "update", "delete"])
1609
+ documentDatabase: /* @__PURE__ */ new Set([
1610
+ "find",
1611
+ "findById",
1612
+ "count",
1613
+ "insertOne",
1614
+ "insertMany",
1615
+ "updateOne",
1616
+ "updateMany",
1617
+ "updateById",
1618
+ "deleteMany",
1619
+ "deleteById",
1620
+ "bulkWrite",
1621
+ "ensureCollection",
1622
+ "ping"
1623
+ ]),
1624
+ kvStore: /* @__PURE__ */ new Set([
1625
+ "get",
1626
+ "getMany",
1627
+ "set",
1628
+ "setMany",
1629
+ "setIfNotExists",
1630
+ "delete",
1631
+ "exists",
1632
+ "cas",
1633
+ "incr",
1634
+ "ttl",
1635
+ "expire",
1636
+ "persist",
1637
+ "ping"
1638
+ ])
905
1639
  };
906
1640
  function resolveAdapter(name) {
907
1641
  const adapterMap = {
@@ -912,8 +1646,8 @@ function resolveAdapter(name) {
912
1646
  embeddings: () => platform.embeddings,
913
1647
  storage: () => platform.storage,
914
1648
  eventBus: () => platform.eventBus,
915
- sqlDatabase: () => platform.sqlDatabase,
916
- documentDatabase: () => platform.documentDatabase
1649
+ documentDatabase: () => platform.documentDatabase,
1650
+ kvStore: () => platform.kvStore
917
1651
  };
918
1652
  const getter = adapterMap[name];
919
1653
  return getter ? getter() : void 0;
@@ -1027,11 +1761,8 @@ function registerPlatformRoutes(app, logger) {
1027
1761
  }
1028
1762
  var MERGED_CACHE_KEY = "__gateway_merged_openapi";
1029
1763
  var MERGED_CACHE_TTL = 3e4;
1030
- var UPSTREAM_SPEC_URLS = [
1031
- "http://localhost:5050/openapi.json",
1032
- "http://localhost:7778/openapi.json"
1033
- ];
1034
- function registerAggregatedDocsRoutes(app, cache) {
1764
+ function registerAggregatedDocsRoutes(app, config, serviceTransport, cache) {
1765
+ const upstreamServiceIds = Object.values(config.upstreams).map((u) => u.serviceId);
1035
1766
  app.get("/openapi-merged.json", async (_req, reply) => {
1036
1767
  if (cache) {
1037
1768
  try {
@@ -1043,8 +1774,11 @@ function registerAggregatedDocsRoutes(app, cache) {
1043
1774
  }
1044
1775
  }
1045
1776
  const results = await Promise.allSettled(
1046
- UPSTREAM_SPEC_URLS.map(
1047
- (url) => fetch(url, { signal: AbortSignal.timeout(3e3) }).then((r) => r.json())
1777
+ upstreamServiceIds.map(
1778
+ (serviceId) => serviceTransport.call(serviceId, {
1779
+ path: "/openapi.json",
1780
+ signal: AbortSignal.timeout(3e3)
1781
+ }).then((r) => r.payload)
1048
1782
  )
1049
1783
  );
1050
1784
  const specs = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
@@ -1095,8 +1829,8 @@ var HostRegistry = class {
1095
1829
  const hosts = await this.store.listAll();
1096
1830
  for (const host of hosts) {
1097
1831
  const offline = { ...host, status: "offline", connections: [] };
1098
- const cacheKey = this.hostKey(host.namespaceId, host.hostId);
1099
- await this.cache.set(cacheKey, offline);
1832
+ const cacheKey2 = this.hostKey(host.namespaceId, host.hostId);
1833
+ await this.cache.set(cacheKey2, offline);
1100
1834
  await this.store.save(offline);
1101
1835
  await this.addToIndex(host.namespaceId, host.hostId);
1102
1836
  }
@@ -1268,10 +2002,16 @@ var HostRegistry = class {
1268
2002
  return results.filter((h) => h !== null);
1269
2003
  }
1270
2004
  async deregister(hostId, namespaceId) {
1271
- const deleted = this.store ? await this.store.delete(hostId, namespaceId) : false;
2005
+ if (this.store) {
2006
+ const deleted = await this.store.delete(hostId, namespaceId);
2007
+ await this.cache.delete(this.hostKey(namespaceId, hostId));
2008
+ await this.removeFromIndex(namespaceId, hostId);
2009
+ return deleted;
2010
+ }
2011
+ const exists = !!await this.cache.get(this.hostKey(namespaceId, hostId));
1272
2012
  await this.cache.delete(this.hostKey(namespaceId, hostId));
1273
2013
  await this.removeFromIndex(namespaceId, hostId);
1274
- return deleted;
2014
+ return exists;
1275
2015
  }
1276
2016
  async ensureRegistered(hostId, namespaceId, name, capabilities = []) {
1277
2017
  const existing = await this.get(hostId, namespaceId);
@@ -1356,8 +2096,29 @@ function createWsHandler(cache, jwtConfig, logger, hostRegistry) {
1356
2096
  socket.close(1008, "Missing Authorization header");
1357
2097
  return;
1358
2098
  }
2099
+ let resolveHello;
2100
+ let rejectHello;
2101
+ const helloRawPromise = new Promise((res, rej) => {
2102
+ resolveHello = res;
2103
+ rejectHello = rej;
2104
+ });
2105
+ helloRawPromise.catch(() => {
2106
+ });
2107
+ const helloTimeout = setTimeout(() => {
2108
+ socket.close(1008, "Hello timeout");
2109
+ rejectHello(new Error("Hello timeout"));
2110
+ }, HELLO_TIMEOUT_MS);
2111
+ socket.once("message", (raw) => {
2112
+ clearTimeout(helloTimeout);
2113
+ resolveHello(raw);
2114
+ });
2115
+ socket.once("close", () => {
2116
+ clearTimeout(helloTimeout);
2117
+ rejectHello(new Error("Socket closed before hello"));
2118
+ });
1359
2119
  const tokenEntry = await resolveToken(token, cache, jwtConfig);
1360
2120
  if (!tokenEntry || tokenEntry.type !== "machine") {
2121
+ clearTimeout(helloTimeout);
1361
2122
  logDiagnosticEvent(logger, {
1362
2123
  domain: "service",
1363
2124
  event: "gateway.hosts.ws.auth",
@@ -1376,88 +2137,67 @@ function createWsHandler(cache, jwtConfig, logger, hostRegistry) {
1376
2137
  const sessionId = randomUUID();
1377
2138
  let protocolVersion = null;
1378
2139
  let helloCaps = [];
1379
- let helloDone = false;
1380
2140
  const protocolVersions = SUPPORTED_PROTOCOL_VERSIONS;
1381
- await new Promise((resolve, reject) => {
1382
- const helloTimeout = setTimeout(() => {
1383
- if (!helloDone) {
1384
- helloDone = true;
1385
- logDiagnosticEvent(logger, {
1386
- domain: "service",
1387
- event: "gateway.hosts.ws.handshake",
1388
- level: "warn",
1389
- reasonCode: "websocket_hello_timeout",
1390
- message: "Host WebSocket hello timed out",
1391
- outcome: "failed",
1392
- serviceId: "gateway",
1393
- route: "/hosts/connect",
1394
- evidence: {
1395
- hostId,
1396
- namespaceId
1397
- }
1398
- });
1399
- socket.close(1008, "Hello timeout");
1400
- reject(new Error("Hello timeout"));
1401
- }
1402
- }, HELLO_TIMEOUT_MS);
1403
- socket.once("message", (raw) => {
1404
- if (helloDone) {
1405
- return;
1406
- }
1407
- helloDone = true;
1408
- clearTimeout(helloTimeout);
1409
- try {
1410
- const msg = HelloMessageSchema.parse(JSON.parse(raw.toString()));
1411
- if (!protocolVersions.includes(msg.protocolVersion)) {
1412
- logDiagnosticEvent(logger, {
1413
- domain: "service",
1414
- event: "gateway.hosts.ws.handshake",
1415
- level: "warn",
1416
- reasonCode: "websocket_protocol_unsupported",
1417
- message: "Host WebSocket protocol version is unsupported",
1418
- outcome: "failed",
1419
- serviceId: "gateway",
1420
- route: "/hosts/connect",
1421
- evidence: {
1422
- hostId,
1423
- namespaceId,
1424
- protocolVersion: msg.protocolVersion,
1425
- supportedVersions: [...SUPPORTED_PROTOCOL_VERSIONS]
1426
- }
1427
- });
1428
- send(socket, {
1429
- type: "negotiate",
1430
- supportedVersions: [...SUPPORTED_PROTOCOL_VERSIONS]
1431
- });
1432
- socket.close(1008, "Unsupported protocol version");
1433
- reject(new Error("Unsupported protocol version"));
1434
- return;
2141
+ try {
2142
+ const helloRaw = await helloRawPromise;
2143
+ const msg = HelloMessageSchema.parse(JSON.parse(helloRaw.toString()));
2144
+ if (!protocolVersions.includes(msg.protocolVersion)) {
2145
+ logDiagnosticEvent(logger, {
2146
+ domain: "service",
2147
+ event: "gateway.hosts.ws.handshake",
2148
+ level: "warn",
2149
+ reasonCode: "websocket_protocol_unsupported",
2150
+ message: "Host WebSocket protocol version is unsupported",
2151
+ outcome: "failed",
2152
+ serviceId: "gateway",
2153
+ route: "/hosts/connect",
2154
+ evidence: {
2155
+ hostId,
2156
+ namespaceId,
2157
+ protocolVersion: msg.protocolVersion,
2158
+ supportedVersions: [...SUPPORTED_PROTOCOL_VERSIONS]
1435
2159
  }
1436
- protocolVersion = msg.protocolVersion;
1437
- helloCaps = msg.capabilities ?? [];
1438
- resolve();
1439
- } catch (error) {
1440
- logDiagnosticEvent(logger, {
1441
- domain: "service",
1442
- event: "gateway.hosts.ws.handshake",
1443
- level: "warn",
1444
- reasonCode: "websocket_handshake_invalid",
1445
- message: "Host WebSocket hello message is invalid",
1446
- outcome: "failed",
1447
- error: error instanceof Error ? error : new Error(String(error)),
1448
- serviceId: "gateway",
1449
- route: "/hosts/connect",
1450
- evidence: {
1451
- hostId,
1452
- namespaceId
1453
- }
1454
- });
1455
- socket.close(1008, "Invalid hello message");
1456
- reject(new Error("Invalid hello"));
1457
- }
1458
- });
1459
- }).catch(() => {
1460
- });
2160
+ });
2161
+ send(socket, {
2162
+ type: "negotiate",
2163
+ supportedVersions: [...SUPPORTED_PROTOCOL_VERSIONS]
2164
+ });
2165
+ socket.close(1008, "Unsupported protocol version");
2166
+ return;
2167
+ }
2168
+ protocolVersion = msg.protocolVersion;
2169
+ helloCaps = msg.capabilities ?? [];
2170
+ } catch (error) {
2171
+ const isTimeout = error instanceof Error && error.message === "Hello timeout";
2172
+ if (isTimeout) {
2173
+ logDiagnosticEvent(logger, {
2174
+ domain: "service",
2175
+ event: "gateway.hosts.ws.handshake",
2176
+ level: "warn",
2177
+ reasonCode: "websocket_hello_timeout",
2178
+ message: "Host WebSocket hello timed out",
2179
+ outcome: "failed",
2180
+ serviceId: "gateway",
2181
+ route: "/hosts/connect",
2182
+ evidence: { hostId, namespaceId }
2183
+ });
2184
+ } else if (!(error instanceof Error && error.message === "Socket closed before hello")) {
2185
+ logDiagnosticEvent(logger, {
2186
+ domain: "service",
2187
+ event: "gateway.hosts.ws.handshake",
2188
+ level: "warn",
2189
+ reasonCode: "websocket_handshake_invalid",
2190
+ message: "Host WebSocket hello message is invalid",
2191
+ outcome: "failed",
2192
+ error: error instanceof Error ? error : new Error(String(error)),
2193
+ serviceId: "gateway",
2194
+ route: "/hosts/connect",
2195
+ evidence: { hostId, namespaceId }
2196
+ });
2197
+ socket.close(1008, "Invalid hello message");
2198
+ }
2199
+ return;
2200
+ }
1461
2201
  if (!protocolVersion) {
1462
2202
  return;
1463
2203
  }
@@ -1839,9 +2579,106 @@ function createClientWsHandler(cache, jwtConfig, logger) {
1839
2579
  };
1840
2580
  }
1841
2581
 
2582
+ // src/ws/pump.ts
2583
+ var OPEN = 1;
2584
+ function normalizeCloseCode(code) {
2585
+ if (code === void 0 || code === 1005 || code === 1006) {
2586
+ return void 0;
2587
+ }
2588
+ return code;
2589
+ }
2590
+ function safeClose(ws, code, reason) {
2591
+ if (ws.readyState === OPEN || ws.readyState === 0) {
2592
+ try {
2593
+ if (code === void 0) {
2594
+ ws.close();
2595
+ } else if (reason === void 0) {
2596
+ ws.close(code);
2597
+ } else {
2598
+ ws.close(code, reason);
2599
+ }
2600
+ } catch {
2601
+ }
2602
+ }
2603
+ }
2604
+ function pumpBidirectional(client, upstream, logger) {
2605
+ const pending = [];
2606
+ let upstreamOpen = false;
2607
+ let closed = false;
2608
+ const closeBoth = (code, reason) => {
2609
+ if (closed) {
2610
+ return;
2611
+ }
2612
+ closed = true;
2613
+ safeClose(client, code, reason);
2614
+ safeClose(upstream, code, reason);
2615
+ };
2616
+ client.on("message", (data, isBinary) => {
2617
+ if (upstreamOpen && upstream.readyState === OPEN) {
2618
+ upstream.send(data, { binary: isBinary });
2619
+ } else {
2620
+ pending.push({ data, binary: isBinary });
2621
+ }
2622
+ });
2623
+ upstream.on("open", () => {
2624
+ upstreamOpen = true;
2625
+ for (const frame of pending) {
2626
+ if (upstream.readyState === OPEN) {
2627
+ upstream.send(frame.data, { binary: frame.binary });
2628
+ }
2629
+ }
2630
+ pending.length = 0;
2631
+ });
2632
+ upstream.on("message", (data, isBinary) => {
2633
+ if (client.readyState === OPEN) {
2634
+ client.send(data, { binary: isBinary });
2635
+ }
2636
+ });
2637
+ upstream.on("close", (code, reason) => {
2638
+ closeBoth(normalizeCloseCode(code), reason);
2639
+ });
2640
+ client.on("close", (code, reason) => {
2641
+ closeBoth(normalizeCloseCode(code), reason);
2642
+ });
2643
+ upstream.on("error", (err) => {
2644
+ logger.warn("Upstream WS error", { error: err.message });
2645
+ closeBoth(1011);
2646
+ });
2647
+ client.on("error", (err) => {
2648
+ logger.warn("Client WS error", { error: err.message });
2649
+ closeBoth(1011);
2650
+ });
2651
+ }
2652
+
1842
2653
  // src/ws/gateway-ws.ts
1843
2654
  var GATEWAY_WS_PATHS = /* @__PURE__ */ new Set(["/hosts/connect", "/clients/connect"]);
1844
- function attachGatewayWs(server, cache, jwtConfig, logger, hostRegistry) {
2655
+ var FORWARDED_WS_HEADERS = [
2656
+ "authorization",
2657
+ "cookie",
2658
+ "x-request-id",
2659
+ "x-trace-id",
2660
+ "sec-websocket-protocol"
2661
+ ];
2662
+ function pickSocketWsUpstream(pathname, upstreams) {
2663
+ return upstreams.find(
2664
+ (u) => pathname === u.prefix || pathname.startsWith(`${u.prefix}/`)
2665
+ );
2666
+ }
2667
+ function buildUpstreamWsUrl(upstream, pathname, search) {
2668
+ const rewritten = upstream.rewritePrefix + pathname.slice(upstream.prefix.length);
2669
+ return `ws+unix://${upstream.socketPath}:${rewritten}${search}`;
2670
+ }
2671
+ function forwardWsHeaders(req) {
2672
+ const out = {};
2673
+ for (const name of FORWARDED_WS_HEADERS) {
2674
+ const value = req.headers[name];
2675
+ if (typeof value === "string") {
2676
+ out[name] = value;
2677
+ }
2678
+ }
2679
+ return out;
2680
+ }
2681
+ function attachGatewayWs(server, cache, jwtConfig, logger, hostRegistry, socketWsUpstreams = []) {
1845
2682
  const wss = new WebSocketServer({ noServer: true });
1846
2683
  const hostsHandler = createWsHandler(cache, jwtConfig, logger, hostRegistry);
1847
2684
  const clientsHandler = createClientWsHandler(cache, jwtConfig, logger);
@@ -1851,16 +2688,29 @@ function attachGatewayWs(server, cache, jwtConfig, logger, hostRegistry) {
1851
2688
  const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
1852
2689
  if (GATEWAY_WS_PATHS.has(pathname)) {
1853
2690
  wss.handleUpgrade(req, socket, head, (ws) => {
1854
- if (pathname === "/hosts/connect") {
1855
- hostsHandler(ws, req);
1856
- } else {
1857
- clientsHandler(ws, req);
1858
- }
2691
+ const handlerPromise = pathname === "/hosts/connect" ? hostsHandler(ws, req) : clientsHandler(ws, req);
2692
+ handlerPromise.catch((err) => {
2693
+ logger.error("Unhandled WS handler error", err instanceof Error ? err : new Error(String(err)), { pathname });
2694
+ try {
2695
+ ws.close(1011, "Internal error");
2696
+ } catch {
2697
+ }
2698
+ });
1859
2699
  });
1860
- } else {
1861
- for (const listener of existingListeners) {
1862
- listener.call(server, req, socket, head);
1863
- }
2700
+ return;
2701
+ }
2702
+ const socketUpstream = pickSocketWsUpstream(pathname, socketWsUpstreams);
2703
+ if (socketUpstream) {
2704
+ const { search } = new URL(req.url ?? "/", "http://localhost");
2705
+ const upstreamUrl = buildUpstreamWsUrl(socketUpstream, pathname, search);
2706
+ wss.handleUpgrade(req, socket, head, (clientWs) => {
2707
+ const upstreamWs = new WebSocket(upstreamUrl, { headers: forwardWsHeaders(req) });
2708
+ pumpBidirectional(clientWs, upstreamWs, logger);
2709
+ });
2710
+ return;
2711
+ }
2712
+ for (const listener of existingListeners) {
2713
+ listener.call(server, req, socket, head);
1864
2714
  }
1865
2715
  });
1866
2716
  logger.info("Gateway WS endpoints attached", { paths: [...GATEWAY_WS_PATHS] });
@@ -2117,10 +2967,767 @@ function mergeTopOperations(httpOperations, domainOperations, limit = 5) {
2117
2967
  }
2118
2968
  return [...sliced.slice(0, Math.max(0, limit - 1)), firstDomainOperation];
2119
2969
  }
2970
+ function registerInternalRoutes(scope, internalSecret, hostRegistry, cache) {
2971
+ scope.post("/internal/dispatch", async (request, reply) => {
2972
+ const provided = request.headers["x-internal-secret"];
2973
+ if (!internalSecret || provided !== internalSecret) {
2974
+ return reply.code(403).send({ error: "Forbidden" });
2975
+ }
2976
+ const body = request.body;
2977
+ if (!body.namespaceId || !body.adapter || !body.method) {
2978
+ return reply.code(400).send({ error: "Missing required fields: namespaceId, adapter, method" });
2979
+ }
2980
+ const hostId = body.hostId ?? globalDispatcher.firstHostWithCapability(body.namespaceId, body.adapter) ?? globalDispatcher.firstHost(body.namespaceId);
2981
+ if (!hostId) {
2982
+ return reply.code(503).send({
2983
+ error: "No host connected",
2984
+ namespaceId: body.namespaceId
2985
+ });
2986
+ }
2987
+ try {
2988
+ const result = await globalDispatcher.call(
2989
+ body.namespaceId,
2990
+ hostId,
2991
+ body.adapter,
2992
+ body.method,
2993
+ body.args ?? []
2994
+ );
2995
+ return { result };
2996
+ } catch (err) {
2997
+ const message = err instanceof Error ? err.message : String(err);
2998
+ if (message.includes("Host not connected")) {
2999
+ return reply.code(503).send({ error: message });
3000
+ }
3001
+ return reply.code(502).send({ error: message });
3002
+ }
3003
+ });
3004
+ scope.get("/internal/auth/handle/:handle", async (request, reply) => {
3005
+ const provided = request.headers["x-internal-secret"];
3006
+ if (!internalSecret || provided !== internalSecret) {
3007
+ return reply.code(403).send({ error: "Forbidden" });
3008
+ }
3009
+ if (!cache) {
3010
+ return reply.code(503).send({ error: "Cache not available" });
3011
+ }
3012
+ const { handle } = request.params;
3013
+ const record = await getClientByHandle(cache, handle);
3014
+ if (!record) {
3015
+ return reply.code(404).send({ error: "Handle not found" });
3016
+ }
3017
+ return { namespaceId: record.namespaceId, name: record.name, handle: record.handle };
3018
+ });
3019
+ scope.post("/internal/resolve-host", async (request, reply) => {
3020
+ const provided = request.headers["x-internal-secret"];
3021
+ if (!internalSecret || provided !== internalSecret) {
3022
+ return reply.code(403).send({ error: "Forbidden" });
3023
+ }
3024
+ const body = request.body;
3025
+ const namespaceId = body.namespaceId ?? "default";
3026
+ const target = body.target ?? {};
3027
+ const strategy = target.hostSelection ?? "any-matching";
3028
+ let hostId;
3029
+ if (strategy === "pinned" && target.hostId) {
3030
+ const host = await hostRegistry.get(target.hostId, namespaceId);
3031
+ if (host?.status === "online" || host?.status === "reconnecting") {
3032
+ hostId = target.hostId;
3033
+ }
3034
+ } else {
3035
+ hostId = globalDispatcher.firstHostWithCapability(namespaceId, "execution");
3036
+ }
3037
+ if (!hostId) {
3038
+ return reply.code(404).send({ error: "No matching host found" });
3039
+ }
3040
+ return { hostId, strategy, namespaceId };
3041
+ });
3042
+ }
3043
+
3044
+ // src/pressure/resolve.ts
3045
+ function resolveResourceId(method, url, config) {
3046
+ const pressure = config.pressure;
3047
+ if (!pressure || pressure.enabled === false) {
3048
+ return null;
3049
+ }
3050
+ const path = url.split("?")[0] ?? url;
3051
+ const upperMethod = method.toUpperCase();
3052
+ for (const override of pressure.perRoute) {
3053
+ if (!path.startsWith(override.pathPrefix)) {
3054
+ continue;
3055
+ }
3056
+ if (override.methods && override.methods.length > 0) {
3057
+ const allowed = override.methods.some((m) => m.toUpperCase() === upperMethod);
3058
+ if (!allowed) {
3059
+ continue;
3060
+ }
3061
+ }
3062
+ return { resource: override.resource, layer: "route", override };
3063
+ }
3064
+ for (const [name, upstream] of Object.entries(config.upstreams)) {
3065
+ if (path.startsWith(upstream.prefix)) {
3066
+ const limits = pressure.perService[name];
3067
+ if (!limits) {
3068
+ return null;
3069
+ }
3070
+ return { resource: `gateway:service:${name}`, layer: "service", upstream: name };
3071
+ }
3072
+ }
3073
+ return null;
3074
+ }
3075
+
3076
+ // src/pressure/register-limits.ts
3077
+ function registerPressureLimits(broker, config, logger) {
3078
+ if (!config || config.enabled === false) {
3079
+ return { perServiceRegistered: 0, perRouteRegistered: 0, perTenantEnabled: false };
3080
+ }
3081
+ let perServiceRegistered = 0;
3082
+ for (const [name, limits] of Object.entries(config.perService)) {
3083
+ broker.registerLimit(`gateway:service:${name}`, limits);
3084
+ perServiceRegistered++;
3085
+ }
3086
+ let perRouteRegistered = 0;
3087
+ for (const override of config.perRoute) {
3088
+ broker.registerLimit(override.resource, override.limits);
3089
+ perRouteRegistered++;
3090
+ }
3091
+ const perTenantEnabled = config.perTenant?.enabled === true;
3092
+ logger.info("pressure.boot", {
3093
+ event: "pressure.boot",
3094
+ perService: perServiceRegistered,
3095
+ perRoute: perRouteRegistered,
3096
+ perTenant: perTenantEnabled
3097
+ });
3098
+ return { perServiceRegistered, perRouteRegistered, perTenantEnabled };
3099
+ }
3100
+
3101
+ // src/pressure/hooks.ts
3102
+ function shouldSkip(request) {
3103
+ const upgrade = request.headers.upgrade;
3104
+ if (typeof upgrade === "string" && upgrade.length > 0) {
3105
+ return true;
3106
+ }
3107
+ return false;
3108
+ }
3109
+ function armRequest(request) {
3110
+ if (!request.pressureReleases) {
3111
+ request.pressureReleases = [];
3112
+ request.raw.on("close", () => {
3113
+ void releaseAll(request);
3114
+ });
3115
+ }
3116
+ return request.pressureReleases;
3117
+ }
3118
+ async function releaseAll(request) {
3119
+ const releases = request.pressureReleases;
3120
+ if (!releases || releases.length === 0) {
3121
+ return;
3122
+ }
3123
+ const snapshot = releases.slice();
3124
+ request.pressureReleases = [];
3125
+ for (const release of snapshot) {
3126
+ try {
3127
+ await release();
3128
+ } catch {
3129
+ }
3130
+ }
3131
+ }
3132
+ function retryAfterSeconds(waitTimeMs) {
3133
+ if (!waitTimeMs || waitTimeMs <= 0) {
3134
+ return 1;
3135
+ }
3136
+ return Math.max(1, Math.ceil(waitTimeMs / 1e3));
3137
+ }
3138
+ function createPressureOnRequest(deps) {
3139
+ return async (request, reply) => {
3140
+ if (shouldSkip(request)) {
3141
+ return;
3142
+ }
3143
+ const resolved = resolveResourceId(request.method, request.url, deps.config);
3144
+ if (!resolved) {
3145
+ return;
3146
+ }
3147
+ const acquired = await deps.broker.tryAcquire(resolved.resource);
3148
+ if (!acquired.allowed) {
3149
+ const retryAfter = retryAfterSeconds(acquired.waitTimeMs);
3150
+ deps.logger.warn("pressure.rejected", {
3151
+ event: "pressure.rejected",
3152
+ resource: resolved.resource,
3153
+ layer: resolved.layer,
3154
+ method: request.method,
3155
+ url: request.url,
3156
+ waitTimeMs: acquired.waitTimeMs
3157
+ });
3158
+ reply.code(429).header("Retry-After", String(retryAfter)).send({
3159
+ error: "rate_limited",
3160
+ resource: resolved.resource,
3161
+ waitTimeMs: acquired.waitTimeMs ?? null
3162
+ });
3163
+ return reply;
3164
+ }
3165
+ armRequest(request).push(acquired.release);
3166
+ };
3167
+ }
3168
+ function createPressurePreHandler(deps) {
3169
+ const registered = /* @__PURE__ */ new Set();
3170
+ const tenantCfg = deps.config.pressure?.perTenant;
3171
+ return async (request, reply) => {
3172
+ if (!tenantCfg || tenantCfg.enabled !== true) {
3173
+ return;
3174
+ }
3175
+ if (shouldSkip(request)) {
3176
+ return;
3177
+ }
3178
+ const namespaceId = request.authContext?.namespaceId;
3179
+ if (!namespaceId) {
3180
+ return;
3181
+ }
3182
+ const resource = `gateway:tenant:${namespaceId}`;
3183
+ if (!registered.has(namespaceId)) {
3184
+ deps.broker.registerLimit(resource, tenantCfg.limits);
3185
+ registered.add(namespaceId);
3186
+ deps.logger.debug?.("pressure.tenant.registered", {
3187
+ event: "pressure.tenant.registered",
3188
+ namespaceId
3189
+ });
3190
+ }
3191
+ const acquired = await deps.broker.tryAcquire(resource);
3192
+ if (!acquired.allowed) {
3193
+ const retryAfter = retryAfterSeconds(acquired.waitTimeMs);
3194
+ deps.logger.warn("pressure.rejected", {
3195
+ event: "pressure.rejected",
3196
+ resource,
3197
+ layer: "tenant",
3198
+ method: request.method,
3199
+ url: request.url,
3200
+ namespaceId,
3201
+ waitTimeMs: acquired.waitTimeMs
3202
+ });
3203
+ reply.code(429).header("Retry-After", String(retryAfter)).send({
3204
+ error: "rate_limited",
3205
+ resource,
3206
+ waitTimeMs: acquired.waitTimeMs ?? null
3207
+ });
3208
+ return reply;
3209
+ }
3210
+ armRequest(request).push(acquired.release);
3211
+ };
3212
+ }
3213
+ function createPressureOnResponse() {
3214
+ return async (request) => {
3215
+ await releaseAll(request);
3216
+ };
3217
+ }
3218
+
3219
+ // src/webhook/secret-store.ts
3220
+ var PREVIOUS_SECRET_GRACE_MS = 864e5;
3221
+ function cacheKey(ns, pluginId, event, instanceId) {
3222
+ const base = `webhook:secret:${ns}:${pluginId}:${event}`;
3223
+ return instanceId ? `${base}:${instanceId}` : base;
3224
+ }
3225
+ var WebhookSecretStore = class {
3226
+ constructor(cache) {
3227
+ this.cache = cache;
3228
+ }
3229
+ cache;
3230
+ async get(ns, pluginId, event, instanceId) {
3231
+ return this.cache.get(cacheKey(ns, pluginId, event, instanceId));
3232
+ }
3233
+ async set(ns, pluginId, event, entry, instanceId) {
3234
+ await this.cache.set(cacheKey(ns, pluginId, event, instanceId), entry);
3235
+ }
3236
+ async rotate(ns, pluginId, event, newSecret, instanceId) {
3237
+ const existing = await this.get(ns, pluginId, event, instanceId);
3238
+ const entry = { current: newSecret };
3239
+ if (existing) {
3240
+ entry.previous = existing.current;
3241
+ entry.previousExpiresAt = Date.now() + PREVIOUS_SECRET_GRACE_MS;
3242
+ }
3243
+ await this.set(ns, pluginId, event, entry, instanceId);
3244
+ return entry;
3245
+ }
3246
+ async delete(ns, pluginId, event, instanceId) {
3247
+ await this.cache.delete(cacheKey(ns, pluginId, event, instanceId));
3248
+ }
3249
+ };
3250
+ async function provisionWebhook(input, secretStore, backend, manifests, logger) {
3251
+ const { namespaceId, pluginId, event, instanceId, baseUrl } = input;
3252
+ const manifestEntry = manifests.find((m) => m.pluginId === pluginId);
3253
+ const decl = manifestEntry?.manifest.webhooks?.handlers.find((h) => h.event === event);
3254
+ if (!manifestEntry || !decl) {
3255
+ throw new Error(`webhook '${pluginId}/${event}' not found`);
3256
+ }
3257
+ const existing = await secretStore.get(namespaceId, pluginId, event, instanceId);
3258
+ const rotated = existing !== null;
3259
+ const newSecret = randomBytes(32).toString("hex");
3260
+ if (rotated) {
3261
+ await secretStore.rotate(namespaceId, pluginId, event, newSecret, instanceId);
3262
+ } else {
3263
+ await secretStore.set(namespaceId, pluginId, event, { current: newSecret }, instanceId);
3264
+ }
3265
+ const url = instanceId ? `${baseUrl}/webhooks/${encodeURIComponent(pluginId)}/${event}/${instanceId}` : `${baseUrl}/webhooks/${encodeURIComponent(pluginId)}/${event}`;
3266
+ let onProvisionCalled = false;
3267
+ if (decl.onProvision) {
3268
+ try {
3269
+ await backend.execute({
3270
+ handlerRef: decl.onProvision,
3271
+ pluginRoot: manifestEntry.pluginRoot,
3272
+ namespaceId,
3273
+ input: { instanceId, secret: newSecret, url }
3274
+ });
3275
+ onProvisionCalled = true;
3276
+ } catch (err) {
3277
+ logger.error(
3278
+ "onProvision handler failed",
3279
+ err instanceof Error ? err : new Error(String(err)),
3280
+ { pluginId, event, instanceId }
3281
+ );
3282
+ }
3283
+ }
3284
+ return { url, secret: newSecret, rotated, onProvisionCalled };
3285
+ }
3286
+
3287
+ // src/webhook/admin-routes.ts
3288
+ var RL_PROVISION = "webhook-admin:provision";
3289
+ var RL_LIST = "webhook-admin:list";
3290
+ var RL_REVOKE = "webhook-admin:revoke";
3291
+ function registerWebhookAdminRoutes(scope, options) {
3292
+ const { cache, logger, backend, manifests, baseUrl, broker } = options;
3293
+ const secretStore = new WebhookSecretStore(cache);
3294
+ broker.registerLimit(RL_PROVISION, { requestsPerMinute: 10 });
3295
+ broker.registerLimit(RL_LIST, { requestsPerMinute: 60 });
3296
+ broker.registerLimit(RL_REVOKE, { requestsPerMinute: 10 });
3297
+ scope.post("/api/v1/webhooks/provision", async (request, reply) => {
3298
+ const auth = request.authContext;
3299
+ if (!auth) {
3300
+ return reply.code(401).send({ error: "Unauthorized" });
3301
+ }
3302
+ const acquired = await broker.tryAcquire(RL_PROVISION);
3303
+ try {
3304
+ if (!acquired.allowed) {
3305
+ const retryAfterSec = acquired.waitTimeMs ? Math.max(1, Math.ceil(acquired.waitTimeMs / 1e3)) : 1;
3306
+ return reply.code(429).header("Retry-After", String(retryAfterSec)).send({ error: "Too Many Requests" });
3307
+ }
3308
+ const body = request.body;
3309
+ const pluginId = typeof body?.pluginId === "string" ? body.pluginId : void 0;
3310
+ const event = typeof body?.event === "string" ? body.event : void 0;
3311
+ const instanceId = typeof body?.instanceId === "string" ? body.instanceId : void 0;
3312
+ if (!pluginId) {
3313
+ return reply.code(400).send({ error: "Bad Request", message: "pluginId is required" });
3314
+ }
3315
+ if (!event) {
3316
+ return reply.code(400).send({ error: "Bad Request", message: "event is required" });
3317
+ }
3318
+ try {
3319
+ const result = await provisionWebhook(
3320
+ { namespaceId: auth.namespaceId, pluginId, event, instanceId, baseUrl },
3321
+ secretStore,
3322
+ backend,
3323
+ manifests,
3324
+ logger
3325
+ );
3326
+ return reply.code(200).send({
3327
+ url: result.url,
3328
+ secret: result.secret,
3329
+ rotated: result.rotated
3330
+ });
3331
+ } catch (err) {
3332
+ if (err instanceof Error && err.message.includes("not found")) {
3333
+ return reply.code(404).send({ error: "Not Found", message: err.message });
3334
+ }
3335
+ throw err;
3336
+ }
3337
+ } finally {
3338
+ await acquired.release();
3339
+ }
3340
+ });
3341
+ scope.get("/api/v1/webhooks", async (request, reply) => {
3342
+ const auth = request.authContext;
3343
+ if (!auth) {
3344
+ return reply.code(401).send({ error: "Unauthorized" });
3345
+ }
3346
+ const acquired = await broker.tryAcquire(RL_LIST);
3347
+ try {
3348
+ if (!acquired.allowed) {
3349
+ const retryAfterSec = acquired.waitTimeMs ? Math.max(1, Math.ceil(acquired.waitTimeMs / 1e3)) : 1;
3350
+ return reply.code(429).header("Retry-After", String(retryAfterSec)).send({ error: "Too Many Requests" });
3351
+ }
3352
+ const query = request.query;
3353
+ const filterPluginId = query.pluginId;
3354
+ const webhooks = [];
3355
+ for (const entry of manifests) {
3356
+ if (filterPluginId && entry.pluginId !== filterPluginId) {
3357
+ continue;
3358
+ }
3359
+ if (!entry.manifest.webhooks?.handlers) {
3360
+ continue;
3361
+ }
3362
+ for (const decl of entry.manifest.webhooks.handlers) {
3363
+ const secretEntry = decl.multi ? null : await secretStore.get(auth.namespaceId, entry.pluginId, decl.event);
3364
+ webhooks.push({
3365
+ pluginId: entry.pluginId,
3366
+ event: decl.event,
3367
+ multi: decl.multi === true,
3368
+ provisioned: secretEntry !== null
3369
+ });
3370
+ }
3371
+ }
3372
+ return reply.code(200).send({ webhooks });
3373
+ } finally {
3374
+ await acquired.release();
3375
+ }
3376
+ });
3377
+ scope.delete(
3378
+ "/api/v1/webhooks/:pluginId/:event",
3379
+ async (request, reply) => {
3380
+ const auth = request.authContext;
3381
+ if (!auth) {
3382
+ return reply.code(401).send({ error: "Unauthorized" });
3383
+ }
3384
+ const acquired = await broker.tryAcquire(RL_REVOKE);
3385
+ try {
3386
+ if (!acquired.allowed) {
3387
+ const retryAfterSec = acquired.waitTimeMs ? Math.max(1, Math.ceil(acquired.waitTimeMs / 1e3)) : 1;
3388
+ return reply.code(429).header("Retry-After", String(retryAfterSec)).send({ error: "Too Many Requests" });
3389
+ }
3390
+ const { pluginId, event } = request.params;
3391
+ await secretStore.delete(auth.namespaceId, pluginId, event);
3392
+ return reply.code(204).send();
3393
+ } finally {
3394
+ await acquired.release();
3395
+ }
3396
+ }
3397
+ );
3398
+ scope.delete(
3399
+ "/api/v1/webhooks/:pluginId/:event/:instanceId",
3400
+ async (request, reply) => {
3401
+ const auth = request.authContext;
3402
+ if (!auth) {
3403
+ return reply.code(401).send({ error: "Unauthorized" });
3404
+ }
3405
+ const acquired = await broker.tryAcquire(RL_REVOKE);
3406
+ try {
3407
+ if (!acquired.allowed) {
3408
+ const retryAfterSec = acquired.waitTimeMs ? Math.max(1, Math.ceil(acquired.waitTimeMs / 1e3)) : 1;
3409
+ return reply.code(429).header("Retry-After", String(retryAfterSec)).send({ error: "Too Many Requests" });
3410
+ }
3411
+ const { pluginId, event, instanceId } = request.params;
3412
+ await secretStore.delete(auth.namespaceId, pluginId, event, instanceId);
3413
+ return reply.code(204).send();
3414
+ } finally {
3415
+ await acquired.release();
3416
+ }
3417
+ }
3418
+ );
3419
+ }
3420
+
3421
+ // src/webhook/idempotency-store.ts
3422
+ var IDEMPOTENCY_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
3423
+ var WebhookIdempotencyStore = class {
3424
+ constructor(cache) {
3425
+ this.cache = cache;
3426
+ }
3427
+ cache;
3428
+ /**
3429
+ * Check if this delivery has already been processed, and mark it atomically.
3430
+ * Keys are scoped by namespaceId to prevent cross-tenant pollution.
3431
+ *
3432
+ * @returns `true` if duplicate (already processed), `false` if first time
3433
+ */
3434
+ async checkAndMark(namespaceId, pluginId, event, key) {
3435
+ const cacheKey2 = `webhook:idempotency:${namespaceId}:${pluginId}:${event}:${key}`;
3436
+ const isNew = await this.cache.setIfNotExists(cacheKey2, 1, IDEMPOTENCY_TTL_MS);
3437
+ return !isNew;
3438
+ }
3439
+ };
3440
+ function getHeader(headers, name) {
3441
+ const lower = name.toLowerCase();
3442
+ const value = headers[lower] ?? headers[name];
3443
+ return Array.isArray(value) ? value[0] : value;
3444
+ }
3445
+ var _SESSION_KEY = randomBytes(32);
3446
+ function safeEqual(a, b) {
3447
+ const ha = createHmac("sha256", _SESSION_KEY).update(a).digest();
3448
+ const hb = createHmac("sha256", _SESSION_KEY).update(b).digest();
3449
+ return timingSafeEqual(ha, hb);
3450
+ }
3451
+ function isPreviousValid(entry) {
3452
+ return entry.previous !== void 0 && entry.previousExpiresAt !== void 0 && entry.previousExpiresAt > Date.now();
3453
+ }
3454
+ function verifySecret(header, headers, entry) {
3455
+ const value = getHeader(headers, header);
3456
+ if (!value) {
3457
+ return false;
3458
+ }
3459
+ if (safeEqual(value, entry.current)) {
3460
+ return true;
3461
+ }
3462
+ if (isPreviousValid(entry) && safeEqual(value, entry.previous)) {
3463
+ return true;
3464
+ }
3465
+ return false;
3466
+ }
3467
+ function computeHmac(body, secret) {
3468
+ return createHmac("sha256", secret).update(body).digest("hex");
3469
+ }
3470
+ function verifyHmac(header, prefix, rawBody, headers, entry) {
3471
+ const headerValue = getHeader(headers, header);
3472
+ if (!headerValue) {
3473
+ return false;
3474
+ }
3475
+ const receivedSig = prefix && headerValue.startsWith(prefix) ? headerValue.slice(prefix.length) : headerValue;
3476
+ const expectedCurrent = computeHmac(rawBody, entry.current);
3477
+ if (safeEqual(receivedSig, expectedCurrent)) {
3478
+ return true;
3479
+ }
3480
+ if (isPreviousValid(entry)) {
3481
+ const expectedPrevious = computeHmac(rawBody, entry.previous);
3482
+ if (safeEqual(receivedSig, expectedPrevious)) {
3483
+ return true;
3484
+ }
3485
+ }
3486
+ return false;
3487
+ }
3488
+ async function verifyWebhookAuth(authConfig, req, secretStore, backend, pluginRoot) {
3489
+ const entry = await secretStore.get(req.namespaceId, req.pluginId, req.event, req.instanceId);
3490
+ if (!entry) {
3491
+ return { valid: false, reason: "not provisioned" };
3492
+ }
3493
+ switch (authConfig.type) {
3494
+ case "secret": {
3495
+ const valid = verifySecret(authConfig.header, req.headers, entry);
3496
+ return valid ? { valid: true } : { valid: false, reason: "invalid secret" };
3497
+ }
3498
+ case "hmac": {
3499
+ const valid = verifyHmac(authConfig.header, authConfig.prefix, req.rawBody, req.headers, entry);
3500
+ return valid ? { valid: true } : { valid: false, reason: "invalid hmac signature" };
3501
+ }
3502
+ case "custom": {
3503
+ if (!backend || !pluginRoot) {
3504
+ return { valid: false, reason: "custom validator requires backend and pluginRoot" };
3505
+ }
3506
+ try {
3507
+ const bodyHmac = createHmac("sha256", entry.current).update(req.rawBody).digest("hex");
3508
+ const result = await backend.execute({
3509
+ handlerRef: authConfig.validator,
3510
+ pluginRoot,
3511
+ input: {
3512
+ headers: req.headers,
3513
+ rawBody: req.rawBody.toString("base64"),
3514
+ bodyHmac
3515
+ }
3516
+ });
3517
+ return result.valid ? { valid: true } : { valid: false, reason: "custom validator rejected" };
3518
+ } catch {
3519
+ return { valid: false, reason: "validator error" };
3520
+ }
3521
+ }
3522
+ }
3523
+ }
3524
+
3525
+ // src/webhook/router.ts
3526
+ function getByDotPath(obj, path) {
3527
+ const parts = path.split(".");
3528
+ let current = obj;
3529
+ for (const part of parts) {
3530
+ if (!current || typeof current !== "object") {
3531
+ return void 0;
3532
+ }
3533
+ current = current[part];
3534
+ }
3535
+ return current;
3536
+ }
3537
+ async function registerWebhookRoutes(scope, options) {
3538
+ const { cache, broker, logger, manifests } = options;
3539
+ const secretStore = new WebhookSecretStore(cache);
3540
+ const idempotencyStore = new WebhookIdempotencyStore(cache);
3541
+ const declMap = /* @__PURE__ */ new Map();
3542
+ let needsRawBody = false;
3543
+ for (const entry of manifests) {
3544
+ const { pluginId, manifest, pluginRoot } = entry;
3545
+ if (!manifest.webhooks?.handlers.length) {
3546
+ continue;
3547
+ }
3548
+ for (const decl of manifest.webhooks.handlers) {
3549
+ if (!decl.auth) {
3550
+ throw new Error(`webhook '${pluginId}/${decl.event}' has no auth config`);
3551
+ }
3552
+ const mapKey = `${pluginId}:${decl.event}:${decl.multi ? "multi" : "single"}`;
3553
+ declMap.set(mapKey, { pluginId, decl, pluginRoot, manifest });
3554
+ broker.registerLimit(`webhook:${pluginId}:${decl.event}`, {
3555
+ requestsPerMinute: decl.rateLimit?.requestsPerMinute ?? 60
3556
+ });
3557
+ if (decl.auth.type === "hmac" || decl.auth.type === "custom") {
3558
+ needsRawBody = true;
3559
+ }
3560
+ }
3561
+ }
3562
+ if (declMap.size === 0) {
3563
+ return 0;
3564
+ }
3565
+ if (needsRawBody) {
3566
+ scope.addHook("preParsing", async (_request, _reply, payload) => {
3567
+ const chunks = [];
3568
+ for await (const chunk of payload) {
3569
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
3570
+ }
3571
+ const body = Buffer.concat(chunks);
3572
+ _request.rawBody = body;
3573
+ return Readable.from(body);
3574
+ });
3575
+ }
3576
+ scope.post(
3577
+ "/webhooks/:pluginId/:event",
3578
+ async (request, reply) => {
3579
+ const { pluginId, event } = request.params;
3580
+ const entry = declMap.get(`${pluginId}:${event}:single`);
3581
+ if (!entry) {
3582
+ return reply.code(404).send({ error: "Webhook not found" });
3583
+ }
3584
+ return handleWebhook({ request, reply, entry, namespaceHeader: request.headers["x-kb-namespace"], instanceId: void 0, secretStore, idempotencyStore, broker, logger });
3585
+ }
3586
+ );
3587
+ scope.post(
3588
+ "/webhooks/:pluginId/:event/:instanceId",
3589
+ async (request, reply) => {
3590
+ const { pluginId, event, instanceId } = request.params;
3591
+ const entry = declMap.get(`${pluginId}:${event}:multi`);
3592
+ if (!entry) {
3593
+ return reply.code(404).send({ error: "Webhook not found" });
3594
+ }
3595
+ return handleWebhook({ request, reply, entry, namespaceHeader: request.headers["x-kb-namespace"], instanceId, secretStore, idempotencyStore, broker, logger });
3596
+ }
3597
+ );
3598
+ return declMap.size;
3599
+ }
3600
+ async function handleWebhook({
3601
+ request,
3602
+ reply,
3603
+ entry,
3604
+ namespaceHeader,
3605
+ instanceId,
3606
+ secretStore,
3607
+ idempotencyStore,
3608
+ broker,
3609
+ logger
3610
+ }) {
3611
+ const { pluginId, decl, pluginRoot, manifest } = entry;
3612
+ const namespaceId = Array.isArray(namespaceHeader) ? namespaceHeader[0] : namespaceHeader;
3613
+ if (!namespaceId) {
3614
+ return reply.code(400).send({ error: "Missing required header: x-kb-namespace" });
3615
+ }
3616
+ const rateResource = `webhook:${pluginId}:${decl.event}`;
3617
+ const acquired = await broker.tryAcquire(rateResource);
3618
+ try {
3619
+ if (!acquired.allowed) {
3620
+ const retryAfterSec = acquired.waitTimeMs ? Math.max(1, Math.ceil(acquired.waitTimeMs / 1e3)) : 1;
3621
+ return reply.code(429).header("Retry-After", String(retryAfterSec)).send({
3622
+ error: "Too Many Requests",
3623
+ retryAfterMs: acquired.waitTimeMs ?? null
3624
+ });
3625
+ }
3626
+ if (decl.challenge && request.body) {
3627
+ const fieldValue = getByDotPath(request.body, decl.challenge.bodyPath);
3628
+ if (fieldValue === decl.challenge.value) {
3629
+ const replyValue = getByDotPath(request.body, decl.challenge.replyPath);
3630
+ const replyKey = decl.challenge.replyPath.split(".").pop();
3631
+ return reply.code(200).send({ [replyKey]: replyValue });
3632
+ }
3633
+ }
3634
+ const rawBody = request.rawBody ?? (request.body == null ? Buffer.alloc(0) : Buffer.from(typeof request.body === "string" ? request.body : JSON.stringify(request.body)));
3635
+ const authReq = {
3636
+ headers: request.headers,
3637
+ rawBody,
3638
+ namespaceId,
3639
+ pluginId,
3640
+ event: decl.event,
3641
+ instanceId
3642
+ };
3643
+ const authBackend = decl.auth.type === "custom" ? makeAuthBackend(namespaceId, pluginId) : void 0;
3644
+ const authResult = await verifyWebhookAuth(decl.auth, authReq, secretStore, authBackend, pluginRoot);
3645
+ if (!authResult.valid) {
3646
+ return reply.code(401).send({ error: "Unauthorized", reason: authResult.reason });
3647
+ }
3648
+ if (decl.idempotencyKey && request.body) {
3649
+ const deliveryKey = getByDotPath(request.body, decl.idempotencyKey);
3650
+ if (typeof deliveryKey === "string") {
3651
+ const isDuplicate = await idempotencyStore.checkAndMark(namespaceId, pluginId, decl.event, deliveryKey);
3652
+ if (isDuplicate) {
3653
+ return reply.code(200).send({ status: "duplicate" });
3654
+ }
3655
+ }
3656
+ }
3657
+ const webhookId = randomUUID();
3658
+ const hostContext = {
3659
+ host: "webhook",
3660
+ event: decl.event,
3661
+ source: request.ip,
3662
+ payload: request.body,
3663
+ namespaceId,
3664
+ webhookId,
3665
+ ...instanceId !== void 0 ? { instanceId } : {}
3666
+ };
3667
+ const hostId = globalDispatcher.firstHostWithCapability(namespaceId, "execution");
3668
+ if (!hostId) {
3669
+ return reply.code(503).send({ error: "No execution host connected" });
3670
+ }
3671
+ const dispatchArgs = [{
3672
+ pluginId,
3673
+ handlerRef: decl.handler,
3674
+ input: request.body,
3675
+ executionId: webhookId,
3676
+ requestId: webhookId,
3677
+ descriptor: {
3678
+ hostType: "webhook",
3679
+ hostContext,
3680
+ pluginId,
3681
+ pluginVersion: manifest.version,
3682
+ requestId: webhookId,
3683
+ permissions: decl.permissions ?? []
3684
+ }
3685
+ }];
3686
+ if (decl.async) {
3687
+ reply.code(202).send({ status: "accepted", webhookId });
3688
+ void globalDispatcher.call(namespaceId, hostId, "execution", "execute", dispatchArgs).catch((err) => {
3689
+ logger.error(
3690
+ "Webhook async dispatch failed",
3691
+ err instanceof Error ? err : new Error(String(err)),
3692
+ { pluginId, event: decl.event, webhookId }
3693
+ );
3694
+ });
3695
+ return;
3696
+ }
3697
+ try {
3698
+ const result = await globalDispatcher.call(namespaceId, hostId, "execution", "execute", dispatchArgs);
3699
+ return reply.code(200).send({ status: "ok", webhookId, result });
3700
+ } catch (err) {
3701
+ logger.error(
3702
+ "Webhook dispatch failed",
3703
+ err instanceof Error ? err : new Error(String(err)),
3704
+ { pluginId, event: decl.event, webhookId }
3705
+ );
3706
+ return reply.code(500).send({ error: "Dispatch failed" });
3707
+ }
3708
+ } finally {
3709
+ await acquired.release();
3710
+ }
3711
+ }
3712
+ function makeAuthBackend(namespaceId, pluginId) {
3713
+ return {
3714
+ async execute({ handlerRef, pluginRoot, input }) {
3715
+ const hostId = globalDispatcher.firstHostWithCapability(namespaceId, "execution");
3716
+ if (!hostId) {
3717
+ throw new Error("No execution host connected for custom auth");
3718
+ }
3719
+ return globalDispatcher.call(namespaceId, hostId, "execution", "execute", [
3720
+ { pluginId, handlerRef, pluginRoot, input }
3721
+ ]);
3722
+ }
3723
+ };
3724
+ }
3725
+
3726
+ // src/server.ts
2120
3727
  function redactQueryToken(url) {
2121
3728
  return url.replace(/([?&]access_token=)[^&]*/gi, "$1[REDACTED]");
2122
3729
  }
2123
- async function createServer(config, cache, logger, jwtConfig, registry) {
3730
+ async function createServer(config, cache, logger, jwtConfig, registry, serviceTransport, userAuth, webhookManifests) {
2124
3731
  const gatewayLogger = createCorrelatedLogger(logger, {
2125
3732
  serviceId: "gateway",
2126
3733
  logsSource: "gateway",
@@ -2129,8 +3736,11 @@ async function createServer(config, cache, logger, jwtConfig, registry) {
2129
3736
  operation: "gateway.http"
2130
3737
  });
2131
3738
  const app = Fastify({
2132
- logger: false
3739
+ logger: false,
3740
+ // CD-10: gateway sits behind nginx; parse X-Forwarded-For for real client IPs.
3741
+ trustProxy: true
2133
3742
  });
3743
+ await app.register(fastifyCookie);
2134
3744
  const isProduction = process.env.NODE_ENV === "production";
2135
3745
  await registerOpenAPI(app, {
2136
3746
  title: "KB Labs Gateway",
@@ -2142,6 +3752,11 @@ async function createServer(config, cache, logger, jwtConfig, registry) {
2142
3752
  await app.register(fastifyCors, { origin: false });
2143
3753
  const observability = new GatewayObservabilityCollector(config);
2144
3754
  observability.register(app);
3755
+ if (config.pressure && config.pressure.enabled !== false && platform.hasResourceBroker) {
3756
+ const deps = { broker: platform.resourceBroker, logger, config };
3757
+ app.addHook("onRequest", createPressureOnRequest(deps));
3758
+ app.addHook("onResponse", createPressureOnResponse());
3759
+ }
2145
3760
  app.addHook("onRequest", async (request, reply) => {
2146
3761
  const requestId = request.headers["x-request-id"] || request.id || randomUUID();
2147
3762
  const traceId = request.headers["x-trace-id"] || randomUUID();
@@ -2172,26 +3787,92 @@ async function createServer(config, cache, logger, jwtConfig, registry) {
2172
3787
  statusCode: reply.statusCode
2173
3788
  });
2174
3789
  });
2175
- const PROXY_TIMEOUT_MS = 36e5;
3790
+ const socketWsUpstreams = [];
2176
3791
  for (const [name, upstream] of Object.entries(config.upstreams)) {
3792
+ const conn = serviceTransport.connectionInfo(upstream.serviceId);
3793
+ if (!conn) {
3794
+ throw new Error(
3795
+ `Gateway startup error: no transport config for upstream "${name}" (serviceId: "${upstream.serviceId}"). Configure @kb-labs/adapters-service-transport-http as adapterOptions.serviceTransport.services in kb.config.json.`
3796
+ );
3797
+ }
3798
+ const wsOverSocket = Boolean(upstream.websocket) && Boolean(conn.socketPath);
3799
+ if (wsOverSocket) {
3800
+ socketWsUpstreams.push({
3801
+ prefix: upstream.prefix,
3802
+ rewritePrefix: upstream.rewritePrefix ?? upstream.prefix,
3803
+ socketPath: conn.socketPath
3804
+ });
3805
+ }
2177
3806
  await app.register(fastifyHttpProxy, {
2178
- upstream: upstream.url,
3807
+ upstream: conn.baseUrl,
2179
3808
  prefix: upstream.prefix,
2180
3809
  rewritePrefix: upstream.rewritePrefix ?? upstream.prefix,
2181
3810
  disableCache: true,
2182
- websocket: upstream.websocket ?? false,
2183
- http: {
2184
- requestOptions: {
2185
- timeout: PROXY_TIMEOUT_MS
2186
- }
3811
+ // Disable http-proxy WS for socket upstreams — the gateway dialer owns it.
3812
+ websocket: wsOverSocket ? false : upstream.websocket ?? false,
3813
+ undici: {
3814
+ // Restore 1-hour body timeout for SSE streams and large transfers.
3815
+ // undici defaults: headersTimeout=30s, bodyTimeout=300s — too short for streaming.
3816
+ bodyTimeout: 36e5,
3817
+ ...conn.socketPath ? { socketPath: conn.socketPath } : {}
2187
3818
  }
2188
3819
  });
2189
- gatewayLogger.info(`Upstream registered: ${name} \u2192 ${upstream.url} (${upstream.prefix}${upstream.websocket ? ", ws" : ""})`);
3820
+ const connDesc = conn.socketPath ? `${conn.baseUrl} (unix:${conn.socketPath})` : conn.baseUrl;
3821
+ const wsDesc = upstream.websocket ? wsOverSocket ? ", ws\u2192unix" : ", ws" : "";
3822
+ gatewayLogger.info(`Upstream registered: ${name} \u2192 ${connDesc} (${upstream.prefix}${wsDesc})`);
2190
3823
  }
2191
3824
  await app.register(async function gatewayRoutes(scope) {
2192
- scope.addHook("onRequest", createAuthMiddleware(cache, jwtConfig));
3825
+ if (userAuth) {
3826
+ scope.addHook(
3827
+ "onRequest",
3828
+ createUserAuthMiddleware({
3829
+ users: userAuth.users,
3830
+ tenantResolver: userAuth.tenantResolver,
3831
+ jwtConfig
3832
+ })
3833
+ );
3834
+ }
3835
+ scope.addHook(
3836
+ "onRequest",
3837
+ createAuthMiddleware(cache, jwtConfig, { authEnabled: config.auth?.enabled !== false })
3838
+ );
3839
+ if (config.pressure?.perTenant?.enabled === true && platform.hasResourceBroker) {
3840
+ scope.addHook(
3841
+ "preHandler",
3842
+ createPressurePreHandler({ broker: platform.resourceBroker, logger, config })
3843
+ );
3844
+ }
2193
3845
  const authService = new AuthService(cache, jwtConfig);
2194
- registerAuthRoutes(scope, authService);
3846
+ const userExt = userAuth ? {
3847
+ userRefreshFn: createUserRefreshFn({
3848
+ userAuthService: userAuth.userAuthService,
3849
+ cookieOpts: { cookieSecure: userAuth.cookieSecure }
3850
+ }),
3851
+ pdp: userAuth.pdp
3852
+ } : void 0;
3853
+ registerAuthRoutes(scope, authService, userExt);
3854
+ if (userAuth) {
3855
+ registerUserAuthRoutes(
3856
+ scope,
3857
+ {
3858
+ userAuthService: userAuth.userAuthService,
3859
+ users: userAuth.users,
3860
+ sessions: userAuth.sessions,
3861
+ invites: userAuth.invites,
3862
+ providers: userAuth.providers,
3863
+ pdp: userAuth.pdp,
3864
+ tenantResolver: userAuth.tenantResolver,
3865
+ cookieOpts: { cookieSecure: userAuth.cookieSecure },
3866
+ accessTtlSec: userAuth.accessTtlSec,
3867
+ refreshTtlSec: userAuth.refreshTtlSec,
3868
+ inviteTtlMs: userAuth.inviteTtlMs,
3869
+ rateLimiter: userAuth.rateLimiter,
3870
+ authRateLimit: userAuth.authRateLimit,
3871
+ oauthState: userAuth.oauthState,
3872
+ oauthCallbackPerIpPerMinute: userAuth.oauthCallbackPerIpPerMinute
3873
+ }
3874
+ );
3875
+ }
2195
3876
  const HEALTH_CACHE_KEY = "__gateway_health";
2196
3877
  const HEALTH_CACHE_TTL = 15e3;
2197
3878
  const startupTime = Date.now();
@@ -2219,7 +3900,8 @@ async function createServer(config, cache, logger, jwtConfig, registry) {
2219
3900
  await observability.observeOperation(`gateway.upstream.${name}.health`, async () => {
2220
3901
  const probeStart = Date.now();
2221
3902
  try {
2222
- const res = await fetch(`${upstream.url}/health`, {
3903
+ const res = await serviceTransport.call(upstream.serviceId, {
3904
+ path: "/health",
2223
3905
  signal: AbortSignal.timeout(2e3)
2224
3906
  });
2225
3907
  const latencyMs = Date.now() - probeStart;
@@ -2236,8 +3918,8 @@ async function createServer(config, cache, logger, jwtConfig, registry) {
2236
3918
  route: `${upstream.prefix}/health`,
2237
3919
  evidence: {
2238
3920
  upstreamId: name,
2239
- upstreamUrl: upstream.url,
2240
- statusCode: res.status,
3921
+ serviceId: upstream.serviceId,
3922
+ statusCode: res.statusCode,
2241
3923
  latencyMs
2242
3924
  }
2243
3925
  });
@@ -2257,7 +3939,7 @@ async function createServer(config, cache, logger, jwtConfig, registry) {
2257
3939
  route: `${upstream.prefix}/health`,
2258
3940
  evidence: {
2259
3941
  upstreamId: name,
2260
- upstreamUrl: upstream.url,
3942
+ serviceId: upstream.serviceId,
2261
3943
  latencyMs
2262
3944
  }
2263
3945
  });
@@ -2281,6 +3963,14 @@ async function createServer(config, cache, logger, jwtConfig, registry) {
2281
3963
  scope.get("/health", { schema: { tags: ["System"], summary: "Gateway health check" } }, async () => {
2282
3964
  return collectHealthSnapshot();
2283
3965
  });
3966
+ scope.get("/health/adapters", {
3967
+ schema: {
3968
+ tags: ["System"],
3969
+ summary: "Platform adapter status \u2014 mode (real | inmemory | noop) per slot"
3970
+ }
3971
+ }, async () => {
3972
+ return getAdapterStatus();
3973
+ });
2284
3974
  scope.get("/ready", { schema: { tags: ["System"], summary: "Gateway readiness check" } }, async (_request, reply) => {
2285
3975
  const health = await collectHealthSnapshot();
2286
3976
  const upstreams = health.upstreams ?? {};
@@ -2379,73 +4069,55 @@ async function createServer(config, cache, logger, jwtConfig, registry) {
2379
4069
  registerLLMGatewayRoutes(scope, logger);
2380
4070
  registerTelemetryRoutes(scope, logger);
2381
4071
  registerPlatformRoutes(scope, logger);
2382
- registerAggregatedDocsRoutes(scope, cache);
2383
- const internalSecret = process.env.GATEWAY_INTERNAL_SECRET;
2384
- scope.post("/internal/dispatch", async (request, reply) => {
2385
- const provided = request.headers["x-internal-secret"];
2386
- if (!internalSecret || provided !== internalSecret) {
2387
- return reply.code(403).send({ error: "Forbidden" });
2388
- }
2389
- const body = request.body;
2390
- if (!body.namespaceId || !body.adapter || !body.method) {
2391
- return reply.code(400).send({ error: "Missing required fields: namespaceId, adapter, method" });
2392
- }
2393
- const hostId = body.hostId ?? globalDispatcher.firstHostWithCapability(body.namespaceId, body.adapter) ?? globalDispatcher.firstHost(body.namespaceId);
2394
- if (!hostId) {
2395
- return reply.code(503).send({
2396
- error: "No host connected",
2397
- namespaceId: body.namespaceId
2398
- });
2399
- }
2400
- try {
2401
- const result = await globalDispatcher.call(
2402
- body.namespaceId,
2403
- hostId,
2404
- body.adapter,
2405
- body.method,
2406
- body.args ?? []
2407
- );
2408
- return { result };
2409
- } catch (err) {
2410
- const message = err instanceof Error ? err.message : String(err);
2411
- if (message.includes("Host not connected")) {
2412
- return reply.code(503).send({ error: message });
4072
+ registerAggregatedDocsRoutes(scope, config, serviceTransport, cache);
4073
+ registerInternalRoutes(scope, process.env.GATEWAY_INTERNAL_SECRET, hostRegistry, cache);
4074
+ if (platform.hasResourceBroker) {
4075
+ const webhookBaseUrl = process.env.GATEWAY_PUBLIC_URL ?? `http://localhost:${config.port + (Number(process.env.KB_NET_OFFSET) || 0)}`;
4076
+ const provisionBackend = {
4077
+ async execute({ handlerRef, pluginRoot, input, namespaceId }) {
4078
+ if (!namespaceId) {
4079
+ return;
4080
+ }
4081
+ const hostId = globalDispatcher.firstHostWithCapability(namespaceId, "execution");
4082
+ if (!hostId) {
4083
+ return;
4084
+ }
4085
+ return globalDispatcher.call(namespaceId, hostId, "execution", "execute", [
4086
+ { handlerRef, pluginRoot, input }
4087
+ ]);
2413
4088
  }
2414
- return reply.code(502).send({ error: message });
2415
- }
2416
- });
2417
- scope.post("/internal/resolve-host", async (request, reply) => {
2418
- const provided = request.headers["x-internal-secret"];
2419
- if (!internalSecret || provided !== internalSecret) {
2420
- return reply.code(403).send({ error: "Forbidden" });
2421
- }
2422
- const body = request.body;
2423
- const namespaceId = body.namespaceId ?? "default";
2424
- const target = body.target ?? {};
2425
- const strategy = target.hostSelection ?? "any-matching";
2426
- let hostId;
2427
- if (strategy === "pinned" && target.hostId) {
2428
- const host = await hostRegistry.get(target.hostId, namespaceId);
2429
- if (host?.status === "online" || host?.status === "reconnecting") {
2430
- hostId = target.hostId;
4089
+ };
4090
+ registerWebhookAdminRoutes(
4091
+ scope,
4092
+ {
4093
+ cache,
4094
+ logger,
4095
+ backend: provisionBackend,
4096
+ manifests: webhookManifests ?? [],
4097
+ baseUrl: webhookBaseUrl,
4098
+ broker: platform.resourceBroker
2431
4099
  }
2432
- } else {
2433
- hostId = globalDispatcher.firstHostWithCapability(namespaceId, "execution");
2434
- }
2435
- if (!hostId) {
2436
- return reply.code(404).send({ error: "No matching host found" });
2437
- }
2438
- return { hostId, strategy, namespaceId };
2439
- });
4100
+ );
4101
+ }
2440
4102
  });
4103
+ if (webhookManifests?.length && platform.hasResourceBroker) {
4104
+ process.env.GATEWAY_PUBLIC_URL ?? `http://localhost:${config.port + (Number(process.env.KB_NET_OFFSET) || 0)}`;
4105
+ await app.register(async (webhookScope) => {
4106
+ await registerWebhookRoutes(webhookScope, {
4107
+ cache,
4108
+ broker: platform.resourceBroker,
4109
+ logger,
4110
+ manifests: webhookManifests});
4111
+ });
4112
+ }
2441
4113
  await app.ready();
2442
- attachGatewayWs(app.server, cache, jwtConfig, logger, registry);
4114
+ attachGatewayWs(app.server, cache, jwtConfig, logger, registry, socketWsUpstreams);
2443
4115
  return app;
2444
4116
  }
2445
4117
 
2446
4118
  // src/bootstrap.ts
2447
4119
  async function bootstrap(repoRoot = process.cwd()) {
2448
- await createServiceBootstrap({ appId: "gateway", repoRoot });
4120
+ await createServiceBootstrap({ appId: "gateway", repoRoot, assemblyHook: makeAssemblyHook() });
2449
4121
  const logger = createCorrelatedLogger(platform.logger, {
2450
4122
  serviceId: "gateway",
2451
4123
  logsSource: "gateway",
@@ -2454,20 +4126,123 @@ async function bootstrap(repoRoot = process.cwd()) {
2454
4126
  operation: "gateway.bootstrap"
2455
4127
  });
2456
4128
  logger.info("Platform initialized", { repoRoot });
2457
- const config = await loadGatewayConfig(repoRoot, getPlatformRoot());
4129
+ const config = await loadGatewayConfig(getProjectRoot() ?? repoRoot, getPlatformRoot());
2458
4130
  logger.info("Gateway config loaded", {
2459
4131
  port: config.port,
2460
- upstreams: Object.keys(config.upstreams)
4132
+ upstreams: Object.keys(config.upstreams),
4133
+ projectRoot: getProjectRoot() ?? repoRoot,
4134
+ platformRoot: getPlatformRoot(),
4135
+ configProviderIds: config.auth?.providers ? Object.keys(config.auth.providers) : []
2461
4136
  });
2462
4137
  let hostStore;
2463
- const db = platform.getAdapter("sqlDatabase");
2464
- if (db) {
2465
- hostStore = new SqliteHostStore(db);
2466
- logger.info("Host store: SQLite (persistent)");
4138
+ const configuredDocs = platform.getAdapter("documentDatabase");
4139
+ const docs = configuredDocs ?? createInMemoryDocumentDatabase();
4140
+ if (configuredDocs) {
4141
+ hostStore = new HostStore(docs);
4142
+ logger.info("Host store: documentDatabase-backed (persistent)");
2467
4143
  } else {
2468
- logger.warn("Host store: none (cache-only, hosts will be lost on restart)");
4144
+ hostStore = new HostStore(docs);
4145
+ logger.warn("documentDatabase: in-memory fallback (data lost on restart)");
4146
+ logger.warn("Host store: in-memory (hosts will be lost on restart)");
2469
4147
  }
2470
- const registry = new HostRegistry(platform.cache, hostStore);
4148
+ let userAuth;
4149
+ {
4150
+ const accessTtlSec = config.auth?.sessionAccessTtlSec ?? (process.env.AUTH_ACCESS_TTL_SEC ? parseInt(process.env.AUTH_ACCESS_TTL_SEC, 10) : 900);
4151
+ const refreshTtlSec = config.auth?.sessionRefreshTtlSec ?? (process.env.AUTH_REFRESH_TTL_SEC ? parseInt(process.env.AUTH_REFRESH_TTL_SEC, 10) : 30 * 24 * 3600);
4152
+ const graceWindowMs = (config.auth?.refreshGraceWindowSec ?? 5) * 1e3;
4153
+ const bcryptCost = config.auth?.bcryptCost ?? 12;
4154
+ const cookieSecure = config.auth?.cookieSecure ?? (process.env.AUTH_COOKIE_SECURE === "false" ? false : true);
4155
+ const tenantPattern = config.tenants?.pattern ?? "{tenant}.kblabs.ru";
4156
+ const bootstrapTenantId = config.auth?.bootstrap?.tenantId ?? process.env.GATEWAY_BOOTSTRAP_TENANT_ID ?? "kblabs-cloud";
4157
+ const users = new UsersStore(docs);
4158
+ const credentials = new CredentialsStore(docs);
4159
+ const memberships = new MembershipsStore(docs);
4160
+ const sessions = new SessionsStore(docs, {
4161
+ refreshTtlMs: refreshTtlSec * 1e3,
4162
+ graceWindowMs
4163
+ });
4164
+ const invites = new InvitesStore(docs);
4165
+ const providers = await loadIdentityProviders(config.auth?.providers, {
4166
+ users,
4167
+ credentials,
4168
+ tenantId: bootstrapTenantId,
4169
+ bcryptCost,
4170
+ logger
4171
+ });
4172
+ const passwordPolicy = createPasswordPolicy({
4173
+ minLength: config.auth?.passwordPolicy?.minLength ?? 8,
4174
+ maxLength: config.auth?.passwordPolicy?.maxLength ?? 256,
4175
+ hibpEnabled: config.auth?.passwordPolicy?.hibpEnabled ?? true
4176
+ });
4177
+ const pdp = createStubPDP({ memberships });
4178
+ const tenantResolver = createTenantResolver({ pattern: tenantPattern });
4179
+ const userAuthService = createUserAuthService({
4180
+ users,
4181
+ credentials,
4182
+ memberships,
4183
+ sessions,
4184
+ invites,
4185
+ providers,
4186
+ passwordPolicy,
4187
+ jwtConfig: { secret: process.env.GATEWAY_JWT_SECRET ?? "dev-insecure-secret-change-me" },
4188
+ accessTtlSec,
4189
+ refreshTtlSec,
4190
+ bcryptCost
4191
+ });
4192
+ const adminEmail = config.auth?.bootstrap?.adminEmail ?? process.env.GATEWAY_BOOTSTRAP_ADMIN_EMAIL;
4193
+ const adminPassword = process.env.GATEWAY_BOOTSTRAP_ADMIN_PASSWORD;
4194
+ await ensureBootstrapAdmin({
4195
+ bootstrap: adminEmail && adminPassword ? { adminEmail, adminPassword, tenantId: bootstrapTenantId } : void 0,
4196
+ users,
4197
+ credentials,
4198
+ memberships,
4199
+ bcryptCost,
4200
+ logger
4201
+ }).catch((err) => {
4202
+ logger.warn("Bootstrap admin seed failed (non-fatal)", {
4203
+ error: err instanceof Error ? err.message : String(err)
4204
+ });
4205
+ });
4206
+ const inviteTtlMs = process.env.AUTH_INVITE_TTL_MS ? parseInt(process.env.AUTH_INVITE_TTL_MS, 10) : config.auth?.inviteTtlMs ?? 7 * 24 * 60 * 60 * 1e3;
4207
+ const kv = platform.getAdapter("kvStore") ?? createInMemoryKVStore();
4208
+ const rateLimiter = createRateLimiter(kv);
4209
+ if (!platform.getAdapter("kvStore")) {
4210
+ logger.warn("kvStore adapter not configured \u2014 auth rate limiting using in-memory KV (counters reset on restart)");
4211
+ }
4212
+ const loginPerIpPerMinute = process.env.AUTH_LOGIN_RATE_LIMIT_PER_IP ? parseInt(process.env.AUTH_LOGIN_RATE_LIMIT_PER_IP, 10) : config.auth?.rateLimit?.loginPerIpPerMinute ?? 10;
4213
+ const loginPerEmailPerMinute = process.env.AUTH_LOGIN_RATE_LIMIT_PER_EMAIL ? parseInt(process.env.AUTH_LOGIN_RATE_LIMIT_PER_EMAIL, 10) : config.auth?.rateLimit?.loginPerEmailPerMinute ?? 5;
4214
+ const oauthState = new OAuthStateStore(kv);
4215
+ const hasRedirectProvider = providers.list().some((p) => p.kind === "redirect");
4216
+ if (hasRedirectProvider && !platform.getAdapter("kvStore")) {
4217
+ logger.warn(
4218
+ "OAuth requires a shared kvStore (Redis) in multi-process/HA; in-memory state is per-process and callbacks may land on a different worker"
4219
+ );
4220
+ }
4221
+ userAuth = {
4222
+ userAuthService,
4223
+ users,
4224
+ sessions,
4225
+ invites,
4226
+ providers,
4227
+ pdp,
4228
+ tenantResolver,
4229
+ cookieSecure,
4230
+ accessTtlSec,
4231
+ refreshTtlSec,
4232
+ inviteTtlMs,
4233
+ rateLimiter,
4234
+ authRateLimit: { loginPerIpPerMinute, loginPerEmailPerMinute },
4235
+ oauthState
4236
+ };
4237
+ logger.info("User auth infrastructure initialised", {
4238
+ tenantPattern,
4239
+ bootstrapTenantId,
4240
+ cookieSecure,
4241
+ persistent: !!configuredDocs
4242
+ });
4243
+ }
4244
+ const cache = platform.cache;
4245
+ const registry = new HostRegistry(cache, hostStore);
2471
4246
  let restoredCount = 0;
2472
4247
  try {
2473
4248
  restoredCount = await registry.restore();
@@ -2489,9 +4264,29 @@ async function bootstrap(repoRoot = process.cwd()) {
2489
4264
  if (restoredCount > 0) {
2490
4265
  logger.info("Restored hosts from store", { count: restoredCount });
2491
4266
  }
2492
- for (const [token, entry] of Object.entries(config.staticTokens)) {
2493
- await platform.cache.set(`host:token:${token}`, entry);
2494
- logger.info("Static token seeded", { hostId: entry.hostId, namespaceId: entry.namespaceId });
4267
+ let webhookManifests = [];
4268
+ try {
4269
+ const projectRoot = getProjectRoot() ?? repoRoot;
4270
+ const platformRoot = getPlatformRoot();
4271
+ const pluginRegistry = await createRegistry({
4272
+ root: projectRoot,
4273
+ platformRoot: platformRoot !== projectRoot ? platformRoot : void 0,
4274
+ cache: { ttlMs: 6e5, adapter: cache }
4275
+ });
4276
+ const snapshot = pluginRegistry.snapshot();
4277
+ webhookManifests = snapshot.manifests.filter((entry) => (entry.manifest.webhooks?.handlers?.length ?? 0) > 0).map((entry) => ({
4278
+ pluginId: entry.pluginId,
4279
+ manifest: entry.manifest,
4280
+ pluginRoot: entry.pluginRoot
4281
+ }));
4282
+ if (webhookManifests.length > 0) {
4283
+ logger.info("Webhook manifests discovered", { count: webhookManifests.length });
4284
+ }
4285
+ } catch (err) {
4286
+ logger.warn("Webhook manifest discovery failed \u2014 webhook routes disabled", {
4287
+ error: err instanceof Error ? err.message : String(err)
4288
+ });
4289
+ webhookManifests = [];
2495
4290
  }
2496
4291
  const DEV_JWT_SECRET = "dev-insecure-secret-change-me";
2497
4292
  const jwtSecret = process.env.GATEWAY_JWT_SECRET;
@@ -2505,9 +4300,28 @@ async function bootstrap(repoRoot = process.cwd()) {
2505
4300
  logger.warn("GATEWAY_JWT_SECRET not set \u2014 using insecure default (dev only, never use in production!)");
2506
4301
  }
2507
4302
  const jwtConfig = { secret: jwtSecret ?? DEV_JWT_SECRET };
2508
- const server = await createServer(config, platform.cache, platform.logger, jwtConfig, registry);
2509
- const address = await server.listen({ port: config.port, host: "0.0.0.0" });
2510
- logger.info("Gateway listening", { address });
4303
+ if (platform.hasResourceBroker) {
4304
+ registerPressureLimits(platform.resourceBroker, config.pressure, platform.logger);
4305
+ } else {
4306
+ logger.warn("Resource broker unavailable \u2014 pressure control disabled");
4307
+ }
4308
+ const serviceTransport = platform.getAdapter("serviceTransport");
4309
+ if (!serviceTransport) {
4310
+ throw new Error(
4311
+ 'Gateway requires the serviceTransport adapter. Configure it in kb.config.json:\n "adapters": { "serviceTransport": "@kb-labs/adapters-service-transport-http" },\n "adapterOptions": { "serviceTransport": { "services": { "rest": { "url": "http://127.0.0.1:5050" }, ... } } }'
4312
+ );
4313
+ }
4314
+ const server = await createServer(config, cache, platform.logger, jwtConfig, registry, serviceTransport, userAuth, webhookManifests);
4315
+ const bindHost = config.host ?? "0.0.0.0";
4316
+ if (config.auth?.enabled === false && !isLoopbackHost(bindHost)) {
4317
+ throw new Error(
4318
+ `Refusing to start: auth is disabled but the gateway binds to "${bindHost}" (not loopback). A no-auth platform reachable on the network grants full access to anyone. Either set gateway.auth.enabled = true, or bind to 127.0.0.1 (gateway.host) for solo/local use.`
4319
+ );
4320
+ }
4321
+ const netOffset = Number(process.env.KB_NET_OFFSET) || 0;
4322
+ const listenPort = config.port + netOffset;
4323
+ const address = await server.listen({ port: listenPort, host: bindHost });
4324
+ logger.info("Gateway listening", { address, authEnabled: config.auth?.enabled !== false });
2511
4325
  const shutdown = async (signal) => {
2512
4326
  logger.warn("Received shutdown signal", { signal });
2513
4327
  await platform.shutdown();
@@ -2518,6 +4332,10 @@ async function bootstrap(repoRoot = process.cwd()) {
2518
4332
  process.on("SIGTERM", () => shutdown("SIGTERM"));
2519
4333
  process.on("SIGINT", () => shutdown("SIGINT"));
2520
4334
  }
4335
+ function isLoopbackHost(host) {
4336
+ const h = host.trim().toLowerCase();
4337
+ return h === "127.0.0.1" || h === "localhost" || h === "::1" || h === "[::1]" || h.startsWith("127.");
4338
+ }
2521
4339
 
2522
4340
  // src/index.ts
2523
4341
  bootstrap(process.cwd()).catch((error) => {