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