@cosmicdrift/kumiko-framework 0.326.0 → 0.327.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/package.json +4 -4
- package/src/api/__tests__/auth-middleware-transport.test.ts +23 -1
- package/src/api/__tests__/client-ip.test.ts +20 -0
- package/src/api/__tests__/origin-middleware.test.ts +65 -0
- package/src/api/__tests__/websocket-route.test.ts +552 -0
- package/src/api/auth-middleware.ts +29 -7
- package/src/api/client-ip.ts +15 -2
- package/src/api/index.ts +24 -1
- package/src/api/jwt.ts +5 -0
- package/src/api/origin-middleware.ts +27 -1
- package/src/api/server.ts +161 -2
- package/src/api/websocket-route.ts +327 -0
- package/src/bun-db/query.ts +7 -5
- package/src/changes.json +18 -0
- package/src/crypto/__tests__/system-event-pii.test.ts +77 -0
- package/src/crypto/event-pii.ts +20 -4
- package/src/crypto/system-event-pii.ts +57 -0
- package/src/db/__tests__/apply-entity-event-removed-column.integration.test.ts +109 -0
- package/src/engine/__tests__/describe-workflow-step-error.test.ts +17 -0
- package/src/engine/__tests__/steps-mail-send.test.ts +6 -5
- package/src/engine/__tests__/steps-webhook-send.test.ts +16 -8
- package/src/engine/define-feature.ts +1 -0
- package/src/engine/feature-builder-state.ts +3 -0
- package/src/engine/feature-ui-extensions.ts +52 -0
- package/src/engine/index.ts +5 -0
- package/src/engine/steps/describe-workflow-step-error.ts +6 -0
- package/src/engine/steps/mail-send.ts +5 -1
- package/src/engine/steps/retry.ts +13 -1
- package/src/engine/steps/webhook-send.ts +6 -16
- package/src/engine/types/index.ts +7 -0
- package/src/event-store/provenance-append.ts +1 -1
- package/src/event-store/transfer.ts +2 -2
- package/src/jobs/__tests__/jobs.integration.test.ts +31 -0
- package/src/jobs/job-runner.ts +18 -0
- package/src/pipeline/append-event-core.ts +1 -8
- package/src/pipeline/irreversible-operation-gate.ts +4 -3
package/src/api/jwt.ts
CHANGED
|
@@ -25,6 +25,8 @@ export type JwtPayload = {
|
|
|
25
25
|
// Present when the app wires a `sessionCreator` callback (see sessions
|
|
26
26
|
// feature). Absent → stateless-JWT mode, no revocation possible.
|
|
27
27
|
jti?: string;
|
|
28
|
+
// Expiry as epoch seconds, so long-lived consumers (WebSocket) can enforce it after the upgrade.
|
|
29
|
+
exp?: number;
|
|
28
30
|
};
|
|
29
31
|
|
|
30
32
|
export type JwtHelper = {
|
|
@@ -184,6 +186,9 @@ export function createJwtHelper(
|
|
|
184
186
|
if (typeof payload.jti === "string") {
|
|
185
187
|
result.jti = payload.jti;
|
|
186
188
|
}
|
|
189
|
+
if (typeof payload.exp === "number") {
|
|
190
|
+
result.exp = payload.exp;
|
|
191
|
+
}
|
|
187
192
|
return result;
|
|
188
193
|
},
|
|
189
194
|
ttlSeconds,
|
|
@@ -31,7 +31,7 @@ export function isForeignCookieOrigin(c: Context, allowlist: ReadonlySet<string>
|
|
|
31
31
|
return c.req.header("sec-fetch-site") === "cross-site";
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
function rejectOrigin(c: Context): Response {
|
|
34
|
+
export function rejectOrigin(c: Context): Response {
|
|
35
35
|
return c.json(
|
|
36
36
|
{
|
|
37
37
|
error: {
|
|
@@ -45,6 +45,32 @@ function rejectOrigin(c: Context): Response {
|
|
|
45
45
|
);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// WebSocket upgrades are GETs, so originMiddleware/csrfMiddleware never see
|
|
49
|
+
// them — yet a cookie-authenticated upgrade from a foreign page is a
|
|
50
|
+
// cross-site WebSocket hijack. Bearer transports can't be set by a browser
|
|
51
|
+
// WebSocket and need no check.
|
|
52
|
+
export function isWebSocketOriginAllowed(
|
|
53
|
+
c: Context,
|
|
54
|
+
normalizedAllowlist: ReadonlySet<string> | undefined,
|
|
55
|
+
): boolean {
|
|
56
|
+
if (getAuthTransport(c) !== "cookie") return true;
|
|
57
|
+
const origin = c.req.header("origin");
|
|
58
|
+
if (origin === undefined || origin === "null") return false;
|
|
59
|
+
let originHost: string;
|
|
60
|
+
try {
|
|
61
|
+
originHost = new URL(origin).host.toLowerCase();
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
// With an allowlist there is deliberately no same-host fallback: wide
|
|
66
|
+
// cookieDomain setups are exactly where a sibling subdomain is the attacker.
|
|
67
|
+
if (normalizedAllowlist !== undefined && normalizedAllowlist.size > 0) {
|
|
68
|
+
return isOriginAllowed(origin, normalizedAllowlist);
|
|
69
|
+
}
|
|
70
|
+
const requestHost = c.req.header("host")?.toLowerCase();
|
|
71
|
+
return requestHost !== undefined && originHost === requestHost;
|
|
72
|
+
}
|
|
73
|
+
|
|
48
74
|
// Server-side Origin-allowlist guard — an additional CSRF-hardening layer on
|
|
49
75
|
// top of the double-submit token (csrf-middleware) for deployments that widen
|
|
50
76
|
// the auth cookie across subdomains via AuthRoutesConfig.cookieDomain. A wide
|
package/src/api/server.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type RunIn,
|
|
16
16
|
type SessionUser,
|
|
17
17
|
type TenantId,
|
|
18
|
+
type WebSocketRouteDefinition,
|
|
18
19
|
type WriteResult,
|
|
19
20
|
} from "../engine/types";
|
|
20
21
|
import { createFileContext } from "../files/file-handle";
|
|
@@ -22,6 +23,7 @@ import type { FileRoutesOptions } from "../files/file-routes";
|
|
|
22
23
|
import { createFileRoutes, readFilesRouteOptions } from "../files/file-routes";
|
|
23
24
|
import { makeFileProviderResolver } from "../files/provider-resolver";
|
|
24
25
|
import type { Lifecycle } from "../lifecycle";
|
|
26
|
+
import { createFallbackLogger } from "../logging";
|
|
25
27
|
import {
|
|
26
28
|
createNoopProvider,
|
|
27
29
|
DEFAULT_SENSITIVE_CONFIG,
|
|
@@ -60,7 +62,9 @@ import { assertUnreachable, generateId } from "../utils";
|
|
|
60
62
|
import { NO_ROUTE_MATCH_HEADER_NAME, PUBLIC_API_PATHS, Routes } from "./api-constants";
|
|
61
63
|
import {
|
|
62
64
|
type AnonymousAccessResolved,
|
|
65
|
+
type AuthSessionChecker,
|
|
63
66
|
authMiddleware,
|
|
67
|
+
getAuthTokenExpiry,
|
|
64
68
|
getUser,
|
|
65
69
|
type TenantLifecycleStatusResolver,
|
|
66
70
|
} from "./auth-middleware";
|
|
@@ -82,11 +86,17 @@ import {
|
|
|
82
86
|
import { computeStrongEtag, etagMatches } from "./http-cache";
|
|
83
87
|
import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
|
|
84
88
|
import { observabilityMiddleware } from "./observability-middleware";
|
|
85
|
-
import {
|
|
89
|
+
import {
|
|
90
|
+
assertOriginGuardConfig,
|
|
91
|
+
isWebSocketOriginAllowed,
|
|
92
|
+
normalizeOrigin,
|
|
93
|
+
originMiddleware,
|
|
94
|
+
rejectOrigin,
|
|
95
|
+
} from "./origin-middleware";
|
|
86
96
|
import { patRouteGuard } from "./pat-route-guard";
|
|
87
97
|
import { piiCiphertextResponseGuard } from "./pii-leak-guard";
|
|
88
98
|
import { createDefaultSseBroker, type RedisSseBroker } from "./redis-sse-broker";
|
|
89
|
-
import { requestContext } from "./request-context";
|
|
99
|
+
import { type RequestContextData, requestContext } from "./request-context";
|
|
90
100
|
import { buildRequestContextData, requestIdMiddleware } from "./request-id-middleware";
|
|
91
101
|
import {
|
|
92
102
|
DEFAULT_MAX_REQUEST_BYTES,
|
|
@@ -98,6 +108,15 @@ import {
|
|
|
98
108
|
import { createApiRoutes } from "./routes";
|
|
99
109
|
import type { SseBroker } from "./sse-broker";
|
|
100
110
|
import { createSseRoute } from "./sse-route";
|
|
111
|
+
import {
|
|
112
|
+
buildWebSocketSessionRevalidator,
|
|
113
|
+
createWebSocketConnectionLimiter,
|
|
114
|
+
hasWebSocketUpgradeServer,
|
|
115
|
+
isWebSocketUpgradeRequest,
|
|
116
|
+
WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER,
|
|
117
|
+
WEBSOCKET_DEFAULT_MAX_MESSAGE_BYTES,
|
|
118
|
+
type WebSocketConnectionLimiter,
|
|
119
|
+
} from "./websocket-route";
|
|
101
120
|
|
|
102
121
|
export type ServerOptions = {
|
|
103
122
|
registry: Registry;
|
|
@@ -985,6 +1004,39 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
985
1004
|
}
|
|
986
1005
|
}
|
|
987
1006
|
|
|
1007
|
+
// r.webSocketRoute — GET routes under /api/ws/*, so the jwt/pat/rate-limit
|
|
1008
|
+
// chain above already ran. Origin/CSRF guards skip GETs, hence the own check.
|
|
1009
|
+
const webSocketOriginAllowlist = allowedOrigins?.length
|
|
1010
|
+
? new Set(allowedOrigins.map(normalizeOrigin))
|
|
1011
|
+
: undefined;
|
|
1012
|
+
const webSocketRoutePaths = new Map<string, string>();
|
|
1013
|
+
const webSocketConnectionLimiter = createWebSocketConnectionLimiter();
|
|
1014
|
+
for (const feature of options.registry.features.values()) {
|
|
1015
|
+
for (const route of Object.values(feature.webSocketRoutes)) {
|
|
1016
|
+
const owner = webSocketRoutePaths.get(route.path);
|
|
1017
|
+
if (owner !== undefined) {
|
|
1018
|
+
throw new Error(
|
|
1019
|
+
`[kumiko] WebSocket route "${route.path}" is declared by both feature "${owner}" and ` +
|
|
1020
|
+
`"${feature.name}" — paths must be unique across features.`,
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
webSocketRoutePaths.set(route.path, feature.name);
|
|
1024
|
+
app.get(
|
|
1025
|
+
route.path,
|
|
1026
|
+
buildWebSocketRouteHandler(route, {
|
|
1027
|
+
dispatcher,
|
|
1028
|
+
clientIpResolver,
|
|
1029
|
+
originAllowlist: webSocketOriginAllowlist,
|
|
1030
|
+
...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
|
|
1031
|
+
...(tenantLifecycleResolver
|
|
1032
|
+
? { resolveTenantLifecycleStatus: tenantLifecycleResolver }
|
|
1033
|
+
: {}),
|
|
1034
|
+
connectionLimiter: webSocketConnectionLimiter,
|
|
1035
|
+
}),
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
988
1040
|
// extraRoutes (kumiko-framework#3050) — declarative HTTP-routes with a
|
|
989
1041
|
// Pflicht `entry` tier. Mounted after r.httpRoute for the same reason: an
|
|
990
1042
|
// extraRoute dispatching through `dispatcher` builds Hono's matcher, so
|
|
@@ -1218,6 +1270,113 @@ function unauthenticatedResponse(c: import("hono").Context): Response {
|
|
|
1218
1270
|
);
|
|
1219
1271
|
}
|
|
1220
1272
|
|
|
1273
|
+
type WebSocketRouteHandlerDeps = {
|
|
1274
|
+
readonly dispatcher: Dispatcher;
|
|
1275
|
+
readonly clientIpResolver: ClientIpResolver;
|
|
1276
|
+
readonly originAllowlist: ReadonlySet<string> | undefined;
|
|
1277
|
+
readonly sessionChecker?: AuthSessionChecker;
|
|
1278
|
+
readonly resolveTenantLifecycleStatus?: TenantLifecycleStatusResolver;
|
|
1279
|
+
readonly connectionLimiter: WebSocketConnectionLimiter;
|
|
1280
|
+
};
|
|
1281
|
+
|
|
1282
|
+
const webSocketLog = createFallbackLogger("websocket");
|
|
1283
|
+
|
|
1284
|
+
function webSocketErrorResponse(
|
|
1285
|
+
c: import("hono").Context,
|
|
1286
|
+
status: 400 | 426 | 429 | 501,
|
|
1287
|
+
code: string,
|
|
1288
|
+
message: string,
|
|
1289
|
+
): Response {
|
|
1290
|
+
return c.json({ error: { code, httpStatus: status, message } }, status);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function buildWebSocketRouteHandler(
|
|
1294
|
+
route: WebSocketRouteDefinition,
|
|
1295
|
+
shared: WebSocketRouteHandlerDeps,
|
|
1296
|
+
// biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
|
|
1297
|
+
): (c: import("hono").Context<any, any>) => Promise<Response> {
|
|
1298
|
+
return async (c) => {
|
|
1299
|
+
if (!isWebSocketOriginAllowed(c, shared.originAllowlist)) return rejectOrigin(c);
|
|
1300
|
+
const user = getUser(c);
|
|
1301
|
+
if (isMissingOrAnonymousUser(user)) return unauthenticatedResponse(c);
|
|
1302
|
+
if (!isWebSocketUpgradeRequest(c.req.raw)) {
|
|
1303
|
+
return webSocketErrorResponse(
|
|
1304
|
+
c,
|
|
1305
|
+
426,
|
|
1306
|
+
"websocket_upgrade_required",
|
|
1307
|
+
"this route only accepts WebSocket upgrade requests",
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
const honoEnv: unknown = c.env;
|
|
1311
|
+
if (!hasWebSocketUpgradeServer(honoEnv)) {
|
|
1312
|
+
const message = "webSocketUpgradeFetch not wired: pass it to buildBunServeOptions";
|
|
1313
|
+
webSocketLog.error(message, { path: route.path });
|
|
1314
|
+
return webSocketErrorResponse(c, 501, "websocket_upgrade_not_wired", message);
|
|
1315
|
+
}
|
|
1316
|
+
// The slot is taken before connect (a refused attempt must not run it) and stays synchronous with
|
|
1317
|
+
// the check, so concurrent upgrades can't overshoot the cap. Every path without a live socket frees it.
|
|
1318
|
+
const releaseConnectionSlot = shared.connectionLimiter.tryAcquire(
|
|
1319
|
+
`${route.path}\n${user.tenantId}\n${user.id}`,
|
|
1320
|
+
route.maxConnectionsPerUser ?? WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER,
|
|
1321
|
+
);
|
|
1322
|
+
if (!releaseConnectionSlot) {
|
|
1323
|
+
return webSocketErrorResponse(
|
|
1324
|
+
c,
|
|
1325
|
+
429,
|
|
1326
|
+
"websocket_connection_limit",
|
|
1327
|
+
"too many open WebSocket connections for this user",
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
let upgraded = false;
|
|
1331
|
+
try {
|
|
1332
|
+
const handlers = await route.connect(c, {
|
|
1333
|
+
user,
|
|
1334
|
+
query: (type, payload) => shared.dispatcher.query(type, payload, user),
|
|
1335
|
+
write: (type, payload) => shared.dispatcher.write(type, payload, user),
|
|
1336
|
+
clientIp: shared.clientIpResolver.resolve(clientIpSourceFromHonoContext(c)),
|
|
1337
|
+
});
|
|
1338
|
+
if (handlers instanceof Response) return handlers;
|
|
1339
|
+
|
|
1340
|
+
const captured = requestContext.get();
|
|
1341
|
+
const requestContextData = captured ? withoutAbortSignal(captured) : undefined;
|
|
1342
|
+
const revalidateSession = buildWebSocketSessionRevalidator({
|
|
1343
|
+
user,
|
|
1344
|
+
sessionChecker: shared.sessionChecker,
|
|
1345
|
+
resolveTenantLifecycleStatus: shared.resolveTenantLifecycleStatus,
|
|
1346
|
+
tokenExpiresAtSec: getAuthTokenExpiry(c),
|
|
1347
|
+
});
|
|
1348
|
+
upgraded = honoEnv.server.upgrade(c.req.raw, {
|
|
1349
|
+
data: {
|
|
1350
|
+
handlers,
|
|
1351
|
+
maxMessageBytes: route.maxMessageBytes ?? WEBSOCKET_DEFAULT_MAX_MESSAGE_BYTES,
|
|
1352
|
+
requestContextData,
|
|
1353
|
+
revalidateSession,
|
|
1354
|
+
releaseConnectionSlot,
|
|
1355
|
+
},
|
|
1356
|
+
});
|
|
1357
|
+
if (!upgraded) {
|
|
1358
|
+
return webSocketErrorResponse(
|
|
1359
|
+
c,
|
|
1360
|
+
400,
|
|
1361
|
+
"websocket_upgrade_failed",
|
|
1362
|
+
"the WebSocket upgrade was rejected",
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
// Bun ignores the returned Response after a successful upgrade.
|
|
1366
|
+
return new Response(null);
|
|
1367
|
+
} finally {
|
|
1368
|
+
if (!upgraded) releaseConnectionSlot();
|
|
1369
|
+
}
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// The request's AbortSignal fires when the upgrade request ends, which would
|
|
1374
|
+
// abort every later handler running under the captured context.
|
|
1375
|
+
function withoutAbortSignal(data: RequestContextData): RequestContextData {
|
|
1376
|
+
const { signal: _signal, ...rest } = data;
|
|
1377
|
+
return rest;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1221
1380
|
function isKnownExtraRouteEntry(entry: unknown): entry is ExtraRouteEntry {
|
|
1222
1381
|
return (
|
|
1223
1382
|
entry === ExtraRouteEntries.anonymous ||
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import {
|
|
2
|
+
WEBSOCKET_BACKPRESSURE_LIMIT_BYTES,
|
|
3
|
+
WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER,
|
|
4
|
+
WEBSOCKET_DEFAULT_MAX_MESSAGE_BYTES,
|
|
5
|
+
WEBSOCKET_MAX_PAYLOAD_BYTES,
|
|
6
|
+
WEBSOCKET_ROUTE_PATH_PREFIX,
|
|
7
|
+
type WebSocketConnection,
|
|
8
|
+
type WebSocketMessageData,
|
|
9
|
+
type WebSocketSessionHandlers,
|
|
10
|
+
} from "@cosmicdrift/kumiko-types/websocket-route";
|
|
11
|
+
import type { SessionUser } from "../engine/types";
|
|
12
|
+
import { createFallbackLogger } from "../logging";
|
|
13
|
+
import {
|
|
14
|
+
type AuthSessionChecker,
|
|
15
|
+
resolveTenantTeardownStatus,
|
|
16
|
+
sessionCheckStatus,
|
|
17
|
+
type TenantLifecycleStatusResolver,
|
|
18
|
+
} from "./auth-middleware";
|
|
19
|
+
import { type RequestContextData, requestContext } from "./request-context";
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
WEBSOCKET_BACKPRESSURE_LIMIT_BYTES,
|
|
23
|
+
WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER,
|
|
24
|
+
WEBSOCKET_DEFAULT_MAX_MESSAGE_BYTES,
|
|
25
|
+
WEBSOCKET_MAX_PAYLOAD_BYTES,
|
|
26
|
+
WEBSOCKET_ROUTE_PATH_PREFIX,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Must stay below ingress-nginx's default 60 s proxy-read-timeout, or idle sockets get cut.
|
|
30
|
+
export const WEBSOCKET_HEARTBEAT_INTERVAL_MS = 25_000;
|
|
31
|
+
|
|
32
|
+
const CLOSE_POLICY_VIOLATION = 1008;
|
|
33
|
+
const CLOSE_MESSAGE_TOO_BIG = 1009;
|
|
34
|
+
const CLOSE_INTERNAL_ERROR = 1011;
|
|
35
|
+
const CLOSE_TRY_AGAIN_LATER = 1013;
|
|
36
|
+
|
|
37
|
+
// Consecutive failed session-store checks before a socket is closed: rides out a
|
|
38
|
+
// short store blip, but a socket must not stay unverified forever.
|
|
39
|
+
export const WEBSOCKET_REVALIDATION_FAILURE_LIMIT = 3;
|
|
40
|
+
|
|
41
|
+
const log = createFallbackLogger("websocket");
|
|
42
|
+
|
|
43
|
+
export type KumikoWebSocketData = {
|
|
44
|
+
readonly handlers: WebSocketSessionHandlers;
|
|
45
|
+
readonly maxMessageBytes: number;
|
|
46
|
+
// Socket callbacks run outside the Hono request, so the dispatcher's
|
|
47
|
+
// AsyncLocalStorage context is captured at upgrade time and re-entered.
|
|
48
|
+
readonly requestContextData: RequestContextData | undefined;
|
|
49
|
+
readonly revalidateSession: WebSocketSessionRevalidator | undefined;
|
|
50
|
+
// Frees this socket's per-user connection slot; called once on close.
|
|
51
|
+
readonly releaseConnectionSlot?: (() => void) | undefined;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type WebSocketRevalidation = "live" | "expired" | "invalid";
|
|
55
|
+
export type WebSocketSessionRevalidator = () => Promise<WebSocketRevalidation>;
|
|
56
|
+
|
|
57
|
+
// Structural so Bun.Server satisfies it without this package depending on bun types.
|
|
58
|
+
export type WebSocketUpgradeServer = {
|
|
59
|
+
upgrade(req: Request, options: { data: KumikoWebSocketData }): boolean;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Second argument to `app.fetch(req, env)` on upgrade requests; plain requests
|
|
63
|
+
// keep passing the bare socket-address string.
|
|
64
|
+
export type KumikoServeEnv = {
|
|
65
|
+
readonly socketAddress?: string;
|
|
66
|
+
readonly server?: WebSocketUpgradeServer;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type KumikoServerWebSocket = {
|
|
70
|
+
readonly data: KumikoWebSocketData;
|
|
71
|
+
send(data: WebSocketMessageData): unknown;
|
|
72
|
+
close(code?: number, reason?: string): void;
|
|
73
|
+
ping(): unknown;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export function isWebSocketUpgradeRequest(req: Request): boolean {
|
|
77
|
+
return req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function hasWebSocketUpgradeServer(
|
|
81
|
+
honoEnv: unknown,
|
|
82
|
+
): honoEnv is { readonly server: WebSocketUpgradeServer } {
|
|
83
|
+
return (
|
|
84
|
+
typeof honoEnv === "object" &&
|
|
85
|
+
honoEnv !== null &&
|
|
86
|
+
"server" in honoEnv &&
|
|
87
|
+
typeof honoEnv.server === "object" &&
|
|
88
|
+
honoEnv.server !== null &&
|
|
89
|
+
"upgrade" in honoEnv.server &&
|
|
90
|
+
typeof honoEnv.server.upgrade === "function"
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function sameRoles(a: readonly string[], b: readonly string[]): boolean {
|
|
95
|
+
const left = new Set(a);
|
|
96
|
+
return left.size === new Set(b).size && b.every((role) => left.has(role));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Same checks the HTTP guard runs per request, plus the JWT's own expiry (the
|
|
100
|
+
// upgrade request passed the guard once, the socket outlives that). query/write
|
|
101
|
+
// stay bound to the upgrade-time user, so changed roles close the socket
|
|
102
|
+
// instead of silently serving stale permissions.
|
|
103
|
+
export function buildWebSocketSessionRevalidator(deps: {
|
|
104
|
+
readonly user: SessionUser;
|
|
105
|
+
readonly sessionChecker?: AuthSessionChecker | undefined;
|
|
106
|
+
readonly resolveTenantLifecycleStatus?: TenantLifecycleStatusResolver | undefined;
|
|
107
|
+
/** JWT exp, epoch seconds. */
|
|
108
|
+
readonly tokenExpiresAtSec?: number | undefined;
|
|
109
|
+
readonly nowMs?: () => number;
|
|
110
|
+
}): WebSocketSessionRevalidator | undefined {
|
|
111
|
+
const { user, sessionChecker, resolveTenantLifecycleStatus, tokenExpiresAtSec } = deps;
|
|
112
|
+
const nowMs = deps.nowMs ?? Date.now;
|
|
113
|
+
const checkSession = sessionChecker && user.sid ? sessionChecker : undefined;
|
|
114
|
+
if (!checkSession && !resolveTenantLifecycleStatus && tokenExpiresAtSec === undefined) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
return async () => {
|
|
118
|
+
if (tokenExpiresAtSec !== undefined && nowMs() >= tokenExpiresAtSec * 1000) return "expired";
|
|
119
|
+
if (checkSession && user.sid) {
|
|
120
|
+
const result = await checkSession(user.sid, user.id);
|
|
121
|
+
if (sessionCheckStatus(result) !== "live") return "invalid";
|
|
122
|
+
if (typeof result === "object" && !sameRoles(result.roles, user.roles)) return "invalid";
|
|
123
|
+
}
|
|
124
|
+
const teardown = await resolveTenantTeardownStatus(user.tenantId, resolveTenantLifecycleStatus);
|
|
125
|
+
return teardown === undefined ? "live" : "invalid";
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ponytail: per-process counter, so the cap is per pod, not cluster-wide; move to Redis if that matters.
|
|
130
|
+
export type WebSocketConnectionLimiter = {
|
|
131
|
+
readonly tryAcquire: (key: string, max: number) => (() => void) | undefined;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export function createWebSocketConnectionLimiter(): WebSocketConnectionLimiter {
|
|
135
|
+
const counts = new Map<string, number>();
|
|
136
|
+
return {
|
|
137
|
+
tryAcquire(key, max) {
|
|
138
|
+
const current = counts.get(key) ?? 0;
|
|
139
|
+
if (current >= max) return undefined;
|
|
140
|
+
counts.set(key, current + 1);
|
|
141
|
+
let released = false;
|
|
142
|
+
return () => {
|
|
143
|
+
// skip: slot already released
|
|
144
|
+
if (released) return;
|
|
145
|
+
released = true;
|
|
146
|
+
const remaining = (counts.get(key) ?? 1) - 1;
|
|
147
|
+
if (remaining <= 0) counts.delete(key);
|
|
148
|
+
else counts.set(key, remaining);
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
type ConnectionState = {
|
|
155
|
+
timer: ReturnType<typeof setInterval> | undefined;
|
|
156
|
+
revalidationFailures: number;
|
|
157
|
+
revalidating: boolean;
|
|
158
|
+
// onOpen/onMessage run one after another in arrival order; steps never reject.
|
|
159
|
+
chain: Promise<void>;
|
|
160
|
+
closed: boolean;
|
|
161
|
+
readonly controller: AbortController;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const connectionStates = new WeakMap<KumikoWebSocketData, ConnectionState>();
|
|
165
|
+
|
|
166
|
+
function stateOf(ws: KumikoServerWebSocket): ConnectionState {
|
|
167
|
+
let state = connectionStates.get(ws.data);
|
|
168
|
+
if (!state) {
|
|
169
|
+
state = {
|
|
170
|
+
timer: undefined,
|
|
171
|
+
revalidationFailures: 0,
|
|
172
|
+
revalidating: false,
|
|
173
|
+
chain: Promise.resolve(),
|
|
174
|
+
closed: false,
|
|
175
|
+
controller: new AbortController(),
|
|
176
|
+
};
|
|
177
|
+
connectionStates.set(ws.data, state);
|
|
178
|
+
}
|
|
179
|
+
return state;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function toConnection(ws: KumikoServerWebSocket): WebSocketConnection {
|
|
183
|
+
return {
|
|
184
|
+
send: (data) => {
|
|
185
|
+
ws.send(data);
|
|
186
|
+
},
|
|
187
|
+
close: (code, reason) => ws.close(code, reason),
|
|
188
|
+
signal: stateOf(ws).controller.signal,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function reportHandlerFailure(error: unknown): void {
|
|
193
|
+
log.error("websocket handler failed", {
|
|
194
|
+
error: error instanceof Error ? error.message : String(error),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Runs outside the chain: a hung onMessage must not hold back onClose cleanup.
|
|
199
|
+
function runInRequestContext(ws: KumikoServerWebSocket, run: () => Promise<void>): Promise<void> {
|
|
200
|
+
const { requestContextData } = ws.data;
|
|
201
|
+
return requestContextData ? requestContext.run(requestContextData, run) : run();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function enqueue(ws: KumikoServerWebSocket, run: () => void | Promise<void>): void {
|
|
205
|
+
const state = stateOf(ws);
|
|
206
|
+
// skip: the socket is gone, nothing may start after close
|
|
207
|
+
if (state.closed) return;
|
|
208
|
+
const step = async (): Promise<void> => {
|
|
209
|
+
// skip: queued behind a slower handler and the socket closed meanwhile
|
|
210
|
+
if (state.closed) return;
|
|
211
|
+
try {
|
|
212
|
+
await run();
|
|
213
|
+
} catch (error) {
|
|
214
|
+
reportHandlerFailure(error);
|
|
215
|
+
ws.close(CLOSE_INTERNAL_ERROR, "internal error");
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
state.chain = state.chain.then(() => runInRequestContext(ws, step));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function recordRevalidationFailure(
|
|
222
|
+
ws: KumikoServerWebSocket,
|
|
223
|
+
state: ConnectionState,
|
|
224
|
+
reason: string,
|
|
225
|
+
): void {
|
|
226
|
+
// One failing check must not drop every live socket during a store blip, but persistent failure must.
|
|
227
|
+
state.revalidationFailures += 1;
|
|
228
|
+
log.error("websocket heartbeat failed", {
|
|
229
|
+
error: reason,
|
|
230
|
+
consecutiveFailures: state.revalidationFailures,
|
|
231
|
+
});
|
|
232
|
+
if (state.revalidationFailures >= WEBSOCKET_REVALIDATION_FAILURE_LIMIT) {
|
|
233
|
+
ws.close(CLOSE_TRY_AGAIN_LATER, "try again later");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function heartbeat(ws: KumikoServerWebSocket): Promise<void> {
|
|
238
|
+
const state = stateOf(ws);
|
|
239
|
+
try {
|
|
240
|
+
ws.ping();
|
|
241
|
+
} catch (error) {
|
|
242
|
+
log.error("websocket ping failed", {
|
|
243
|
+
error: error instanceof Error ? error.message : String(error),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
const { revalidateSession } = ws.data;
|
|
247
|
+
// skip: no revalidation wired for this socket
|
|
248
|
+
if (!revalidateSession) return;
|
|
249
|
+
// A store that never answers must not pile up checks; an unanswered one counts as a failed check.
|
|
250
|
+
if (state.revalidating) {
|
|
251
|
+
recordRevalidationFailure(ws, state, "previous session check still pending");
|
|
252
|
+
// skip: the pending check owns the verdict; the failure was logged above
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
state.revalidating = true;
|
|
256
|
+
try {
|
|
257
|
+
const verdict = await revalidateSession();
|
|
258
|
+
state.revalidationFailures = 0;
|
|
259
|
+
if (verdict === "expired") ws.close(CLOSE_POLICY_VIOLATION, "session expired");
|
|
260
|
+
else if (verdict === "invalid") ws.close(CLOSE_POLICY_VIOLATION, "session changed");
|
|
261
|
+
} catch (error) {
|
|
262
|
+
recordRevalidationFailure(ws, state, error instanceof Error ? error.message : String(error));
|
|
263
|
+
} finally {
|
|
264
|
+
state.revalidating = false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function toMessageData(message: string | Uint8Array): WebSocketMessageData {
|
|
269
|
+
if (typeof message === "string") return message;
|
|
270
|
+
// Copy: a Buffer view can sit on a larger pooled ArrayBuffer and be reused after the handler returns.
|
|
271
|
+
return new Uint8Array(message);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function messageByteLength(message: string | Uint8Array): number {
|
|
275
|
+
return typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function createKumikoWebSocketHandler(
|
|
279
|
+
options: { readonly heartbeatIntervalMs?: number } = {},
|
|
280
|
+
) {
|
|
281
|
+
const heartbeatIntervalMs = options.heartbeatIntervalMs ?? WEBSOCKET_HEARTBEAT_INTERVAL_MS;
|
|
282
|
+
return {
|
|
283
|
+
open(ws: KumikoServerWebSocket): void {
|
|
284
|
+
stateOf(ws).timer = setInterval(() => {
|
|
285
|
+
void heartbeat(ws);
|
|
286
|
+
}, heartbeatIntervalMs);
|
|
287
|
+
const { onOpen } = ws.data.handlers;
|
|
288
|
+
if (onOpen) enqueue(ws, () => onOpen(toConnection(ws)));
|
|
289
|
+
},
|
|
290
|
+
message(ws: KumikoServerWebSocket, message: string | Uint8Array): void {
|
|
291
|
+
const byteLength = messageByteLength(message);
|
|
292
|
+
if (byteLength > ws.data.maxMessageBytes) {
|
|
293
|
+
ws.close(CLOSE_MESSAGE_TOO_BIG, "message too big");
|
|
294
|
+
log.warn("websocket message over cap", {
|
|
295
|
+
byteLength,
|
|
296
|
+
maxMessageBytes: ws.data.maxMessageBytes,
|
|
297
|
+
});
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const { onMessage } = ws.data.handlers;
|
|
301
|
+
if (onMessage) enqueue(ws, () => onMessage(toMessageData(message), toConnection(ws)));
|
|
302
|
+
},
|
|
303
|
+
close(ws: KumikoServerWebSocket, code: number, reason: string): void {
|
|
304
|
+
const state = stateOf(ws);
|
|
305
|
+
if (state.timer !== undefined) {
|
|
306
|
+
clearInterval(state.timer);
|
|
307
|
+
state.timer = undefined;
|
|
308
|
+
}
|
|
309
|
+
ws.data.releaseConnectionSlot?.();
|
|
310
|
+
state.closed = true;
|
|
311
|
+
state.controller.abort();
|
|
312
|
+
const { onClose } = ws.data.handlers;
|
|
313
|
+
if (onClose) {
|
|
314
|
+
const cleanup = async (): Promise<void> => {
|
|
315
|
+
try {
|
|
316
|
+
await onClose(code, reason, toConnection(ws));
|
|
317
|
+
} catch (error) {
|
|
318
|
+
reportHandlerFailure(error);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
void runInRequestContext(ws, cleanup);
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export const kumikoWebSocketHandler = createKumikoWebSocketHandler();
|
package/src/bun-db/query.ts
CHANGED
|
@@ -930,11 +930,13 @@ export async function updateMany<TRow = any>(
|
|
|
930
930
|
return scoped.updateMany<TRow>(table, set, where);
|
|
931
931
|
}
|
|
932
932
|
const info = extractTableInfo(table);
|
|
933
|
-
const setEntries = Object.entries(set)
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
933
|
+
const setEntries = Object.entries(set)
|
|
934
|
+
.filter(([k]) => info.hasColumn(k))
|
|
935
|
+
.map(([k, v]) => {
|
|
936
|
+
const col = info.columnOf(k);
|
|
937
|
+
const pgType = info.pgTypeOf(col);
|
|
938
|
+
return { col, prepared: prepareValue(v, pgType) };
|
|
939
|
+
});
|
|
938
940
|
if (setEntries.length === 0) throw new Error("updateMany: empty set object");
|
|
939
941
|
const values: unknown[] = [];
|
|
940
942
|
let idx = 1;
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.327.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "r.webSocketRoute for authenticated WebSocket routes under /api/ws/",
|
|
6
|
+
"detail": "renderer-web gains `FloatingPanel` (movable, resizable, non-modal panel with persisted geometry and a full-screen sheet on narrow viewports) and exports `useIsNarrowViewport`. Features can declare `r.webSocketRoute` under `/api/ws/` with session auth, an Origin check (allowlist, or same host without one), a per-route message cap, backpressure protection (4 MiB, the socket is closed beyond it) and a per-user connection cap (`maxConnectionsPerUser`, default 5, per server process; over it the upgrade gets 429). Handlers run one after another in arrival order per socket; on close `onClose` runs immediately (not queued behind a hung handler), queued messages never start, and `connection.signal` aborts. A 25 s heartbeat revalidates session, roles, tenant lifecycle and the token's own expiry (close 1008); a session store that keeps failing closes the socket with 1013 after three failed checks in a row. A server without upgrade wiring answers 501 `websocket_upgrade_not_wired` and logs the fix. `buildBunServeOptions` takes an optional `{ upgradeFetch, heartbeatIntervalMs? }` object as 4th argument and `runProdApp` handles expose `webSocketUpgradeFetch`; the dev server wires it. Upgrade rejections carry the same security headers as other responses. Additive, no migration."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.327.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "JobRunner.stop() waits for queue readiness before closing",
|
|
12
|
+
"detail": "JobRunner.stop() waits (bounded by the boot Redis timeout) for its queues to be ready before closing them. Stopping a runner right after construction no longer leaves an unhandled \"Connection is closed.\" rejection behind."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.327.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "updateMany drops unknown columns, so replaying an update event for a removed field no longer fails the rebuild",
|
|
18
|
+
"detail": "`updateMany` now ignores keys that have no column on the target table, matching `insertOne`/`insertMany`. A historical `<entity>.updated` event that still carries a field removed from the entity no longer aborts a projection rebuild with \"column ... does not exist\"."
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"version": "0.325.0",
|
|
4
22
|
"type": "improvement",
|