@kb-labs/gateway-app 2.94.0 → 2.96.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,620 @@ 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({ userId: auth.userId, namespaceId: auth.namespaceId });
497
+ });
498
+ app.get("/auth/providers", {
499
+ schema: { tags: ["Auth"], summary: "List registered identity providers" }
500
+ }, async (_request, reply) => {
501
+ return reply.send({ providers: providers.list() });
502
+ });
503
+ app.post("/auth/login", {
504
+ schema: { tags: ["Auth"], summary: "Login with email/password (sets session cookies)" }
505
+ }, async (request, reply) => {
506
+ const { email, password, providerId, tenantId: bodyTenantId } = request.body ?? {};
507
+ if (typeof email !== "string" || !email) {
508
+ return reply.code(400).send({ error: "Bad Request", message: "email is required" });
509
+ }
510
+ if (typeof password !== "string" || !password) {
511
+ return reply.code(400).send({ error: "Bad Request", message: "password is required" });
512
+ }
513
+ const host = typeof request.headers.host === "string" ? request.headers.host : "";
514
+ const hostTenant = tenantResolver.resolve(host);
515
+ const tenantId = hostTenant ?? bodyTenantId ?? "";
516
+ if (rateLimiter) {
517
+ const emailNorm = email.toLowerCase().trim();
518
+ const [ipPeek, emailPeek] = await Promise.all([
519
+ rateLimiter.peek(`rl:login:ip:${request.ip}`, { max: authRateLimitCfg.loginPerIpPerMinute, windowMs: 6e4 }),
520
+ rateLimiter.peek(`rl:login:email:${emailNorm}`, { max: authRateLimitCfg.loginPerEmailPerMinute, windowMs: 6e4 })
521
+ ]);
522
+ if (!ipPeek.allowed || !emailPeek.allowed) {
523
+ const retryAfter = !ipPeek.allowed ? ipPeek.retryAfterSec : !emailPeek.allowed ? emailPeek.retryAfterSec : 60;
524
+ reply.header("Retry-After", String(retryAfter));
525
+ return reply.code(429).send({ error: "Too Many Requests", message: "Rate limit exceeded", retryAfterSec: retryAfter });
526
+ }
527
+ }
528
+ try {
529
+ const result = await userAuthService.login(
530
+ { providerId: providerId ?? "email-password", input: { email, password } },
531
+ tenantId,
532
+ { ip: request.ip, userAgent: request.headers["user-agent"] }
533
+ );
534
+ setSessionCookies(reply, {
535
+ accessToken: result.access.token,
536
+ accessTtlSec: accessTtl2(result),
537
+ refreshToken: result.refresh.token,
538
+ refreshTtlSec: refreshTtl2(result),
539
+ csrfToken: result.csrf
540
+ }, cookieOpts);
541
+ return reply.send({ ok: true });
542
+ } catch (err) {
543
+ if (err instanceof AuthError) {
544
+ if (rateLimiter) {
545
+ const perIpMax = authRateLimitCfg.loginPerIpPerMinute;
546
+ const perEmailMax = authRateLimitCfg.loginPerEmailPerMinute;
547
+ const emailNorm = email.toLowerCase().trim();
548
+ const [ipResult, emailResult] = await Promise.all([
549
+ rateLimiter.check(`rl:login:ip:${request.ip}`, { max: perIpMax, windowMs: 6e4 }),
550
+ rateLimiter.check(`rl:login:email:${emailNorm}`, { max: perEmailMax, windowMs: 6e4 })
551
+ ]);
552
+ if (!ipResult.allowed || !emailResult.allowed) {
553
+ const retryAfter = !ipResult.allowed ? ipResult.retryAfterSec : !emailResult.allowed ? emailResult.retryAfterSec : 60;
554
+ reply.header("Retry-After", String(retryAfter));
555
+ return reply.code(429).send({ error: "Too Many Requests", message: "Rate limit exceeded", retryAfterSec: retryAfter });
556
+ }
557
+ }
558
+ return reply.code(401).send({ error: "invalid_credentials" });
559
+ }
560
+ throw err;
561
+ }
562
+ });
563
+ app.post("/auth/logout", {
564
+ schema: { tags: ["Auth"], summary: "Logout and clear session cookies" }
565
+ }, async (request, reply) => {
566
+ const ctx = requireUser(request, reply);
567
+ if (!ctx) {
568
+ return;
569
+ }
570
+ if (!checkCsrf(request, reply)) {
571
+ return;
572
+ }
573
+ const refreshCookie = getCookies2(request)[COOKIE_REFRESH];
574
+ if (refreshCookie) {
575
+ await userAuthService.logout(refreshCookie).catch(() => {
576
+ });
577
+ }
578
+ clearSessionCookies(reply, cookieOpts);
579
+ return reply.send({ ok: true });
580
+ });
581
+ app.get("/auth/permissions", {
582
+ schema: { tags: ["Auth"], summary: "List permissions for the authenticated user" }
583
+ }, async (request, reply) => {
584
+ if (request.userAuthContext) {
585
+ const ctx = request.userAuthContext;
586
+ const permissions = await pdp.enumeratePermissions({
587
+ userId: ctx.userId,
588
+ tenantId: ctx.tenantId,
589
+ type: "user"
590
+ });
591
+ return reply.send({ permissions });
592
+ }
593
+ const auth = request.authContext;
594
+ if (!auth) {
595
+ return reply.code(401).send({ error: "Unauthorized", message: "No active session" });
596
+ }
597
+ return reply.send({ permissions: auth.permissions });
598
+ });
599
+ app.post("/auth/activate", {
600
+ schema: { tags: ["Auth"], summary: "Activate invite and create account (auto-login)" }
601
+ }, async (request, reply) => {
602
+ const { token, password } = request.body ?? {};
603
+ if (typeof token !== "string" || !token) {
604
+ return reply.code(400).send({ error: "Bad Request", message: "token is required" });
605
+ }
606
+ if (typeof password !== "string" || !password) {
607
+ return reply.code(400).send({ error: "Bad Request", message: "password is required" });
608
+ }
609
+ try {
610
+ const result = await userAuthService.activate({
611
+ activationToken: token,
612
+ password,
613
+ deviceCtx: {
614
+ ip: request.ip,
615
+ userAgent: request.headers["user-agent"]
616
+ }
617
+ });
618
+ setSessionCookies(reply, {
619
+ accessToken: result.access.token,
620
+ accessTtlSec: accessTtl2(result),
621
+ refreshToken: result.refresh.token,
622
+ refreshTtlSec: refreshTtl2(result),
623
+ csrfToken: result.csrf
624
+ }, cookieOpts);
625
+ return reply.send({ ok: true });
626
+ } catch (err) {
627
+ if (err instanceof AuthError) {
628
+ const code = err.code;
629
+ if (code === "unknown_invite") {
630
+ return reply.code(401).send({ error: "invalid_invite", message: "Invalid or expired invite" });
631
+ }
632
+ if (code === "invalid_invite") {
633
+ return reply.code(422).send({ error: "invalid_invite", message: "Invalid or expired invite" });
634
+ }
635
+ return reply.code(400).send({ error: "Bad Request", message: err.message });
636
+ }
637
+ throw err;
638
+ }
639
+ });
640
+ app.post("/auth/password/change", {
641
+ schema: { tags: ["Auth"], summary: "Change password (revokes all other sessions)" }
642
+ }, async (request, reply) => {
643
+ const ctx = requireUser(request, reply);
644
+ if (!ctx) {
645
+ return;
646
+ }
647
+ if (!checkCsrf(request, reply)) {
648
+ return;
649
+ }
650
+ const { currentPassword, newPassword } = request.body ?? {};
651
+ if (!currentPassword || !newPassword) {
652
+ return reply.code(400).send({
653
+ error: "Bad Request",
654
+ message: "currentPassword and newPassword are required"
655
+ });
656
+ }
657
+ try {
658
+ await userAuthService.changePassword({
659
+ userId: ctx.userId,
660
+ currentFamilyId: ctx.familyId,
661
+ currentPassword,
662
+ newPassword
663
+ });
664
+ return reply.send({ ok: true });
665
+ } catch (err) {
666
+ if (err instanceof AuthError) {
667
+ const code = err.code;
668
+ if (code === "invalid_current_password") {
669
+ return reply.code(400).send({ error: "Bad Request", message: "Current password is incorrect" });
670
+ }
671
+ if (code === "weak_password") {
672
+ return reply.code(400).send({ error: "Bad Request", message: err.message });
673
+ }
674
+ }
675
+ throw err;
676
+ }
677
+ });
678
+ app.get("/auth/sessions", {
679
+ schema: { tags: ["Auth"], summary: "List active sessions for the authenticated user" }
680
+ }, async (request, reply) => {
681
+ const ctx = requireUser(request, reply);
682
+ if (!ctx) {
683
+ return;
684
+ }
685
+ const families = await sessions.listFamiliesByUser(ctx.userId);
686
+ return reply.send({
687
+ sessions: families.map((f) => ({
688
+ familyId: f.familyId,
689
+ createdAt: f.createdAt,
690
+ lastUsedAt: f.lastUsedAt,
691
+ userAgent: f.userAgent,
692
+ ipFirst: f.ipFirst,
693
+ isCurrent: f.familyId === ctx.familyId
694
+ }))
695
+ });
696
+ });
697
+ app.post("/auth/sessions/revoke-all", {
698
+ schema: { tags: ["Auth"], summary: "Revoke all sessions except the current one" }
699
+ }, async (request, reply) => {
700
+ const ctx = requireUser(request, reply);
701
+ if (!ctx) {
702
+ return;
703
+ }
704
+ if (!checkCsrf(request, reply)) {
705
+ return;
706
+ }
707
+ await sessions.revokeAllUserSessionsExcept(ctx.userId, ctx.familyId);
708
+ return reply.send({ ok: true });
709
+ });
710
+ app.post("/auth/sessions/:fam/revoke", {
711
+ schema: { tags: ["Auth"], summary: "Revoke a specific session family" }
712
+ }, async (request, reply) => {
713
+ const ctx = requireUser(request, reply);
714
+ if (!ctx) {
715
+ return;
716
+ }
717
+ if (!checkCsrf(request, reply)) {
718
+ return;
719
+ }
720
+ const { fam } = request.params;
721
+ const ownFamilies = await sessions.listFamiliesByUser(ctx.userId);
722
+ if (!ownFamilies.some((f) => f.familyId === fam)) {
723
+ return reply.code(404).send({ error: "Not found", message: "Session not found" });
724
+ }
725
+ await sessions.revokeFamily(fam);
726
+ return reply.send({ ok: true });
727
+ });
728
+ async function requirePermission(request, reply, permission) {
729
+ const ctx = requireUser(request, reply);
730
+ if (!ctx) {
731
+ return false;
732
+ }
733
+ const decision = await pdp.check(
734
+ { userId: ctx.userId, tenantId: ctx.tenantId, type: "user" },
735
+ permission
736
+ );
737
+ if (!decision.allow) {
738
+ void reply.code(403).send({ error: "Forbidden", message: `Permission denied: ${permission}` });
739
+ return false;
740
+ }
741
+ return true;
742
+ }
743
+ app.post("/auth/invites", {
744
+ schema: { tags: ["Auth"], summary: "Create an invite for a new user (admin only)" }
745
+ }, async (request, reply) => {
746
+ if (!await requirePermission(request, reply, PERMISSIONS.INVITES_WRITE)) {
747
+ return;
748
+ }
749
+ if (!checkCsrf(request, reply)) {
750
+ return;
751
+ }
752
+ const ctx = request.userAuthContext;
753
+ const { email, groupId, ttlMs: bodyTtlMs } = request.body ?? {};
754
+ if (!email || !groupId) {
755
+ return reply.code(400).send({ error: "Bad Request", message: "email and groupId are required" });
756
+ }
757
+ const effectiveTtlMs = typeof bodyTtlMs === "number" && bodyTtlMs > 0 ? bodyTtlMs : inviteTtlMs;
758
+ const result = await invites.createInvite({
759
+ email,
760
+ tenantId: ctx.tenantId,
761
+ groupId,
762
+ createdBy: ctx.userId,
763
+ ttlMs: effectiveTtlMs
764
+ });
765
+ const host = typeof request.headers.host === "string" ? request.headers.host : "localhost";
766
+ const protocol = cookieOpts.cookieSecure ? "https" : "http";
767
+ const activationUrl = `${protocol}://${host}/activate?token=${result.activationToken}`;
768
+ return reply.code(201).send({ inviteId: result.inviteId, activationUrl });
769
+ });
770
+ app.post("/auth/invites/:id/revoke", {
771
+ schema: { tags: ["Auth"], summary: "Revoke a pending invite (admin only)" }
772
+ }, async (request, reply) => {
773
+ if (!await requirePermission(request, reply, PERMISSIONS.INVITES_WRITE)) {
774
+ return;
775
+ }
776
+ if (!checkCsrf(request, reply)) {
777
+ return;
778
+ }
779
+ const ctx = request.userAuthContext;
780
+ const invite = await invites.findById(request.params.id);
781
+ if (!invite || invite.tenantId !== ctx.tenantId) {
782
+ return reply.code(404).send({ error: "Not found", message: "Invite not found" });
783
+ }
784
+ await invites.revoke(request.params.id);
785
+ return reply.send({ ok: true });
786
+ });
787
+ app.get("/auth/invites", {
788
+ schema: { tags: ["Auth"], summary: "List invites for the tenant (admin only)" }
789
+ }, async (request, reply) => {
790
+ if (!await requirePermission(request, reply, PERMISSIONS.INVITES_READ)) {
791
+ return;
792
+ }
793
+ const ctx = request.userAuthContext;
794
+ const all = await invites.listByTenant(ctx.tenantId);
795
+ return reply.send({ invites: all });
796
+ });
797
+ app.get("/auth/users", {
798
+ schema: { tags: ["Auth"], summary: "List users for the tenant (admin only)" }
799
+ }, async (request, reply) => {
800
+ if (!await requirePermission(request, reply, PERMISSIONS.USERS_READ)) {
801
+ return;
802
+ }
803
+ const ctx = request.userAuthContext;
804
+ const all = await users.listByTenant(ctx.tenantId);
805
+ return reply.send({
806
+ users: all.map((u) => ({
807
+ userId: u.userId,
808
+ email: u.email,
809
+ tenantId: u.tenantId,
810
+ status: u.status
811
+ }))
812
+ });
813
+ });
814
+ app.post("/auth/users/:id/disable", {
815
+ schema: { tags: ["Auth"], summary: "Disable a user account (immediate via CD-1)" }
816
+ }, async (request, reply) => {
817
+ if (!await requirePermission(request, reply, PERMISSIONS.USERS_WRITE)) {
818
+ return;
819
+ }
820
+ if (!checkCsrf(request, reply)) {
821
+ return;
822
+ }
823
+ const ctx = request.userAuthContext;
824
+ const { id } = request.params;
825
+ const target = await users.getById(id);
826
+ if (!target || target.tenantId !== ctx.tenantId) {
827
+ return reply.code(404).send({ error: "Not found", message: "User not found" });
828
+ }
829
+ await users.setStatus(id, "disabled");
830
+ await sessions.revokeAllUserSessions(id);
831
+ return reply.send({ ok: true });
832
+ });
833
+ app.post("/auth/users/:id/enable", {
834
+ schema: { tags: ["Auth"], summary: "Re-enable a disabled user account" }
835
+ }, async (request, reply) => {
836
+ if (!await requirePermission(request, reply, PERMISSIONS.USERS_WRITE)) {
837
+ return;
838
+ }
839
+ if (!checkCsrf(request, reply)) {
840
+ return;
841
+ }
842
+ const ctx = request.userAuthContext;
843
+ const { id } = request.params;
844
+ const target = await users.getById(id);
845
+ if (!target || target.tenantId !== ctx.tenantId) {
846
+ return reply.code(404).send({ error: "Not found", message: "User not found" });
847
+ }
848
+ await users.setStatus(id, "active");
849
+ return reply.send({ ok: true });
850
+ });
851
+ }
151
852
 
152
853
  // src/execute/execution-registry.ts
153
854
  var ExecutionRegistry = class {
@@ -900,8 +1601,36 @@ var ALLOWED_METHODS = {
900
1601
  embeddings: /* @__PURE__ */ new Set(["embed"]),
901
1602
  storage: /* @__PURE__ */ new Set(["read", "write", "delete", "list", "exists"]),
902
1603
  eventBus: /* @__PURE__ */ new Set(["publish", "subscribe"]),
903
- sqlDatabase: /* @__PURE__ */ new Set(["query", "execute"]),
904
- documentDatabase: /* @__PURE__ */ new Set(["find", "findOne", "insert", "update", "delete"])
1604
+ documentDatabase: /* @__PURE__ */ new Set([
1605
+ "find",
1606
+ "findById",
1607
+ "count",
1608
+ "insertOne",
1609
+ "insertMany",
1610
+ "updateOne",
1611
+ "updateMany",
1612
+ "updateById",
1613
+ "deleteMany",
1614
+ "deleteById",
1615
+ "bulkWrite",
1616
+ "ensureCollection",
1617
+ "ping"
1618
+ ]),
1619
+ kvStore: /* @__PURE__ */ new Set([
1620
+ "get",
1621
+ "getMany",
1622
+ "set",
1623
+ "setMany",
1624
+ "setIfNotExists",
1625
+ "delete",
1626
+ "exists",
1627
+ "cas",
1628
+ "incr",
1629
+ "ttl",
1630
+ "expire",
1631
+ "persist",
1632
+ "ping"
1633
+ ])
905
1634
  };
906
1635
  function resolveAdapter(name) {
907
1636
  const adapterMap = {
@@ -912,8 +1641,8 @@ function resolveAdapter(name) {
912
1641
  embeddings: () => platform.embeddings,
913
1642
  storage: () => platform.storage,
914
1643
  eventBus: () => platform.eventBus,
915
- sqlDatabase: () => platform.sqlDatabase,
916
- documentDatabase: () => platform.documentDatabase
1644
+ documentDatabase: () => platform.documentDatabase,
1645
+ kvStore: () => platform.kvStore
917
1646
  };
918
1647
  const getter = adapterMap[name];
919
1648
  return getter ? getter() : void 0;
@@ -1027,11 +1756,8 @@ function registerPlatformRoutes(app, logger) {
1027
1756
  }
1028
1757
  var MERGED_CACHE_KEY = "__gateway_merged_openapi";
1029
1758
  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) {
1759
+ function registerAggregatedDocsRoutes(app, config, serviceTransport, cache) {
1760
+ const upstreamServiceIds = Object.values(config.upstreams).map((u) => u.serviceId);
1035
1761
  app.get("/openapi-merged.json", async (_req, reply) => {
1036
1762
  if (cache) {
1037
1763
  try {
@@ -1043,8 +1769,11 @@ function registerAggregatedDocsRoutes(app, cache) {
1043
1769
  }
1044
1770
  }
1045
1771
  const results = await Promise.allSettled(
1046
- UPSTREAM_SPEC_URLS.map(
1047
- (url) => fetch(url, { signal: AbortSignal.timeout(3e3) }).then((r) => r.json())
1772
+ upstreamServiceIds.map(
1773
+ (serviceId) => serviceTransport.call(serviceId, {
1774
+ path: "/openapi.json",
1775
+ signal: AbortSignal.timeout(3e3)
1776
+ }).then((r) => r.payload)
1048
1777
  )
1049
1778
  );
1050
1779
  const specs = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
@@ -1095,8 +1824,8 @@ var HostRegistry = class {
1095
1824
  const hosts = await this.store.listAll();
1096
1825
  for (const host of hosts) {
1097
1826
  const offline = { ...host, status: "offline", connections: [] };
1098
- const cacheKey = this.hostKey(host.namespaceId, host.hostId);
1099
- await this.cache.set(cacheKey, offline);
1827
+ const cacheKey2 = this.hostKey(host.namespaceId, host.hostId);
1828
+ await this.cache.set(cacheKey2, offline);
1100
1829
  await this.store.save(offline);
1101
1830
  await this.addToIndex(host.namespaceId, host.hostId);
1102
1831
  }
@@ -1268,10 +1997,16 @@ var HostRegistry = class {
1268
1997
  return results.filter((h) => h !== null);
1269
1998
  }
1270
1999
  async deregister(hostId, namespaceId) {
1271
- const deleted = this.store ? await this.store.delete(hostId, namespaceId) : false;
2000
+ if (this.store) {
2001
+ const deleted = await this.store.delete(hostId, namespaceId);
2002
+ await this.cache.delete(this.hostKey(namespaceId, hostId));
2003
+ await this.removeFromIndex(namespaceId, hostId);
2004
+ return deleted;
2005
+ }
2006
+ const exists = !!await this.cache.get(this.hostKey(namespaceId, hostId));
1272
2007
  await this.cache.delete(this.hostKey(namespaceId, hostId));
1273
2008
  await this.removeFromIndex(namespaceId, hostId);
1274
- return deleted;
2009
+ return exists;
1275
2010
  }
1276
2011
  async ensureRegistered(hostId, namespaceId, name, capabilities = []) {
1277
2012
  const existing = await this.get(hostId, namespaceId);
@@ -1356,8 +2091,29 @@ function createWsHandler(cache, jwtConfig, logger, hostRegistry) {
1356
2091
  socket.close(1008, "Missing Authorization header");
1357
2092
  return;
1358
2093
  }
2094
+ let resolveHello;
2095
+ let rejectHello;
2096
+ const helloRawPromise = new Promise((res, rej) => {
2097
+ resolveHello = res;
2098
+ rejectHello = rej;
2099
+ });
2100
+ helloRawPromise.catch(() => {
2101
+ });
2102
+ const helloTimeout = setTimeout(() => {
2103
+ socket.close(1008, "Hello timeout");
2104
+ rejectHello(new Error("Hello timeout"));
2105
+ }, HELLO_TIMEOUT_MS);
2106
+ socket.once("message", (raw) => {
2107
+ clearTimeout(helloTimeout);
2108
+ resolveHello(raw);
2109
+ });
2110
+ socket.once("close", () => {
2111
+ clearTimeout(helloTimeout);
2112
+ rejectHello(new Error("Socket closed before hello"));
2113
+ });
1359
2114
  const tokenEntry = await resolveToken(token, cache, jwtConfig);
1360
2115
  if (!tokenEntry || tokenEntry.type !== "machine") {
2116
+ clearTimeout(helloTimeout);
1361
2117
  logDiagnosticEvent(logger, {
1362
2118
  domain: "service",
1363
2119
  event: "gateway.hosts.ws.auth",
@@ -1376,88 +2132,67 @@ function createWsHandler(cache, jwtConfig, logger, hostRegistry) {
1376
2132
  const sessionId = randomUUID();
1377
2133
  let protocolVersion = null;
1378
2134
  let helloCaps = [];
1379
- let helloDone = false;
1380
2135
  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;
2136
+ try {
2137
+ const helloRaw = await helloRawPromise;
2138
+ const msg = HelloMessageSchema.parse(JSON.parse(helloRaw.toString()));
2139
+ if (!protocolVersions.includes(msg.protocolVersion)) {
2140
+ logDiagnosticEvent(logger, {
2141
+ domain: "service",
2142
+ event: "gateway.hosts.ws.handshake",
2143
+ level: "warn",
2144
+ reasonCode: "websocket_protocol_unsupported",
2145
+ message: "Host WebSocket protocol version is unsupported",
2146
+ outcome: "failed",
2147
+ serviceId: "gateway",
2148
+ route: "/hosts/connect",
2149
+ evidence: {
2150
+ hostId,
2151
+ namespaceId,
2152
+ protocolVersion: msg.protocolVersion,
2153
+ supportedVersions: [...SUPPORTED_PROTOCOL_VERSIONS]
1435
2154
  }
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
- });
2155
+ });
2156
+ send(socket, {
2157
+ type: "negotiate",
2158
+ supportedVersions: [...SUPPORTED_PROTOCOL_VERSIONS]
2159
+ });
2160
+ socket.close(1008, "Unsupported protocol version");
2161
+ return;
2162
+ }
2163
+ protocolVersion = msg.protocolVersion;
2164
+ helloCaps = msg.capabilities ?? [];
2165
+ } catch (error) {
2166
+ const isTimeout = error instanceof Error && error.message === "Hello timeout";
2167
+ if (isTimeout) {
2168
+ logDiagnosticEvent(logger, {
2169
+ domain: "service",
2170
+ event: "gateway.hosts.ws.handshake",
2171
+ level: "warn",
2172
+ reasonCode: "websocket_hello_timeout",
2173
+ message: "Host WebSocket hello timed out",
2174
+ outcome: "failed",
2175
+ serviceId: "gateway",
2176
+ route: "/hosts/connect",
2177
+ evidence: { hostId, namespaceId }
2178
+ });
2179
+ } else if (!(error instanceof Error && error.message === "Socket closed before hello")) {
2180
+ logDiagnosticEvent(logger, {
2181
+ domain: "service",
2182
+ event: "gateway.hosts.ws.handshake",
2183
+ level: "warn",
2184
+ reasonCode: "websocket_handshake_invalid",
2185
+ message: "Host WebSocket hello message is invalid",
2186
+ outcome: "failed",
2187
+ error: error instanceof Error ? error : new Error(String(error)),
2188
+ serviceId: "gateway",
2189
+ route: "/hosts/connect",
2190
+ evidence: { hostId, namespaceId }
2191
+ });
2192
+ socket.close(1008, "Invalid hello message");
2193
+ }
2194
+ return;
2195
+ }
1461
2196
  if (!protocolVersion) {
1462
2197
  return;
1463
2198
  }
@@ -1839,9 +2574,106 @@ function createClientWsHandler(cache, jwtConfig, logger) {
1839
2574
  };
1840
2575
  }
1841
2576
 
2577
+ // src/ws/pump.ts
2578
+ var OPEN = 1;
2579
+ function normalizeCloseCode(code) {
2580
+ if (code === void 0 || code === 1005 || code === 1006) {
2581
+ return void 0;
2582
+ }
2583
+ return code;
2584
+ }
2585
+ function safeClose(ws, code, reason) {
2586
+ if (ws.readyState === OPEN || ws.readyState === 0) {
2587
+ try {
2588
+ if (code === void 0) {
2589
+ ws.close();
2590
+ } else if (reason === void 0) {
2591
+ ws.close(code);
2592
+ } else {
2593
+ ws.close(code, reason);
2594
+ }
2595
+ } catch {
2596
+ }
2597
+ }
2598
+ }
2599
+ function pumpBidirectional(client, upstream, logger) {
2600
+ const pending = [];
2601
+ let upstreamOpen = false;
2602
+ let closed = false;
2603
+ const closeBoth = (code, reason) => {
2604
+ if (closed) {
2605
+ return;
2606
+ }
2607
+ closed = true;
2608
+ safeClose(client, code, reason);
2609
+ safeClose(upstream, code, reason);
2610
+ };
2611
+ client.on("message", (data, isBinary) => {
2612
+ if (upstreamOpen && upstream.readyState === OPEN) {
2613
+ upstream.send(data, { binary: isBinary });
2614
+ } else {
2615
+ pending.push({ data, binary: isBinary });
2616
+ }
2617
+ });
2618
+ upstream.on("open", () => {
2619
+ upstreamOpen = true;
2620
+ for (const frame of pending) {
2621
+ if (upstream.readyState === OPEN) {
2622
+ upstream.send(frame.data, { binary: frame.binary });
2623
+ }
2624
+ }
2625
+ pending.length = 0;
2626
+ });
2627
+ upstream.on("message", (data, isBinary) => {
2628
+ if (client.readyState === OPEN) {
2629
+ client.send(data, { binary: isBinary });
2630
+ }
2631
+ });
2632
+ upstream.on("close", (code, reason) => {
2633
+ closeBoth(normalizeCloseCode(code), reason);
2634
+ });
2635
+ client.on("close", (code, reason) => {
2636
+ closeBoth(normalizeCloseCode(code), reason);
2637
+ });
2638
+ upstream.on("error", (err) => {
2639
+ logger.warn("Upstream WS error", { error: err.message });
2640
+ closeBoth(1011);
2641
+ });
2642
+ client.on("error", (err) => {
2643
+ logger.warn("Client WS error", { error: err.message });
2644
+ closeBoth(1011);
2645
+ });
2646
+ }
2647
+
1842
2648
  // src/ws/gateway-ws.ts
1843
2649
  var GATEWAY_WS_PATHS = /* @__PURE__ */ new Set(["/hosts/connect", "/clients/connect"]);
1844
- function attachGatewayWs(server, cache, jwtConfig, logger, hostRegistry) {
2650
+ var FORWARDED_WS_HEADERS = [
2651
+ "authorization",
2652
+ "cookie",
2653
+ "x-request-id",
2654
+ "x-trace-id",
2655
+ "sec-websocket-protocol"
2656
+ ];
2657
+ function pickSocketWsUpstream(pathname, upstreams) {
2658
+ return upstreams.find(
2659
+ (u) => pathname === u.prefix || pathname.startsWith(`${u.prefix}/`)
2660
+ );
2661
+ }
2662
+ function buildUpstreamWsUrl(upstream, pathname, search) {
2663
+ const rewritten = upstream.rewritePrefix + pathname.slice(upstream.prefix.length);
2664
+ return `ws+unix://${upstream.socketPath}:${rewritten}${search}`;
2665
+ }
2666
+ function forwardWsHeaders(req) {
2667
+ const out = {};
2668
+ for (const name of FORWARDED_WS_HEADERS) {
2669
+ const value = req.headers[name];
2670
+ if (typeof value === "string") {
2671
+ out[name] = value;
2672
+ }
2673
+ }
2674
+ return out;
2675
+ }
2676
+ function attachGatewayWs(server, cache, jwtConfig, logger, hostRegistry, socketWsUpstreams = []) {
1845
2677
  const wss = new WebSocketServer({ noServer: true });
1846
2678
  const hostsHandler = createWsHandler(cache, jwtConfig, logger, hostRegistry);
1847
2679
  const clientsHandler = createClientWsHandler(cache, jwtConfig, logger);
@@ -1851,16 +2683,29 @@ function attachGatewayWs(server, cache, jwtConfig, logger, hostRegistry) {
1851
2683
  const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
1852
2684
  if (GATEWAY_WS_PATHS.has(pathname)) {
1853
2685
  wss.handleUpgrade(req, socket, head, (ws) => {
1854
- if (pathname === "/hosts/connect") {
1855
- hostsHandler(ws, req);
1856
- } else {
1857
- clientsHandler(ws, req);
1858
- }
2686
+ const handlerPromise = pathname === "/hosts/connect" ? hostsHandler(ws, req) : clientsHandler(ws, req);
2687
+ handlerPromise.catch((err) => {
2688
+ logger.error("Unhandled WS handler error", err instanceof Error ? err : new Error(String(err)), { pathname });
2689
+ try {
2690
+ ws.close(1011, "Internal error");
2691
+ } catch {
2692
+ }
2693
+ });
1859
2694
  });
1860
- } else {
1861
- for (const listener of existingListeners) {
1862
- listener.call(server, req, socket, head);
1863
- }
2695
+ return;
2696
+ }
2697
+ const socketUpstream = pickSocketWsUpstream(pathname, socketWsUpstreams);
2698
+ if (socketUpstream) {
2699
+ const { search } = new URL(req.url ?? "/", "http://localhost");
2700
+ const upstreamUrl = buildUpstreamWsUrl(socketUpstream, pathname, search);
2701
+ wss.handleUpgrade(req, socket, head, (clientWs) => {
2702
+ const upstreamWs = new WebSocket(upstreamUrl, { headers: forwardWsHeaders(req) });
2703
+ pumpBidirectional(clientWs, upstreamWs, logger);
2704
+ });
2705
+ return;
2706
+ }
2707
+ for (const listener of existingListeners) {
2708
+ listener.call(server, req, socket, head);
1864
2709
  }
1865
2710
  });
1866
2711
  logger.info("Gateway WS endpoints attached", { paths: [...GATEWAY_WS_PATHS] });
@@ -2117,10 +2962,767 @@ function mergeTopOperations(httpOperations, domainOperations, limit = 5) {
2117
2962
  }
2118
2963
  return [...sliced.slice(0, Math.max(0, limit - 1)), firstDomainOperation];
2119
2964
  }
2965
+ function registerInternalRoutes(scope, internalSecret, hostRegistry, cache) {
2966
+ scope.post("/internal/dispatch", async (request, reply) => {
2967
+ const provided = request.headers["x-internal-secret"];
2968
+ if (!internalSecret || provided !== internalSecret) {
2969
+ return reply.code(403).send({ error: "Forbidden" });
2970
+ }
2971
+ const body = request.body;
2972
+ if (!body.namespaceId || !body.adapter || !body.method) {
2973
+ return reply.code(400).send({ error: "Missing required fields: namespaceId, adapter, method" });
2974
+ }
2975
+ const hostId = body.hostId ?? globalDispatcher.firstHostWithCapability(body.namespaceId, body.adapter) ?? globalDispatcher.firstHost(body.namespaceId);
2976
+ if (!hostId) {
2977
+ return reply.code(503).send({
2978
+ error: "No host connected",
2979
+ namespaceId: body.namespaceId
2980
+ });
2981
+ }
2982
+ try {
2983
+ const result = await globalDispatcher.call(
2984
+ body.namespaceId,
2985
+ hostId,
2986
+ body.adapter,
2987
+ body.method,
2988
+ body.args ?? []
2989
+ );
2990
+ return { result };
2991
+ } catch (err) {
2992
+ const message = err instanceof Error ? err.message : String(err);
2993
+ if (message.includes("Host not connected")) {
2994
+ return reply.code(503).send({ error: message });
2995
+ }
2996
+ return reply.code(502).send({ error: message });
2997
+ }
2998
+ });
2999
+ scope.get("/internal/auth/handle/:handle", async (request, reply) => {
3000
+ const provided = request.headers["x-internal-secret"];
3001
+ if (!internalSecret || provided !== internalSecret) {
3002
+ return reply.code(403).send({ error: "Forbidden" });
3003
+ }
3004
+ if (!cache) {
3005
+ return reply.code(503).send({ error: "Cache not available" });
3006
+ }
3007
+ const { handle } = request.params;
3008
+ const record = await getClientByHandle(cache, handle);
3009
+ if (!record) {
3010
+ return reply.code(404).send({ error: "Handle not found" });
3011
+ }
3012
+ return { namespaceId: record.namespaceId, name: record.name, handle: record.handle };
3013
+ });
3014
+ scope.post("/internal/resolve-host", async (request, reply) => {
3015
+ const provided = request.headers["x-internal-secret"];
3016
+ if (!internalSecret || provided !== internalSecret) {
3017
+ return reply.code(403).send({ error: "Forbidden" });
3018
+ }
3019
+ const body = request.body;
3020
+ const namespaceId = body.namespaceId ?? "default";
3021
+ const target = body.target ?? {};
3022
+ const strategy = target.hostSelection ?? "any-matching";
3023
+ let hostId;
3024
+ if (strategy === "pinned" && target.hostId) {
3025
+ const host = await hostRegistry.get(target.hostId, namespaceId);
3026
+ if (host?.status === "online" || host?.status === "reconnecting") {
3027
+ hostId = target.hostId;
3028
+ }
3029
+ } else {
3030
+ hostId = globalDispatcher.firstHostWithCapability(namespaceId, "execution");
3031
+ }
3032
+ if (!hostId) {
3033
+ return reply.code(404).send({ error: "No matching host found" });
3034
+ }
3035
+ return { hostId, strategy, namespaceId };
3036
+ });
3037
+ }
3038
+
3039
+ // src/pressure/resolve.ts
3040
+ function resolveResourceId(method, url, config) {
3041
+ const pressure = config.pressure;
3042
+ if (!pressure || pressure.enabled === false) {
3043
+ return null;
3044
+ }
3045
+ const path = url.split("?")[0] ?? url;
3046
+ const upperMethod = method.toUpperCase();
3047
+ for (const override of pressure.perRoute) {
3048
+ if (!path.startsWith(override.pathPrefix)) {
3049
+ continue;
3050
+ }
3051
+ if (override.methods && override.methods.length > 0) {
3052
+ const allowed = override.methods.some((m) => m.toUpperCase() === upperMethod);
3053
+ if (!allowed) {
3054
+ continue;
3055
+ }
3056
+ }
3057
+ return { resource: override.resource, layer: "route", override };
3058
+ }
3059
+ for (const [name, upstream] of Object.entries(config.upstreams)) {
3060
+ if (path.startsWith(upstream.prefix)) {
3061
+ const limits = pressure.perService[name];
3062
+ if (!limits) {
3063
+ return null;
3064
+ }
3065
+ return { resource: `gateway:service:${name}`, layer: "service", upstream: name };
3066
+ }
3067
+ }
3068
+ return null;
3069
+ }
3070
+
3071
+ // src/pressure/register-limits.ts
3072
+ function registerPressureLimits(broker, config, logger) {
3073
+ if (!config || config.enabled === false) {
3074
+ return { perServiceRegistered: 0, perRouteRegistered: 0, perTenantEnabled: false };
3075
+ }
3076
+ let perServiceRegistered = 0;
3077
+ for (const [name, limits] of Object.entries(config.perService)) {
3078
+ broker.registerLimit(`gateway:service:${name}`, limits);
3079
+ perServiceRegistered++;
3080
+ }
3081
+ let perRouteRegistered = 0;
3082
+ for (const override of config.perRoute) {
3083
+ broker.registerLimit(override.resource, override.limits);
3084
+ perRouteRegistered++;
3085
+ }
3086
+ const perTenantEnabled = config.perTenant?.enabled === true;
3087
+ logger.info("pressure.boot", {
3088
+ event: "pressure.boot",
3089
+ perService: perServiceRegistered,
3090
+ perRoute: perRouteRegistered,
3091
+ perTenant: perTenantEnabled
3092
+ });
3093
+ return { perServiceRegistered, perRouteRegistered, perTenantEnabled };
3094
+ }
3095
+
3096
+ // src/pressure/hooks.ts
3097
+ function shouldSkip(request) {
3098
+ const upgrade = request.headers.upgrade;
3099
+ if (typeof upgrade === "string" && upgrade.length > 0) {
3100
+ return true;
3101
+ }
3102
+ return false;
3103
+ }
3104
+ function armRequest(request) {
3105
+ if (!request.pressureReleases) {
3106
+ request.pressureReleases = [];
3107
+ request.raw.on("close", () => {
3108
+ void releaseAll(request);
3109
+ });
3110
+ }
3111
+ return request.pressureReleases;
3112
+ }
3113
+ async function releaseAll(request) {
3114
+ const releases = request.pressureReleases;
3115
+ if (!releases || releases.length === 0) {
3116
+ return;
3117
+ }
3118
+ const snapshot = releases.slice();
3119
+ request.pressureReleases = [];
3120
+ for (const release of snapshot) {
3121
+ try {
3122
+ await release();
3123
+ } catch {
3124
+ }
3125
+ }
3126
+ }
3127
+ function retryAfterSeconds(waitTimeMs) {
3128
+ if (!waitTimeMs || waitTimeMs <= 0) {
3129
+ return 1;
3130
+ }
3131
+ return Math.max(1, Math.ceil(waitTimeMs / 1e3));
3132
+ }
3133
+ function createPressureOnRequest(deps) {
3134
+ return async (request, reply) => {
3135
+ if (shouldSkip(request)) {
3136
+ return;
3137
+ }
3138
+ const resolved = resolveResourceId(request.method, request.url, deps.config);
3139
+ if (!resolved) {
3140
+ return;
3141
+ }
3142
+ const acquired = await deps.broker.tryAcquire(resolved.resource);
3143
+ if (!acquired.allowed) {
3144
+ const retryAfter = retryAfterSeconds(acquired.waitTimeMs);
3145
+ deps.logger.warn("pressure.rejected", {
3146
+ event: "pressure.rejected",
3147
+ resource: resolved.resource,
3148
+ layer: resolved.layer,
3149
+ method: request.method,
3150
+ url: request.url,
3151
+ waitTimeMs: acquired.waitTimeMs
3152
+ });
3153
+ reply.code(429).header("Retry-After", String(retryAfter)).send({
3154
+ error: "rate_limited",
3155
+ resource: resolved.resource,
3156
+ waitTimeMs: acquired.waitTimeMs ?? null
3157
+ });
3158
+ return reply;
3159
+ }
3160
+ armRequest(request).push(acquired.release);
3161
+ };
3162
+ }
3163
+ function createPressurePreHandler(deps) {
3164
+ const registered = /* @__PURE__ */ new Set();
3165
+ const tenantCfg = deps.config.pressure?.perTenant;
3166
+ return async (request, reply) => {
3167
+ if (!tenantCfg || tenantCfg.enabled !== true) {
3168
+ return;
3169
+ }
3170
+ if (shouldSkip(request)) {
3171
+ return;
3172
+ }
3173
+ const namespaceId = request.authContext?.namespaceId;
3174
+ if (!namespaceId) {
3175
+ return;
3176
+ }
3177
+ const resource = `gateway:tenant:${namespaceId}`;
3178
+ if (!registered.has(namespaceId)) {
3179
+ deps.broker.registerLimit(resource, tenantCfg.limits);
3180
+ registered.add(namespaceId);
3181
+ deps.logger.debug?.("pressure.tenant.registered", {
3182
+ event: "pressure.tenant.registered",
3183
+ namespaceId
3184
+ });
3185
+ }
3186
+ const acquired = await deps.broker.tryAcquire(resource);
3187
+ if (!acquired.allowed) {
3188
+ const retryAfter = retryAfterSeconds(acquired.waitTimeMs);
3189
+ deps.logger.warn("pressure.rejected", {
3190
+ event: "pressure.rejected",
3191
+ resource,
3192
+ layer: "tenant",
3193
+ method: request.method,
3194
+ url: request.url,
3195
+ namespaceId,
3196
+ waitTimeMs: acquired.waitTimeMs
3197
+ });
3198
+ reply.code(429).header("Retry-After", String(retryAfter)).send({
3199
+ error: "rate_limited",
3200
+ resource,
3201
+ waitTimeMs: acquired.waitTimeMs ?? null
3202
+ });
3203
+ return reply;
3204
+ }
3205
+ armRequest(request).push(acquired.release);
3206
+ };
3207
+ }
3208
+ function createPressureOnResponse() {
3209
+ return async (request) => {
3210
+ await releaseAll(request);
3211
+ };
3212
+ }
3213
+
3214
+ // src/webhook/secret-store.ts
3215
+ var PREVIOUS_SECRET_GRACE_MS = 864e5;
3216
+ function cacheKey(ns, pluginId, event, instanceId) {
3217
+ const base = `webhook:secret:${ns}:${pluginId}:${event}`;
3218
+ return instanceId ? `${base}:${instanceId}` : base;
3219
+ }
3220
+ var WebhookSecretStore = class {
3221
+ constructor(cache) {
3222
+ this.cache = cache;
3223
+ }
3224
+ cache;
3225
+ async get(ns, pluginId, event, instanceId) {
3226
+ return this.cache.get(cacheKey(ns, pluginId, event, instanceId));
3227
+ }
3228
+ async set(ns, pluginId, event, entry, instanceId) {
3229
+ await this.cache.set(cacheKey(ns, pluginId, event, instanceId), entry);
3230
+ }
3231
+ async rotate(ns, pluginId, event, newSecret, instanceId) {
3232
+ const existing = await this.get(ns, pluginId, event, instanceId);
3233
+ const entry = { current: newSecret };
3234
+ if (existing) {
3235
+ entry.previous = existing.current;
3236
+ entry.previousExpiresAt = Date.now() + PREVIOUS_SECRET_GRACE_MS;
3237
+ }
3238
+ await this.set(ns, pluginId, event, entry, instanceId);
3239
+ return entry;
3240
+ }
3241
+ async delete(ns, pluginId, event, instanceId) {
3242
+ await this.cache.delete(cacheKey(ns, pluginId, event, instanceId));
3243
+ }
3244
+ };
3245
+ async function provisionWebhook(input, secretStore, backend, manifests, logger) {
3246
+ const { namespaceId, pluginId, event, instanceId, baseUrl } = input;
3247
+ const manifestEntry = manifests.find((m) => m.pluginId === pluginId);
3248
+ const decl = manifestEntry?.manifest.webhooks?.handlers.find((h) => h.event === event);
3249
+ if (!manifestEntry || !decl) {
3250
+ throw new Error(`webhook '${pluginId}/${event}' not found`);
3251
+ }
3252
+ const existing = await secretStore.get(namespaceId, pluginId, event, instanceId);
3253
+ const rotated = existing !== null;
3254
+ const newSecret = randomBytes(32).toString("hex");
3255
+ if (rotated) {
3256
+ await secretStore.rotate(namespaceId, pluginId, event, newSecret, instanceId);
3257
+ } else {
3258
+ await secretStore.set(namespaceId, pluginId, event, { current: newSecret }, instanceId);
3259
+ }
3260
+ const url = instanceId ? `${baseUrl}/webhooks/${encodeURIComponent(pluginId)}/${event}/${instanceId}` : `${baseUrl}/webhooks/${encodeURIComponent(pluginId)}/${event}`;
3261
+ let onProvisionCalled = false;
3262
+ if (decl.onProvision) {
3263
+ try {
3264
+ await backend.execute({
3265
+ handlerRef: decl.onProvision,
3266
+ pluginRoot: manifestEntry.pluginRoot,
3267
+ namespaceId,
3268
+ input: { instanceId, secret: newSecret, url }
3269
+ });
3270
+ onProvisionCalled = true;
3271
+ } catch (err) {
3272
+ logger.error(
3273
+ "onProvision handler failed",
3274
+ err instanceof Error ? err : new Error(String(err)),
3275
+ { pluginId, event, instanceId }
3276
+ );
3277
+ }
3278
+ }
3279
+ return { url, secret: newSecret, rotated, onProvisionCalled };
3280
+ }
3281
+
3282
+ // src/webhook/admin-routes.ts
3283
+ var RL_PROVISION = "webhook-admin:provision";
3284
+ var RL_LIST = "webhook-admin:list";
3285
+ var RL_REVOKE = "webhook-admin:revoke";
3286
+ function registerWebhookAdminRoutes(scope, options) {
3287
+ const { cache, logger, backend, manifests, baseUrl, broker } = options;
3288
+ const secretStore = new WebhookSecretStore(cache);
3289
+ broker.registerLimit(RL_PROVISION, { requestsPerMinute: 10 });
3290
+ broker.registerLimit(RL_LIST, { requestsPerMinute: 60 });
3291
+ broker.registerLimit(RL_REVOKE, { requestsPerMinute: 10 });
3292
+ scope.post("/api/v1/webhooks/provision", async (request, reply) => {
3293
+ const auth = request.authContext;
3294
+ if (!auth) {
3295
+ return reply.code(401).send({ error: "Unauthorized" });
3296
+ }
3297
+ const acquired = await broker.tryAcquire(RL_PROVISION);
3298
+ try {
3299
+ if (!acquired.allowed) {
3300
+ const retryAfterSec = acquired.waitTimeMs ? Math.max(1, Math.ceil(acquired.waitTimeMs / 1e3)) : 1;
3301
+ return reply.code(429).header("Retry-After", String(retryAfterSec)).send({ error: "Too Many Requests" });
3302
+ }
3303
+ const body = request.body;
3304
+ const pluginId = typeof body?.pluginId === "string" ? body.pluginId : void 0;
3305
+ const event = typeof body?.event === "string" ? body.event : void 0;
3306
+ const instanceId = typeof body?.instanceId === "string" ? body.instanceId : void 0;
3307
+ if (!pluginId) {
3308
+ return reply.code(400).send({ error: "Bad Request", message: "pluginId is required" });
3309
+ }
3310
+ if (!event) {
3311
+ return reply.code(400).send({ error: "Bad Request", message: "event is required" });
3312
+ }
3313
+ try {
3314
+ const result = await provisionWebhook(
3315
+ { namespaceId: auth.namespaceId, pluginId, event, instanceId, baseUrl },
3316
+ secretStore,
3317
+ backend,
3318
+ manifests,
3319
+ logger
3320
+ );
3321
+ return reply.code(200).send({
3322
+ url: result.url,
3323
+ secret: result.secret,
3324
+ rotated: result.rotated
3325
+ });
3326
+ } catch (err) {
3327
+ if (err instanceof Error && err.message.includes("not found")) {
3328
+ return reply.code(404).send({ error: "Not Found", message: err.message });
3329
+ }
3330
+ throw err;
3331
+ }
3332
+ } finally {
3333
+ await acquired.release();
3334
+ }
3335
+ });
3336
+ scope.get("/api/v1/webhooks", async (request, reply) => {
3337
+ const auth = request.authContext;
3338
+ if (!auth) {
3339
+ return reply.code(401).send({ error: "Unauthorized" });
3340
+ }
3341
+ const acquired = await broker.tryAcquire(RL_LIST);
3342
+ try {
3343
+ if (!acquired.allowed) {
3344
+ const retryAfterSec = acquired.waitTimeMs ? Math.max(1, Math.ceil(acquired.waitTimeMs / 1e3)) : 1;
3345
+ return reply.code(429).header("Retry-After", String(retryAfterSec)).send({ error: "Too Many Requests" });
3346
+ }
3347
+ const query = request.query;
3348
+ const filterPluginId = query.pluginId;
3349
+ const webhooks = [];
3350
+ for (const entry of manifests) {
3351
+ if (filterPluginId && entry.pluginId !== filterPluginId) {
3352
+ continue;
3353
+ }
3354
+ if (!entry.manifest.webhooks?.handlers) {
3355
+ continue;
3356
+ }
3357
+ for (const decl of entry.manifest.webhooks.handlers) {
3358
+ const secretEntry = decl.multi ? null : await secretStore.get(auth.namespaceId, entry.pluginId, decl.event);
3359
+ webhooks.push({
3360
+ pluginId: entry.pluginId,
3361
+ event: decl.event,
3362
+ multi: decl.multi === true,
3363
+ provisioned: secretEntry !== null
3364
+ });
3365
+ }
3366
+ }
3367
+ return reply.code(200).send({ webhooks });
3368
+ } finally {
3369
+ await acquired.release();
3370
+ }
3371
+ });
3372
+ scope.delete(
3373
+ "/api/v1/webhooks/:pluginId/:event",
3374
+ async (request, reply) => {
3375
+ const auth = request.authContext;
3376
+ if (!auth) {
3377
+ return reply.code(401).send({ error: "Unauthorized" });
3378
+ }
3379
+ const acquired = await broker.tryAcquire(RL_REVOKE);
3380
+ try {
3381
+ if (!acquired.allowed) {
3382
+ const retryAfterSec = acquired.waitTimeMs ? Math.max(1, Math.ceil(acquired.waitTimeMs / 1e3)) : 1;
3383
+ return reply.code(429).header("Retry-After", String(retryAfterSec)).send({ error: "Too Many Requests" });
3384
+ }
3385
+ const { pluginId, event } = request.params;
3386
+ await secretStore.delete(auth.namespaceId, pluginId, event);
3387
+ return reply.code(204).send();
3388
+ } finally {
3389
+ await acquired.release();
3390
+ }
3391
+ }
3392
+ );
3393
+ scope.delete(
3394
+ "/api/v1/webhooks/:pluginId/:event/:instanceId",
3395
+ async (request, reply) => {
3396
+ const auth = request.authContext;
3397
+ if (!auth) {
3398
+ return reply.code(401).send({ error: "Unauthorized" });
3399
+ }
3400
+ const acquired = await broker.tryAcquire(RL_REVOKE);
3401
+ try {
3402
+ if (!acquired.allowed) {
3403
+ const retryAfterSec = acquired.waitTimeMs ? Math.max(1, Math.ceil(acquired.waitTimeMs / 1e3)) : 1;
3404
+ return reply.code(429).header("Retry-After", String(retryAfterSec)).send({ error: "Too Many Requests" });
3405
+ }
3406
+ const { pluginId, event, instanceId } = request.params;
3407
+ await secretStore.delete(auth.namespaceId, pluginId, event, instanceId);
3408
+ return reply.code(204).send();
3409
+ } finally {
3410
+ await acquired.release();
3411
+ }
3412
+ }
3413
+ );
3414
+ }
3415
+
3416
+ // src/webhook/idempotency-store.ts
3417
+ var IDEMPOTENCY_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
3418
+ var WebhookIdempotencyStore = class {
3419
+ constructor(cache) {
3420
+ this.cache = cache;
3421
+ }
3422
+ cache;
3423
+ /**
3424
+ * Check if this delivery has already been processed, and mark it atomically.
3425
+ * Keys are scoped by namespaceId to prevent cross-tenant pollution.
3426
+ *
3427
+ * @returns `true` if duplicate (already processed), `false` if first time
3428
+ */
3429
+ async checkAndMark(namespaceId, pluginId, event, key) {
3430
+ const cacheKey2 = `webhook:idempotency:${namespaceId}:${pluginId}:${event}:${key}`;
3431
+ const isNew = await this.cache.setIfNotExists(cacheKey2, 1, IDEMPOTENCY_TTL_MS);
3432
+ return !isNew;
3433
+ }
3434
+ };
3435
+ function getHeader(headers, name) {
3436
+ const lower = name.toLowerCase();
3437
+ const value = headers[lower] ?? headers[name];
3438
+ return Array.isArray(value) ? value[0] : value;
3439
+ }
3440
+ var _SESSION_KEY = randomBytes(32);
3441
+ function safeEqual(a, b) {
3442
+ const ha = createHmac("sha256", _SESSION_KEY).update(a).digest();
3443
+ const hb = createHmac("sha256", _SESSION_KEY).update(b).digest();
3444
+ return timingSafeEqual(ha, hb);
3445
+ }
3446
+ function isPreviousValid(entry) {
3447
+ return entry.previous !== void 0 && entry.previousExpiresAt !== void 0 && entry.previousExpiresAt > Date.now();
3448
+ }
3449
+ function verifySecret(header, headers, entry) {
3450
+ const value = getHeader(headers, header);
3451
+ if (!value) {
3452
+ return false;
3453
+ }
3454
+ if (safeEqual(value, entry.current)) {
3455
+ return true;
3456
+ }
3457
+ if (isPreviousValid(entry) && safeEqual(value, entry.previous)) {
3458
+ return true;
3459
+ }
3460
+ return false;
3461
+ }
3462
+ function computeHmac(body, secret) {
3463
+ return createHmac("sha256", secret).update(body).digest("hex");
3464
+ }
3465
+ function verifyHmac(header, prefix, rawBody, headers, entry) {
3466
+ const headerValue = getHeader(headers, header);
3467
+ if (!headerValue) {
3468
+ return false;
3469
+ }
3470
+ const receivedSig = prefix && headerValue.startsWith(prefix) ? headerValue.slice(prefix.length) : headerValue;
3471
+ const expectedCurrent = computeHmac(rawBody, entry.current);
3472
+ if (safeEqual(receivedSig, expectedCurrent)) {
3473
+ return true;
3474
+ }
3475
+ if (isPreviousValid(entry)) {
3476
+ const expectedPrevious = computeHmac(rawBody, entry.previous);
3477
+ if (safeEqual(receivedSig, expectedPrevious)) {
3478
+ return true;
3479
+ }
3480
+ }
3481
+ return false;
3482
+ }
3483
+ async function verifyWebhookAuth(authConfig, req, secretStore, backend, pluginRoot) {
3484
+ const entry = await secretStore.get(req.namespaceId, req.pluginId, req.event, req.instanceId);
3485
+ if (!entry) {
3486
+ return { valid: false, reason: "not provisioned" };
3487
+ }
3488
+ switch (authConfig.type) {
3489
+ case "secret": {
3490
+ const valid = verifySecret(authConfig.header, req.headers, entry);
3491
+ return valid ? { valid: true } : { valid: false, reason: "invalid secret" };
3492
+ }
3493
+ case "hmac": {
3494
+ const valid = verifyHmac(authConfig.header, authConfig.prefix, req.rawBody, req.headers, entry);
3495
+ return valid ? { valid: true } : { valid: false, reason: "invalid hmac signature" };
3496
+ }
3497
+ case "custom": {
3498
+ if (!backend || !pluginRoot) {
3499
+ return { valid: false, reason: "custom validator requires backend and pluginRoot" };
3500
+ }
3501
+ try {
3502
+ const bodyHmac = createHmac("sha256", entry.current).update(req.rawBody).digest("hex");
3503
+ const result = await backend.execute({
3504
+ handlerRef: authConfig.validator,
3505
+ pluginRoot,
3506
+ input: {
3507
+ headers: req.headers,
3508
+ rawBody: req.rawBody.toString("base64"),
3509
+ bodyHmac
3510
+ }
3511
+ });
3512
+ return result.valid ? { valid: true } : { valid: false, reason: "custom validator rejected" };
3513
+ } catch {
3514
+ return { valid: false, reason: "validator error" };
3515
+ }
3516
+ }
3517
+ }
3518
+ }
3519
+
3520
+ // src/webhook/router.ts
3521
+ function getByDotPath(obj, path) {
3522
+ const parts = path.split(".");
3523
+ let current = obj;
3524
+ for (const part of parts) {
3525
+ if (!current || typeof current !== "object") {
3526
+ return void 0;
3527
+ }
3528
+ current = current[part];
3529
+ }
3530
+ return current;
3531
+ }
3532
+ async function registerWebhookRoutes(scope, options) {
3533
+ const { cache, broker, logger, manifests } = options;
3534
+ const secretStore = new WebhookSecretStore(cache);
3535
+ const idempotencyStore = new WebhookIdempotencyStore(cache);
3536
+ const declMap = /* @__PURE__ */ new Map();
3537
+ let needsRawBody = false;
3538
+ for (const entry of manifests) {
3539
+ const { pluginId, manifest, pluginRoot } = entry;
3540
+ if (!manifest.webhooks?.handlers.length) {
3541
+ continue;
3542
+ }
3543
+ for (const decl of manifest.webhooks.handlers) {
3544
+ if (!decl.auth) {
3545
+ throw new Error(`webhook '${pluginId}/${decl.event}' has no auth config`);
3546
+ }
3547
+ const mapKey = `${pluginId}:${decl.event}:${decl.multi ? "multi" : "single"}`;
3548
+ declMap.set(mapKey, { pluginId, decl, pluginRoot, manifest });
3549
+ broker.registerLimit(`webhook:${pluginId}:${decl.event}`, {
3550
+ requestsPerMinute: decl.rateLimit?.requestsPerMinute ?? 60
3551
+ });
3552
+ if (decl.auth.type === "hmac" || decl.auth.type === "custom") {
3553
+ needsRawBody = true;
3554
+ }
3555
+ }
3556
+ }
3557
+ if (declMap.size === 0) {
3558
+ return 0;
3559
+ }
3560
+ if (needsRawBody) {
3561
+ scope.addHook("preParsing", async (_request, _reply, payload) => {
3562
+ const chunks = [];
3563
+ for await (const chunk of payload) {
3564
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
3565
+ }
3566
+ const body = Buffer.concat(chunks);
3567
+ _request.rawBody = body;
3568
+ return Readable.from(body);
3569
+ });
3570
+ }
3571
+ scope.post(
3572
+ "/webhooks/:pluginId/:event",
3573
+ async (request, reply) => {
3574
+ const { pluginId, event } = request.params;
3575
+ const entry = declMap.get(`${pluginId}:${event}:single`);
3576
+ if (!entry) {
3577
+ return reply.code(404).send({ error: "Webhook not found" });
3578
+ }
3579
+ return handleWebhook({ request, reply, entry, namespaceHeader: request.headers["x-kb-namespace"], instanceId: void 0, secretStore, idempotencyStore, broker, logger });
3580
+ }
3581
+ );
3582
+ scope.post(
3583
+ "/webhooks/:pluginId/:event/:instanceId",
3584
+ async (request, reply) => {
3585
+ const { pluginId, event, instanceId } = request.params;
3586
+ const entry = declMap.get(`${pluginId}:${event}:multi`);
3587
+ if (!entry) {
3588
+ return reply.code(404).send({ error: "Webhook not found" });
3589
+ }
3590
+ return handleWebhook({ request, reply, entry, namespaceHeader: request.headers["x-kb-namespace"], instanceId, secretStore, idempotencyStore, broker, logger });
3591
+ }
3592
+ );
3593
+ return declMap.size;
3594
+ }
3595
+ async function handleWebhook({
3596
+ request,
3597
+ reply,
3598
+ entry,
3599
+ namespaceHeader,
3600
+ instanceId,
3601
+ secretStore,
3602
+ idempotencyStore,
3603
+ broker,
3604
+ logger
3605
+ }) {
3606
+ const { pluginId, decl, pluginRoot, manifest } = entry;
3607
+ const namespaceId = Array.isArray(namespaceHeader) ? namespaceHeader[0] : namespaceHeader;
3608
+ if (!namespaceId) {
3609
+ return reply.code(400).send({ error: "Missing required header: x-kb-namespace" });
3610
+ }
3611
+ const rateResource = `webhook:${pluginId}:${decl.event}`;
3612
+ const acquired = await broker.tryAcquire(rateResource);
3613
+ try {
3614
+ if (!acquired.allowed) {
3615
+ const retryAfterSec = acquired.waitTimeMs ? Math.max(1, Math.ceil(acquired.waitTimeMs / 1e3)) : 1;
3616
+ return reply.code(429).header("Retry-After", String(retryAfterSec)).send({
3617
+ error: "Too Many Requests",
3618
+ retryAfterMs: acquired.waitTimeMs ?? null
3619
+ });
3620
+ }
3621
+ if (decl.challenge && request.body) {
3622
+ const fieldValue = getByDotPath(request.body, decl.challenge.bodyPath);
3623
+ if (fieldValue === decl.challenge.value) {
3624
+ const replyValue = getByDotPath(request.body, decl.challenge.replyPath);
3625
+ const replyKey = decl.challenge.replyPath.split(".").pop();
3626
+ return reply.code(200).send({ [replyKey]: replyValue });
3627
+ }
3628
+ }
3629
+ const rawBody = request.rawBody ?? (request.body == null ? Buffer.alloc(0) : Buffer.from(typeof request.body === "string" ? request.body : JSON.stringify(request.body)));
3630
+ const authReq = {
3631
+ headers: request.headers,
3632
+ rawBody,
3633
+ namespaceId,
3634
+ pluginId,
3635
+ event: decl.event,
3636
+ instanceId
3637
+ };
3638
+ const authBackend = decl.auth.type === "custom" ? makeAuthBackend(namespaceId, pluginId) : void 0;
3639
+ const authResult = await verifyWebhookAuth(decl.auth, authReq, secretStore, authBackend, pluginRoot);
3640
+ if (!authResult.valid) {
3641
+ return reply.code(401).send({ error: "Unauthorized", reason: authResult.reason });
3642
+ }
3643
+ if (decl.idempotencyKey && request.body) {
3644
+ const deliveryKey = getByDotPath(request.body, decl.idempotencyKey);
3645
+ if (typeof deliveryKey === "string") {
3646
+ const isDuplicate = await idempotencyStore.checkAndMark(namespaceId, pluginId, decl.event, deliveryKey);
3647
+ if (isDuplicate) {
3648
+ return reply.code(200).send({ status: "duplicate" });
3649
+ }
3650
+ }
3651
+ }
3652
+ const webhookId = randomUUID();
3653
+ const hostContext = {
3654
+ host: "webhook",
3655
+ event: decl.event,
3656
+ source: request.ip,
3657
+ payload: request.body,
3658
+ namespaceId,
3659
+ webhookId,
3660
+ ...instanceId !== void 0 ? { instanceId } : {}
3661
+ };
3662
+ const hostId = globalDispatcher.firstHostWithCapability(namespaceId, "execution");
3663
+ if (!hostId) {
3664
+ return reply.code(503).send({ error: "No execution host connected" });
3665
+ }
3666
+ const dispatchArgs = [{
3667
+ pluginId,
3668
+ handlerRef: decl.handler,
3669
+ input: request.body,
3670
+ executionId: webhookId,
3671
+ requestId: webhookId,
3672
+ descriptor: {
3673
+ hostType: "webhook",
3674
+ hostContext,
3675
+ pluginId,
3676
+ pluginVersion: manifest.version,
3677
+ requestId: webhookId,
3678
+ permissions: decl.permissions ?? []
3679
+ }
3680
+ }];
3681
+ if (decl.async) {
3682
+ reply.code(202).send({ status: "accepted", webhookId });
3683
+ void globalDispatcher.call(namespaceId, hostId, "execution", "execute", dispatchArgs).catch((err) => {
3684
+ logger.error(
3685
+ "Webhook async dispatch failed",
3686
+ err instanceof Error ? err : new Error(String(err)),
3687
+ { pluginId, event: decl.event, webhookId }
3688
+ );
3689
+ });
3690
+ return;
3691
+ }
3692
+ try {
3693
+ const result = await globalDispatcher.call(namespaceId, hostId, "execution", "execute", dispatchArgs);
3694
+ return reply.code(200).send({ status: "ok", webhookId, result });
3695
+ } catch (err) {
3696
+ logger.error(
3697
+ "Webhook dispatch failed",
3698
+ err instanceof Error ? err : new Error(String(err)),
3699
+ { pluginId, event: decl.event, webhookId }
3700
+ );
3701
+ return reply.code(500).send({ error: "Dispatch failed" });
3702
+ }
3703
+ } finally {
3704
+ await acquired.release();
3705
+ }
3706
+ }
3707
+ function makeAuthBackend(namespaceId, pluginId) {
3708
+ return {
3709
+ async execute({ handlerRef, pluginRoot, input }) {
3710
+ const hostId = globalDispatcher.firstHostWithCapability(namespaceId, "execution");
3711
+ if (!hostId) {
3712
+ throw new Error("No execution host connected for custom auth");
3713
+ }
3714
+ return globalDispatcher.call(namespaceId, hostId, "execution", "execute", [
3715
+ { pluginId, handlerRef, pluginRoot, input }
3716
+ ]);
3717
+ }
3718
+ };
3719
+ }
3720
+
3721
+ // src/server.ts
2120
3722
  function redactQueryToken(url) {
2121
3723
  return url.replace(/([?&]access_token=)[^&]*/gi, "$1[REDACTED]");
2122
3724
  }
2123
- async function createServer(config, cache, logger, jwtConfig, registry) {
3725
+ async function createServer(config, cache, logger, jwtConfig, registry, serviceTransport, userAuth, webhookManifests) {
2124
3726
  const gatewayLogger = createCorrelatedLogger(logger, {
2125
3727
  serviceId: "gateway",
2126
3728
  logsSource: "gateway",
@@ -2129,8 +3731,11 @@ async function createServer(config, cache, logger, jwtConfig, registry) {
2129
3731
  operation: "gateway.http"
2130
3732
  });
2131
3733
  const app = Fastify({
2132
- logger: false
3734
+ logger: false,
3735
+ // CD-10: gateway sits behind nginx; parse X-Forwarded-For for real client IPs.
3736
+ trustProxy: true
2133
3737
  });
3738
+ await app.register(fastifyCookie);
2134
3739
  const isProduction = process.env.NODE_ENV === "production";
2135
3740
  await registerOpenAPI(app, {
2136
3741
  title: "KB Labs Gateway",
@@ -2142,6 +3747,11 @@ async function createServer(config, cache, logger, jwtConfig, registry) {
2142
3747
  await app.register(fastifyCors, { origin: false });
2143
3748
  const observability = new GatewayObservabilityCollector(config);
2144
3749
  observability.register(app);
3750
+ if (config.pressure && config.pressure.enabled !== false && platform.hasResourceBroker) {
3751
+ const deps = { broker: platform.resourceBroker, logger, config };
3752
+ app.addHook("onRequest", createPressureOnRequest(deps));
3753
+ app.addHook("onResponse", createPressureOnResponse());
3754
+ }
2145
3755
  app.addHook("onRequest", async (request, reply) => {
2146
3756
  const requestId = request.headers["x-request-id"] || request.id || randomUUID();
2147
3757
  const traceId = request.headers["x-trace-id"] || randomUUID();
@@ -2172,26 +3782,92 @@ async function createServer(config, cache, logger, jwtConfig, registry) {
2172
3782
  statusCode: reply.statusCode
2173
3783
  });
2174
3784
  });
2175
- const PROXY_TIMEOUT_MS = 36e5;
3785
+ const socketWsUpstreams = [];
2176
3786
  for (const [name, upstream] of Object.entries(config.upstreams)) {
3787
+ const conn = serviceTransport.connectionInfo(upstream.serviceId);
3788
+ if (!conn) {
3789
+ throw new Error(
3790
+ `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.`
3791
+ );
3792
+ }
3793
+ const wsOverSocket = Boolean(upstream.websocket) && Boolean(conn.socketPath);
3794
+ if (wsOverSocket) {
3795
+ socketWsUpstreams.push({
3796
+ prefix: upstream.prefix,
3797
+ rewritePrefix: upstream.rewritePrefix ?? upstream.prefix,
3798
+ socketPath: conn.socketPath
3799
+ });
3800
+ }
2177
3801
  await app.register(fastifyHttpProxy, {
2178
- upstream: upstream.url,
3802
+ upstream: conn.baseUrl,
2179
3803
  prefix: upstream.prefix,
2180
3804
  rewritePrefix: upstream.rewritePrefix ?? upstream.prefix,
2181
3805
  disableCache: true,
2182
- websocket: upstream.websocket ?? false,
2183
- http: {
2184
- requestOptions: {
2185
- timeout: PROXY_TIMEOUT_MS
2186
- }
3806
+ // Disable http-proxy WS for socket upstreams — the gateway dialer owns it.
3807
+ websocket: wsOverSocket ? false : upstream.websocket ?? false,
3808
+ undici: {
3809
+ // Restore 1-hour body timeout for SSE streams and large transfers.
3810
+ // undici defaults: headersTimeout=30s, bodyTimeout=300s — too short for streaming.
3811
+ bodyTimeout: 36e5,
3812
+ ...conn.socketPath ? { socketPath: conn.socketPath } : {}
2187
3813
  }
2188
3814
  });
2189
- gatewayLogger.info(`Upstream registered: ${name} \u2192 ${upstream.url} (${upstream.prefix}${upstream.websocket ? ", ws" : ""})`);
3815
+ const connDesc = conn.socketPath ? `${conn.baseUrl} (unix:${conn.socketPath})` : conn.baseUrl;
3816
+ const wsDesc = upstream.websocket ? wsOverSocket ? ", ws\u2192unix" : ", ws" : "";
3817
+ gatewayLogger.info(`Upstream registered: ${name} \u2192 ${connDesc} (${upstream.prefix}${wsDesc})`);
2190
3818
  }
2191
3819
  await app.register(async function gatewayRoutes(scope) {
2192
- scope.addHook("onRequest", createAuthMiddleware(cache, jwtConfig));
3820
+ if (userAuth) {
3821
+ scope.addHook(
3822
+ "onRequest",
3823
+ createUserAuthMiddleware({
3824
+ users: userAuth.users,
3825
+ tenantResolver: userAuth.tenantResolver,
3826
+ jwtConfig
3827
+ })
3828
+ );
3829
+ }
3830
+ scope.addHook(
3831
+ "onRequest",
3832
+ createAuthMiddleware(cache, jwtConfig, { authEnabled: config.auth?.enabled !== false })
3833
+ );
3834
+ if (config.pressure?.perTenant?.enabled === true && platform.hasResourceBroker) {
3835
+ scope.addHook(
3836
+ "preHandler",
3837
+ createPressurePreHandler({ broker: platform.resourceBroker, logger, config })
3838
+ );
3839
+ }
2193
3840
  const authService = new AuthService(cache, jwtConfig);
2194
- registerAuthRoutes(scope, authService);
3841
+ const userExt = userAuth ? {
3842
+ userRefreshFn: createUserRefreshFn({
3843
+ userAuthService: userAuth.userAuthService,
3844
+ cookieOpts: { cookieSecure: userAuth.cookieSecure }
3845
+ }),
3846
+ pdp: userAuth.pdp
3847
+ } : void 0;
3848
+ registerAuthRoutes(scope, authService, userExt);
3849
+ if (userAuth) {
3850
+ registerUserAuthRoutes(
3851
+ scope,
3852
+ {
3853
+ userAuthService: userAuth.userAuthService,
3854
+ users: userAuth.users,
3855
+ sessions: userAuth.sessions,
3856
+ invites: userAuth.invites,
3857
+ providers: userAuth.providers,
3858
+ pdp: userAuth.pdp,
3859
+ tenantResolver: userAuth.tenantResolver,
3860
+ cookieOpts: { cookieSecure: userAuth.cookieSecure },
3861
+ accessTtlSec: userAuth.accessTtlSec,
3862
+ refreshTtlSec: userAuth.refreshTtlSec,
3863
+ inviteTtlMs: userAuth.inviteTtlMs,
3864
+ rateLimiter: userAuth.rateLimiter,
3865
+ authRateLimit: userAuth.authRateLimit,
3866
+ oauthState: userAuth.oauthState,
3867
+ oauthCallbackPerIpPerMinute: userAuth.oauthCallbackPerIpPerMinute
3868
+ }
3869
+ );
3870
+ }
2195
3871
  const HEALTH_CACHE_KEY = "__gateway_health";
2196
3872
  const HEALTH_CACHE_TTL = 15e3;
2197
3873
  const startupTime = Date.now();
@@ -2219,7 +3895,8 @@ async function createServer(config, cache, logger, jwtConfig, registry) {
2219
3895
  await observability.observeOperation(`gateway.upstream.${name}.health`, async () => {
2220
3896
  const probeStart = Date.now();
2221
3897
  try {
2222
- const res = await fetch(`${upstream.url}/health`, {
3898
+ const res = await serviceTransport.call(upstream.serviceId, {
3899
+ path: "/health",
2223
3900
  signal: AbortSignal.timeout(2e3)
2224
3901
  });
2225
3902
  const latencyMs = Date.now() - probeStart;
@@ -2236,8 +3913,8 @@ async function createServer(config, cache, logger, jwtConfig, registry) {
2236
3913
  route: `${upstream.prefix}/health`,
2237
3914
  evidence: {
2238
3915
  upstreamId: name,
2239
- upstreamUrl: upstream.url,
2240
- statusCode: res.status,
3916
+ serviceId: upstream.serviceId,
3917
+ statusCode: res.statusCode,
2241
3918
  latencyMs
2242
3919
  }
2243
3920
  });
@@ -2257,7 +3934,7 @@ async function createServer(config, cache, logger, jwtConfig, registry) {
2257
3934
  route: `${upstream.prefix}/health`,
2258
3935
  evidence: {
2259
3936
  upstreamId: name,
2260
- upstreamUrl: upstream.url,
3937
+ serviceId: upstream.serviceId,
2261
3938
  latencyMs
2262
3939
  }
2263
3940
  });
@@ -2281,6 +3958,14 @@ async function createServer(config, cache, logger, jwtConfig, registry) {
2281
3958
  scope.get("/health", { schema: { tags: ["System"], summary: "Gateway health check" } }, async () => {
2282
3959
  return collectHealthSnapshot();
2283
3960
  });
3961
+ scope.get("/health/adapters", {
3962
+ schema: {
3963
+ tags: ["System"],
3964
+ summary: "Platform adapter status \u2014 mode (real | inmemory | noop) per slot"
3965
+ }
3966
+ }, async () => {
3967
+ return getAdapterStatus();
3968
+ });
2284
3969
  scope.get("/ready", { schema: { tags: ["System"], summary: "Gateway readiness check" } }, async (_request, reply) => {
2285
3970
  const health = await collectHealthSnapshot();
2286
3971
  const upstreams = health.upstreams ?? {};
@@ -2379,73 +4064,55 @@ async function createServer(config, cache, logger, jwtConfig, registry) {
2379
4064
  registerLLMGatewayRoutes(scope, logger);
2380
4065
  registerTelemetryRoutes(scope, logger);
2381
4066
  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 });
4067
+ registerAggregatedDocsRoutes(scope, config, serviceTransport, cache);
4068
+ registerInternalRoutes(scope, process.env.GATEWAY_INTERNAL_SECRET, hostRegistry, cache);
4069
+ if (platform.hasResourceBroker) {
4070
+ const webhookBaseUrl = process.env.GATEWAY_PUBLIC_URL ?? `http://localhost:${config.port + (Number(process.env.KB_NET_OFFSET) || 0)}`;
4071
+ const provisionBackend = {
4072
+ async execute({ handlerRef, pluginRoot, input, namespaceId }) {
4073
+ if (!namespaceId) {
4074
+ return;
4075
+ }
4076
+ const hostId = globalDispatcher.firstHostWithCapability(namespaceId, "execution");
4077
+ if (!hostId) {
4078
+ return;
4079
+ }
4080
+ return globalDispatcher.call(namespaceId, hostId, "execution", "execute", [
4081
+ { handlerRef, pluginRoot, input }
4082
+ ]);
2413
4083
  }
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;
4084
+ };
4085
+ registerWebhookAdminRoutes(
4086
+ scope,
4087
+ {
4088
+ cache,
4089
+ logger,
4090
+ backend: provisionBackend,
4091
+ manifests: webhookManifests ?? [],
4092
+ baseUrl: webhookBaseUrl,
4093
+ broker: platform.resourceBroker
2431
4094
  }
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
- });
4095
+ );
4096
+ }
2440
4097
  });
4098
+ if (webhookManifests?.length && platform.hasResourceBroker) {
4099
+ process.env.GATEWAY_PUBLIC_URL ?? `http://localhost:${config.port + (Number(process.env.KB_NET_OFFSET) || 0)}`;
4100
+ await app.register(async (webhookScope) => {
4101
+ await registerWebhookRoutes(webhookScope, {
4102
+ cache,
4103
+ broker: platform.resourceBroker,
4104
+ logger,
4105
+ manifests: webhookManifests});
4106
+ });
4107
+ }
2441
4108
  await app.ready();
2442
- attachGatewayWs(app.server, cache, jwtConfig, logger, registry);
4109
+ attachGatewayWs(app.server, cache, jwtConfig, logger, registry, socketWsUpstreams);
2443
4110
  return app;
2444
4111
  }
2445
4112
 
2446
4113
  // src/bootstrap.ts
2447
4114
  async function bootstrap(repoRoot = process.cwd()) {
2448
- await createServiceBootstrap({ appId: "gateway", repoRoot });
4115
+ await createServiceBootstrap({ appId: "gateway", repoRoot, assemblyHook: makeAssemblyHook() });
2449
4116
  const logger = createCorrelatedLogger(platform.logger, {
2450
4117
  serviceId: "gateway",
2451
4118
  logsSource: "gateway",
@@ -2454,20 +4121,123 @@ async function bootstrap(repoRoot = process.cwd()) {
2454
4121
  operation: "gateway.bootstrap"
2455
4122
  });
2456
4123
  logger.info("Platform initialized", { repoRoot });
2457
- const config = await loadGatewayConfig(repoRoot, getPlatformRoot());
4124
+ const config = await loadGatewayConfig(getProjectRoot() ?? repoRoot, getPlatformRoot());
2458
4125
  logger.info("Gateway config loaded", {
2459
4126
  port: config.port,
2460
- upstreams: Object.keys(config.upstreams)
4127
+ upstreams: Object.keys(config.upstreams),
4128
+ projectRoot: getProjectRoot() ?? repoRoot,
4129
+ platformRoot: getPlatformRoot(),
4130
+ configProviderIds: config.auth?.providers ? Object.keys(config.auth.providers) : []
2461
4131
  });
2462
4132
  let hostStore;
2463
- const db = platform.getAdapter("sqlDatabase");
2464
- if (db) {
2465
- hostStore = new SqliteHostStore(db);
2466
- logger.info("Host store: SQLite (persistent)");
4133
+ const configuredDocs = platform.getAdapter("documentDatabase");
4134
+ const docs = configuredDocs ?? createInMemoryDocumentDatabase();
4135
+ if (configuredDocs) {
4136
+ hostStore = new HostStore(docs);
4137
+ logger.info("Host store: documentDatabase-backed (persistent)");
2467
4138
  } else {
2468
- logger.warn("Host store: none (cache-only, hosts will be lost on restart)");
4139
+ hostStore = new HostStore(docs);
4140
+ logger.warn("documentDatabase: in-memory fallback (data lost on restart)");
4141
+ logger.warn("Host store: in-memory (hosts will be lost on restart)");
2469
4142
  }
2470
- const registry = new HostRegistry(platform.cache, hostStore);
4143
+ let userAuth;
4144
+ {
4145
+ const accessTtlSec = config.auth?.sessionAccessTtlSec ?? (process.env.AUTH_ACCESS_TTL_SEC ? parseInt(process.env.AUTH_ACCESS_TTL_SEC, 10) : 900);
4146
+ const refreshTtlSec = config.auth?.sessionRefreshTtlSec ?? (process.env.AUTH_REFRESH_TTL_SEC ? parseInt(process.env.AUTH_REFRESH_TTL_SEC, 10) : 30 * 24 * 3600);
4147
+ const graceWindowMs = (config.auth?.refreshGraceWindowSec ?? 5) * 1e3;
4148
+ const bcryptCost = config.auth?.bcryptCost ?? 12;
4149
+ const cookieSecure = config.auth?.cookieSecure ?? (process.env.AUTH_COOKIE_SECURE === "false" ? false : true);
4150
+ const tenantPattern = config.tenants?.pattern ?? "{tenant}.kblabs.ru";
4151
+ const bootstrapTenantId = config.auth?.bootstrap?.tenantId ?? process.env.GATEWAY_BOOTSTRAP_TENANT_ID ?? "kblabs-cloud";
4152
+ const users = new UsersStore(docs);
4153
+ const credentials = new CredentialsStore(docs);
4154
+ const memberships = new MembershipsStore(docs);
4155
+ const sessions = new SessionsStore(docs, {
4156
+ refreshTtlMs: refreshTtlSec * 1e3,
4157
+ graceWindowMs
4158
+ });
4159
+ const invites = new InvitesStore(docs);
4160
+ const providers = await loadIdentityProviders(config.auth?.providers, {
4161
+ users,
4162
+ credentials,
4163
+ tenantId: bootstrapTenantId,
4164
+ bcryptCost,
4165
+ logger
4166
+ });
4167
+ const passwordPolicy = createPasswordPolicy({
4168
+ minLength: config.auth?.passwordPolicy?.minLength ?? 8,
4169
+ maxLength: config.auth?.passwordPolicy?.maxLength ?? 256,
4170
+ hibpEnabled: config.auth?.passwordPolicy?.hibpEnabled ?? true
4171
+ });
4172
+ const pdp = createStubPDP({ memberships });
4173
+ const tenantResolver = createTenantResolver({ pattern: tenantPattern });
4174
+ const userAuthService = createUserAuthService({
4175
+ users,
4176
+ credentials,
4177
+ memberships,
4178
+ sessions,
4179
+ invites,
4180
+ providers,
4181
+ passwordPolicy,
4182
+ jwtConfig: { secret: process.env.GATEWAY_JWT_SECRET ?? "dev-insecure-secret-change-me" },
4183
+ accessTtlSec,
4184
+ refreshTtlSec,
4185
+ bcryptCost
4186
+ });
4187
+ const adminEmail = config.auth?.bootstrap?.adminEmail ?? process.env.GATEWAY_BOOTSTRAP_ADMIN_EMAIL;
4188
+ const adminPassword = process.env.GATEWAY_BOOTSTRAP_ADMIN_PASSWORD;
4189
+ await ensureBootstrapAdmin({
4190
+ bootstrap: adminEmail && adminPassword ? { adminEmail, adminPassword, tenantId: bootstrapTenantId } : void 0,
4191
+ users,
4192
+ credentials,
4193
+ memberships,
4194
+ bcryptCost,
4195
+ logger
4196
+ }).catch((err) => {
4197
+ logger.warn("Bootstrap admin seed failed (non-fatal)", {
4198
+ error: err instanceof Error ? err.message : String(err)
4199
+ });
4200
+ });
4201
+ const inviteTtlMs = process.env.AUTH_INVITE_TTL_MS ? parseInt(process.env.AUTH_INVITE_TTL_MS, 10) : config.auth?.inviteTtlMs ?? 7 * 24 * 60 * 60 * 1e3;
4202
+ const kv = platform.getAdapter("kvStore") ?? createInMemoryKVStore();
4203
+ const rateLimiter = createRateLimiter(kv);
4204
+ if (!platform.getAdapter("kvStore")) {
4205
+ logger.warn("kvStore adapter not configured \u2014 auth rate limiting using in-memory KV (counters reset on restart)");
4206
+ }
4207
+ 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;
4208
+ 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;
4209
+ const oauthState = new OAuthStateStore(kv);
4210
+ const hasRedirectProvider = providers.list().some((p) => p.kind === "redirect");
4211
+ if (hasRedirectProvider && !platform.getAdapter("kvStore")) {
4212
+ logger.warn(
4213
+ "OAuth requires a shared kvStore (Redis) in multi-process/HA; in-memory state is per-process and callbacks may land on a different worker"
4214
+ );
4215
+ }
4216
+ userAuth = {
4217
+ userAuthService,
4218
+ users,
4219
+ sessions,
4220
+ invites,
4221
+ providers,
4222
+ pdp,
4223
+ tenantResolver,
4224
+ cookieSecure,
4225
+ accessTtlSec,
4226
+ refreshTtlSec,
4227
+ inviteTtlMs,
4228
+ rateLimiter,
4229
+ authRateLimit: { loginPerIpPerMinute, loginPerEmailPerMinute },
4230
+ oauthState
4231
+ };
4232
+ logger.info("User auth infrastructure initialised", {
4233
+ tenantPattern,
4234
+ bootstrapTenantId,
4235
+ cookieSecure,
4236
+ persistent: !!configuredDocs
4237
+ });
4238
+ }
4239
+ const cache = platform.cache;
4240
+ const registry = new HostRegistry(cache, hostStore);
2471
4241
  let restoredCount = 0;
2472
4242
  try {
2473
4243
  restoredCount = await registry.restore();
@@ -2489,9 +4259,29 @@ async function bootstrap(repoRoot = process.cwd()) {
2489
4259
  if (restoredCount > 0) {
2490
4260
  logger.info("Restored hosts from store", { count: restoredCount });
2491
4261
  }
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 });
4262
+ let webhookManifests = [];
4263
+ try {
4264
+ const projectRoot = getProjectRoot() ?? repoRoot;
4265
+ const platformRoot = getPlatformRoot();
4266
+ const pluginRegistry = await createRegistry({
4267
+ root: projectRoot,
4268
+ platformRoot: platformRoot !== projectRoot ? platformRoot : void 0,
4269
+ cache: { ttlMs: 6e5, adapter: cache }
4270
+ });
4271
+ const snapshot = pluginRegistry.snapshot();
4272
+ webhookManifests = snapshot.manifests.filter((entry) => (entry.manifest.webhooks?.handlers?.length ?? 0) > 0).map((entry) => ({
4273
+ pluginId: entry.pluginId,
4274
+ manifest: entry.manifest,
4275
+ pluginRoot: entry.pluginRoot
4276
+ }));
4277
+ if (webhookManifests.length > 0) {
4278
+ logger.info("Webhook manifests discovered", { count: webhookManifests.length });
4279
+ }
4280
+ } catch (err) {
4281
+ logger.warn("Webhook manifest discovery failed \u2014 webhook routes disabled", {
4282
+ error: err instanceof Error ? err.message : String(err)
4283
+ });
4284
+ webhookManifests = [];
2495
4285
  }
2496
4286
  const DEV_JWT_SECRET = "dev-insecure-secret-change-me";
2497
4287
  const jwtSecret = process.env.GATEWAY_JWT_SECRET;
@@ -2505,9 +4295,28 @@ async function bootstrap(repoRoot = process.cwd()) {
2505
4295
  logger.warn("GATEWAY_JWT_SECRET not set \u2014 using insecure default (dev only, never use in production!)");
2506
4296
  }
2507
4297
  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 });
4298
+ if (platform.hasResourceBroker) {
4299
+ registerPressureLimits(platform.resourceBroker, config.pressure, platform.logger);
4300
+ } else {
4301
+ logger.warn("Resource broker unavailable \u2014 pressure control disabled");
4302
+ }
4303
+ const serviceTransport = platform.getAdapter("serviceTransport");
4304
+ if (!serviceTransport) {
4305
+ throw new Error(
4306
+ '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" }, ... } } }'
4307
+ );
4308
+ }
4309
+ const server = await createServer(config, cache, platform.logger, jwtConfig, registry, serviceTransport, userAuth, webhookManifests);
4310
+ const bindHost = config.host ?? "0.0.0.0";
4311
+ if (config.auth?.enabled === false && !isLoopbackHost(bindHost)) {
4312
+ throw new Error(
4313
+ `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.`
4314
+ );
4315
+ }
4316
+ const netOffset = Number(process.env.KB_NET_OFFSET) || 0;
4317
+ const listenPort = config.port + netOffset;
4318
+ const address = await server.listen({ port: listenPort, host: bindHost });
4319
+ logger.info("Gateway listening", { address, authEnabled: config.auth?.enabled !== false });
2511
4320
  const shutdown = async (signal) => {
2512
4321
  logger.warn("Received shutdown signal", { signal });
2513
4322
  await platform.shutdown();
@@ -2518,6 +4327,10 @@ async function bootstrap(repoRoot = process.cwd()) {
2518
4327
  process.on("SIGTERM", () => shutdown("SIGTERM"));
2519
4328
  process.on("SIGINT", () => shutdown("SIGINT"));
2520
4329
  }
4330
+ function isLoopbackHost(host) {
4331
+ const h = host.trim().toLowerCase();
4332
+ return h === "127.0.0.1" || h === "localhost" || h === "::1" || h === "[::1]" || h.startsWith("127.");
4333
+ }
2521
4334
 
2522
4335
  // src/index.ts
2523
4336
  bootstrap(process.cwd()).catch((error) => {