@cosmicdrift/kumiko-framework 0.158.2 → 0.159.1
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 +7 -2
- package/src/__tests__/consumer-cli.integration.test.ts +110 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +16 -1
- package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
- package/src/api/__tests__/jwt.test.ts +150 -1
- package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
- package/src/api/api-constants.ts +4 -0
- package/src/api/auth-middleware.ts +48 -59
- package/src/api/auth-routes.ts +51 -17
- package/src/api/index.ts +3 -3
- package/src/api/jwt.ts +148 -7
- package/src/api/pii-leak-guard.ts +5 -2
- package/src/api/server.ts +19 -5
- package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
- package/src/bun-db/query.ts +34 -2
- package/src/consumer-cli.ts +87 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
- package/src/crypto/blind-index.ts +8 -4
- package/src/crypto/event-pii.ts +1 -0
- package/src/crypto/pii-field-encryption.ts +49 -15
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +305 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
- package/src/db/blind-index-cleanup.ts +3 -1
- package/src/db/connection.ts +3 -11
- package/src/db/encryption.ts +2 -3
- package/src/db/entity-table-meta-types.ts +92 -0
- package/src/db/entity-table-meta.ts +16 -90
- package/src/db/queries/backfill-pii.ts +1 -0
- package/src/db/queries/event-consumer.ts +35 -2
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
- package/src/engine/__tests__/define-roles.test.ts +21 -0
- package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
- package/src/engine/__tests__/store-table.test.ts +12 -0
- package/src/engine/boot-validator/action-wiring.ts +1 -1
- package/src/engine/boot-validator/boot-check.ts +21 -0
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +0 -112
- package/src/engine/boot-validator/index.ts +3 -9
- package/src/engine/boot-validator/screens.ts +1 -1
- package/src/engine/define-feature.ts +1 -0
- package/src/engine/define-handler.ts +10 -91
- package/src/engine/entity-handlers.ts +15 -27
- package/src/engine/feature-builder-state.ts +3 -0
- package/src/engine/feature-config-events-jobs.ts +1 -1
- package/src/engine/feature-entity-handlers.ts +1 -1
- package/src/engine/feature-ui-extensions.ts +5 -1
- package/src/engine/field-helpers.ts +31 -0
- package/src/engine/handler-helpers.ts +26 -0
- package/src/engine/hook-helpers.ts +14 -0
- package/src/engine/index.ts +2 -2
- package/src/engine/ownership.ts +22 -76
- package/src/engine/registry-validate.ts +1 -1
- package/src/engine/screen-helpers.ts +54 -0
- package/src/engine/tier-resolver-extension.ts +3 -2
- package/src/engine/types/define-handler.ts +94 -0
- package/src/engine/types/entity-handlers.ts +30 -0
- package/src/engine/types/event-type-map.ts +1 -37
- package/src/engine/types/feature.ts +45 -0
- package/src/engine/types/fields.ts +19 -31
- package/src/engine/types/handlers.ts +7 -26
- package/src/engine/types/hooks.ts +1 -15
- package/src/engine/types/http-route.ts +1 -72
- package/src/engine/types/identifiers.ts +1 -47
- package/src/engine/types/index.ts +34 -9
- package/src/engine/types/ownership.ts +83 -0
- package/src/engine/types/relations.ts +1 -51
- package/src/engine/types/screen.ts +0 -46
- package/src/engine/types/target-ref.ts +1 -21
- package/src/engine/types/tree-node.ts +1 -129
- package/src/entrypoint/index.ts +2 -2
- package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
- package/src/event-store/event-store.ts +28 -32
- package/src/event-store/events-schema.ts +1 -10
- package/src/event-store/index.ts +3 -2
- package/src/event-store/types.ts +22 -0
- package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
- package/src/files/file-handle.ts +2 -19
- package/src/i18n/required-surface-keys.ts +1 -1
- package/src/logging/types.ts +1 -7
- package/src/observability/types/index.ts +1 -29
- package/src/observability/types/metric.ts +1 -56
- package/src/observability/types/provider.ts +1 -32
- package/src/observability/types/span.ts +1 -58
- package/src/pipeline/__tests__/dispatcher.test.ts +38 -1
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
- package/src/pipeline/dispatch-shared.ts +12 -2
- package/src/pipeline/entity-cache.ts +2 -33
- package/src/pipeline/event-consumer-state.ts +28 -3
- package/src/pipeline/event-dispatcher-admin.ts +4 -0
- package/src/pipeline/event-dispatcher-delivery.ts +29 -3
- package/src/pipeline/event-dispatcher.ts +27 -1
- package/src/pipeline/system-hooks.ts +7 -0
- package/src/search/types.ts +1 -39
- package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
- package/src/secrets/__tests__/envelope.test.ts +1 -1
- package/src/secrets/envelope-cipher.ts +13 -39
- package/src/stack/__tests__/event-collector.test.ts +42 -0
- package/src/testing/__tests__/late-bound.test.ts +25 -0
- package/src/testing/__tests__/wait-for.test.ts +53 -0
- package/src/testing/boot-validator-fixture.ts +1 -1
- package/src/testing/file-provider-contract.ts +84 -0
- package/src/testing/handler-context.ts +1 -1
- package/src/testing/index.ts +1 -0
- package/src/time/geo-tz.ts +1 -32
- package/src/ui-types/index.ts +7 -7
package/src/api/auth-routes.ts
CHANGED
|
@@ -16,17 +16,11 @@ import {
|
|
|
16
16
|
type AuthSessionStatus,
|
|
17
17
|
CSRF_COOKIE_NAME,
|
|
18
18
|
getUser,
|
|
19
|
-
type
|
|
19
|
+
type TokenVerifier,
|
|
20
20
|
} from "./auth-middleware";
|
|
21
21
|
import type { JwtHelper } from "./jwt";
|
|
22
22
|
import { generateToken } from "./tokens";
|
|
23
23
|
|
|
24
|
-
// Cookie lifetime must track the JWT's exp claim — both are issued together,
|
|
25
|
-
// both reference the same session. jwt.ts's createJwtHelper hardcodes
|
|
26
|
-
// setExpirationTime("24h"); if that ever becomes configurable this constant
|
|
27
|
-
// follows it.
|
|
28
|
-
const JWT_TTL_SECONDS = 24 * 60 * 60;
|
|
29
|
-
|
|
30
24
|
// Resolves the Secure cookie flag. Locked off in dev/test so Playwright
|
|
31
25
|
// against http://localhost:… can actually receive the cookie. Production
|
|
32
26
|
// flips it on — browsers drop Secure cookies on http, so a misconfigured
|
|
@@ -48,6 +42,10 @@ function setAuthCookies(
|
|
|
48
42
|
csrfToken: string;
|
|
49
43
|
sameSite: "lax" | "strict";
|
|
50
44
|
domain?: string | undefined;
|
|
45
|
+
// Cookie lifetime must track the JWT's exp claim — both are issued
|
|
46
|
+
// together, both reference the same session. Callers pass jwt.ttlSeconds
|
|
47
|
+
// so the two never drift apart.
|
|
48
|
+
ttlSeconds: number;
|
|
51
49
|
},
|
|
52
50
|
): void {
|
|
53
51
|
const sameSite = opts.sameSite === "strict" ? "Strict" : "Lax";
|
|
@@ -55,7 +53,7 @@ function setAuthCookies(
|
|
|
55
53
|
secure: cookieSecure(),
|
|
56
54
|
sameSite,
|
|
57
55
|
path: "/",
|
|
58
|
-
maxAge:
|
|
56
|
+
maxAge: opts.ttlSeconds,
|
|
59
57
|
...(opts.domain !== undefined && { domain: opts.domain }),
|
|
60
58
|
} as const;
|
|
61
59
|
|
|
@@ -262,14 +260,11 @@ export type AuthRoutesConfig = {
|
|
|
262
260
|
// at login, check it here on every request. Leaving this empty disables
|
|
263
261
|
// the revocation path — old JWTs stay valid until they expire naturally.
|
|
264
262
|
sessionChecker?: SessionChecker;
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
// SessionUser, consulted BEFORE jwt.verify. Wired by the
|
|
271
|
-
// personal-access-tokens feature; unwired = PAT auth disabled.
|
|
272
|
-
patResolver?: PatResolver;
|
|
263
|
+
// Resolves bearer tokens (any registered auth-foundation `tokenVerifier`
|
|
264
|
+
// provider — PAT today, future JWT-provider), consulted BEFORE jwt.verify.
|
|
265
|
+
// Wired by run-prod-app/run-dev-app when at least one provider feature is
|
|
266
|
+
// mounted; unwired = no non-JWT bearer auth.
|
|
267
|
+
tokenVerifier?: TokenVerifier;
|
|
273
268
|
// Per-token request-rate limiter for PAT-authenticated requests, keyed by
|
|
274
269
|
// the token id (SessionUser.pat.tokenId). Cookie/JWT requests are unaffected.
|
|
275
270
|
// Reuses the LoginRateLimiter shape (a generic keyed check/reset limiter).
|
|
@@ -288,6 +283,12 @@ export type AuthRoutesConfig = {
|
|
|
288
283
|
passwordReset?: PasswordResetConfig;
|
|
289
284
|
// Email-verification flow. Symmetric to passwordReset.
|
|
290
285
|
emailVerification?: EmailVerificationConfig;
|
|
286
|
+
// Account-unlock flow (#1266). When wired, POST
|
|
287
|
+
// /auth/request-account-unlock + /auth/confirm-account-unlock are
|
|
288
|
+
// mounted as public routes. Confirm needs no extra body field
|
|
289
|
+
// (token-only, like email-verification) — the handler only clears the
|
|
290
|
+
// Redis lockout state, no entity write.
|
|
291
|
+
accountUnlock?: AccountUnlockConfig;
|
|
291
292
|
// Self-Signup (Magic-Link). Wenn wired, mountet POST
|
|
292
293
|
// /auth/signup-request + /auth/signup-confirm. Confirm returnt JWT-
|
|
293
294
|
// Cookie + Session-Body wie login.
|
|
@@ -358,6 +359,13 @@ export type EmailVerificationConfig = {
|
|
|
358
359
|
confirmHandler: string;
|
|
359
360
|
};
|
|
360
361
|
|
|
362
|
+
export type AccountUnlockConfig = {
|
|
363
|
+
requestHandler: string;
|
|
364
|
+
// Token-only body (mirrors EmailVerificationConfig) — no entity write, so
|
|
365
|
+
// there's no newPassword-equivalent field.
|
|
366
|
+
confirmHandler: string;
|
|
367
|
+
};
|
|
368
|
+
|
|
361
369
|
// Tenant-Invite Magic-Link. Drei Accept-Branches für klare Separation:
|
|
362
370
|
// - acceptHandler: logged-in User akzeptiert via JWT (Branch 1)
|
|
363
371
|
// - acceptWithLoginHandler: anon User mit existing email (Branch 2)
|
|
@@ -518,7 +526,13 @@ export function createAuthRoutes(
|
|
|
518
526
|
}
|
|
519
527
|
const token = await jwt.sign(sessionForJwt);
|
|
520
528
|
const csrfToken = generateToken();
|
|
521
|
-
setAuthCookies(c, {
|
|
529
|
+
setAuthCookies(c, {
|
|
530
|
+
token,
|
|
531
|
+
csrfToken,
|
|
532
|
+
sameSite: cookieSameSite,
|
|
533
|
+
domain: cookieDomain,
|
|
534
|
+
ttlSeconds: jwt.ttlSeconds,
|
|
535
|
+
});
|
|
522
536
|
return token;
|
|
523
537
|
}
|
|
524
538
|
|
|
@@ -729,6 +743,26 @@ export function createAuthRoutes(
|
|
|
729
743
|
});
|
|
730
744
|
}
|
|
731
745
|
|
|
746
|
+
// Account-unlock mirrors email-verification (token-only confirm body) —
|
|
747
|
+
// clears the Redis lockout state instead of an entity field, see
|
|
748
|
+
// confirm-account-unlock.write.ts.
|
|
749
|
+
if (config.accountUnlock) {
|
|
750
|
+
const au = config.accountUnlock;
|
|
751
|
+
registerTokenRequestRoute({
|
|
752
|
+
api,
|
|
753
|
+
dispatcher,
|
|
754
|
+
path: Routes.authRequestAccountUnlock,
|
|
755
|
+
requestHandler: au.requestHandler,
|
|
756
|
+
});
|
|
757
|
+
registerTokenConfirmRoute({
|
|
758
|
+
api,
|
|
759
|
+
dispatcher,
|
|
760
|
+
path: Routes.authConfirmAccountUnlock,
|
|
761
|
+
confirmHandler: au.confirmHandler,
|
|
762
|
+
schema: VerifyEmailBody,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
732
766
|
// Self-Signup (Magic-Link). Request mountet wie reset/verify den
|
|
733
767
|
// silent-success-Pfad mit Token-Mail. Confirm ist anders: returnt
|
|
734
768
|
// SessionUser → die Route mintet JWT + setzt Cookies (Auto-Login
|
package/src/api/index.ts
CHANGED
|
@@ -5,10 +5,10 @@ export type {
|
|
|
5
5
|
AuthMiddlewareOptions,
|
|
6
6
|
AuthSessionChecker,
|
|
7
7
|
AuthSessionStatus,
|
|
8
|
-
PatResolver,
|
|
9
8
|
TenantExists,
|
|
10
9
|
TenantLifecycleStatusResolver,
|
|
11
10
|
TenantResolver,
|
|
11
|
+
TokenVerifier,
|
|
12
12
|
} from "./auth-middleware";
|
|
13
13
|
export { authMiddleware, getUser, PAT_TOKEN_PREFIX } from "./auth-middleware";
|
|
14
14
|
export type {
|
|
@@ -34,8 +34,8 @@ export {
|
|
|
34
34
|
etagMatches,
|
|
35
35
|
parseIfNoneMatch,
|
|
36
36
|
} from "./http-cache";
|
|
37
|
-
export type { JwtHelper, JwtPayload } from "./jwt";
|
|
38
|
-
export { createJwtHelper } from "./jwt";
|
|
37
|
+
export type { JwtHelper, JwtKeyring, JwtPayload } from "./jwt";
|
|
38
|
+
export { createJwtHelper, loadJwtSecretOrKeyring } from "./jwt";
|
|
39
39
|
export { patAllows, qnMatches } from "./pat-scope";
|
|
40
40
|
export { type RequestContextData, requestContext } from "./request-context";
|
|
41
41
|
export { requestIdMiddleware } from "./request-id-middleware";
|
package/src/api/jwt.ts
CHANGED
|
@@ -23,10 +23,82 @@ export type JwtPayload = {
|
|
|
23
23
|
export type JwtHelper = {
|
|
24
24
|
sign(user: SessionUser): Promise<string>;
|
|
25
25
|
verify(token: string): Promise<JwtPayload>;
|
|
26
|
+
// The TTL this helper signs tokens with, in seconds — the single source for
|
|
27
|
+
// callers (e.g. the auth-cookie's maxAge) that must stay coupled to the JWT's exp.
|
|
28
|
+
readonly ttlSeconds: number;
|
|
26
29
|
};
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
// kid → secret. All entries verify; `signKid` picks the sign-key. Rotation:
|
|
32
|
+
// add the new kid, flip signKid, keep the old kid around until in-flight
|
|
33
|
+
// tokens expire.
|
|
34
|
+
export type JwtKeyring = {
|
|
35
|
+
readonly keys: Readonly<Record<string, string>>;
|
|
36
|
+
readonly signKid: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type NormalizedKeyring = {
|
|
40
|
+
readonly verifyKeys: ReadonlyMap<string, Uint8Array>;
|
|
41
|
+
readonly signKid: string | undefined;
|
|
42
|
+
readonly signKey: Uint8Array;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function normalizeKeyring(secretOrKeyring: string | JwtKeyring): NormalizedKeyring {
|
|
46
|
+
if (typeof secretOrKeyring === "string") {
|
|
47
|
+
const key = new TextEncoder().encode(secretOrKeyring);
|
|
48
|
+
return { verifyKeys: new Map(), signKid: undefined, signKey: key };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const verifyKeys = new Map<string, Uint8Array>();
|
|
52
|
+
for (const [kid, secret] of Object.entries(secretOrKeyring.keys)) {
|
|
53
|
+
verifyKeys.set(kid, new TextEncoder().encode(secret));
|
|
54
|
+
}
|
|
55
|
+
const signKey = verifyKeys.get(secretOrKeyring.signKid);
|
|
56
|
+
if (!signKey) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`createJwtHelper: signKid "${secretOrKeyring.signKid}" is not present in the keyring`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return { verifyKeys, signKid: secretOrKeyring.signKid, signKey };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Tokens carry `kid` in the protected header when signed from a keyring — pick the
|
|
65
|
+
// matching verify-key directly. Tokens without `kid` (single-secret form, or in-flight
|
|
66
|
+
// tokens signed before a rotation) fall back to trying every verify-key.
|
|
67
|
+
async function verifyWithKeyring(token: string, keyring: NormalizedKeyring, issuer: string) {
|
|
68
|
+
const { kid } = jose.decodeProtectedHeader(token);
|
|
69
|
+
if (typeof kid === "string" && keyring.verifyKeys.size > 0) {
|
|
70
|
+
const key = keyring.verifyKeys.get(kid);
|
|
71
|
+
if (!key) {
|
|
72
|
+
throw new Error(`JWT verification failed: unknown kid "${kid}"`);
|
|
73
|
+
}
|
|
74
|
+
return jose.jwtVerify(token, key, { issuer });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ponytail: tries every key in the ring (O(keys) per legacy-token verify) — fine for a
|
|
78
|
+
// rotation window of a handful of keys, revisit if the keyring ever grows large.
|
|
79
|
+
const candidates =
|
|
80
|
+
keyring.verifyKeys.size > 0 ? [...keyring.verifyKeys.values()] : [keyring.signKey];
|
|
81
|
+
let lastError: unknown;
|
|
82
|
+
for (const key of candidates) {
|
|
83
|
+
try {
|
|
84
|
+
return await jose.jwtVerify(token, key, { issuer });
|
|
85
|
+
} catch (err) {
|
|
86
|
+
lastError = err;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
throw lastError instanceof Error
|
|
90
|
+
? lastError
|
|
91
|
+
: new Error("JWT verification failed: no matching key");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const DEFAULT_JWT_TTL_SECONDS = 24 * 60 * 60;
|
|
95
|
+
|
|
96
|
+
export function createJwtHelper(
|
|
97
|
+
secretOrKeyring: string | JwtKeyring,
|
|
98
|
+
issuer = "kumiko",
|
|
99
|
+
ttlSeconds = DEFAULT_JWT_TTL_SECONDS,
|
|
100
|
+
): JwtHelper {
|
|
101
|
+
const keyring = normalizeKeyring(secretOrKeyring);
|
|
30
102
|
|
|
31
103
|
return {
|
|
32
104
|
async sign(user) {
|
|
@@ -36,19 +108,27 @@ export function createJwtHelper(secret: string, issuer = "kumiko"): JwtHelper {
|
|
|
36
108
|
};
|
|
37
109
|
if (user.claims) body.claims = { ...user.claims };
|
|
38
110
|
|
|
111
|
+
const header: jose.JWTHeaderParameters = keyring.signKid
|
|
112
|
+
? { alg: "HS256", kid: keyring.signKid }
|
|
113
|
+
: { alg: "HS256" };
|
|
114
|
+
|
|
115
|
+
// iat/exp share one `now` — jose's setIssuedAt()/setExpirationTime(Date)
|
|
116
|
+
// each read the clock separately, letting `exp - iat` drift by a
|
|
117
|
+
// second and making TTL-precision tests flaky.
|
|
118
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
39
119
|
const builder = new jose.SignJWT(body)
|
|
40
|
-
.setProtectedHeader(
|
|
120
|
+
.setProtectedHeader(header)
|
|
41
121
|
.setSubject(String(user.id))
|
|
42
122
|
.setIssuer(issuer)
|
|
43
|
-
.setIssuedAt()
|
|
44
|
-
.setExpirationTime(
|
|
123
|
+
.setIssuedAt(nowSec)
|
|
124
|
+
.setExpirationTime(nowSec + ttlSeconds);
|
|
45
125
|
if (user.sid) builder.setJti(user.sid);
|
|
46
126
|
|
|
47
|
-
return builder.sign(
|
|
127
|
+
return builder.sign(keyring.signKey);
|
|
48
128
|
},
|
|
49
129
|
|
|
50
130
|
async verify(token) {
|
|
51
|
-
const { payload } = await
|
|
131
|
+
const { payload } = await verifyWithKeyring(token, keyring, issuer);
|
|
52
132
|
|
|
53
133
|
// defence-in-depth: valid sig ≠ well-formed claims; malformed payload → throw → 401
|
|
54
134
|
const tenantId = parseTenantId(payload["tenantId"]);
|
|
@@ -84,5 +164,66 @@ export function createJwtHelper(secret: string, issuer = "kumiko"): JwtHelper {
|
|
|
84
164
|
}
|
|
85
165
|
return result;
|
|
86
166
|
},
|
|
167
|
+
ttlSeconds,
|
|
87
168
|
};
|
|
88
169
|
}
|
|
170
|
+
|
|
171
|
+
const JWT_KEY_VAR_PATTERN = /^JWT_SECRET_V(\d+)$/;
|
|
172
|
+
const JWT_CURRENT_VERSION_VAR = "JWT_SECRET_CURRENT_VERSION";
|
|
173
|
+
// Mirrors authEmailPasswordEnvSchema's JWT_SECRET.min(32) — HS256 minimum.
|
|
174
|
+
// JWT_SECRET_V<n> bypasses that zod schema entirely (it only validates the
|
|
175
|
+
// plain JWT_SECRET name), so this loader is the only gate for the rotation path.
|
|
176
|
+
const MIN_JWT_SECRET_LENGTH = 32;
|
|
177
|
+
|
|
178
|
+
function assertMinLength(name: string, value: string): void {
|
|
179
|
+
if (value.length < MIN_JWT_SECRET_LENGTH) {
|
|
180
|
+
throw new Error(`[jwt] ${name} must be ≥${MIN_JWT_SECRET_LENGTH} chars (HS256 minimum)`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Env-loader for createJwtHelper's secret-or-keyring param, analog to
|
|
185
|
+
// secrets' loadKeyring: JWT_SECRET_V<n> (+ JWT_SECRET_CURRENT_VERSION picking
|
|
186
|
+
// the active signKid) for rotation, falling back to plain JWT_SECRET when no
|
|
187
|
+
// JWT_SECRET_V<n> is set — so a non-rotating deployment needs no new env vars.
|
|
188
|
+
export function loadJwtSecretOrKeyring(
|
|
189
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
190
|
+
): string | JwtKeyring {
|
|
191
|
+
const keys: Record<string, string> = {};
|
|
192
|
+
for (const [name, value] of Object.entries(env)) {
|
|
193
|
+
const match = name.match(JWT_KEY_VAR_PATTERN);
|
|
194
|
+
if (!match || !value) continue;
|
|
195
|
+
assertMinLength(name, value);
|
|
196
|
+
// biome-ignore lint/style/noNonNullAssertion: regex group 1 always present
|
|
197
|
+
keys[`v${match[1]!}`] = value;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// skip: no JWT_SECRET_V<n> found — single-secret fallback.
|
|
201
|
+
if (Object.keys(keys).length === 0) {
|
|
202
|
+
const secret = env["JWT_SECRET"];
|
|
203
|
+
if (!secret) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
"[jwt] JWT_SECRET not set — set JWT_SECRET for a single key, or " +
|
|
206
|
+
"JWT_SECRET_V1 (+ JWT_SECRET_CURRENT_VERSION=1) for a rotatable keyring.",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
assertMinLength("JWT_SECRET", secret);
|
|
210
|
+
return secret;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const currentRaw = env[JWT_CURRENT_VERSION_VAR];
|
|
214
|
+
if (!currentRaw) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`[jwt] ${JWT_CURRENT_VERSION_VAR} not set — explicit current-version required ` +
|
|
217
|
+
"so adding a new JWT_SECRET_V<n> doesn't auto-promote it to the sign key.",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const signKid = `v${currentRaw}`;
|
|
221
|
+
if (!keys[signKid]) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`[jwt] ${JWT_CURRENT_VERSION_VAR}="${currentRaw}" not present in the keyring ` +
|
|
224
|
+
`(have versions: ${Object.keys(keys).sort().join(", ")}). ` +
|
|
225
|
+
`Check JWT_SECRET_V${currentRaw} is set.`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return { keys, signKid };
|
|
229
|
+
}
|
|
@@ -2,7 +2,10 @@ import type { MiddlewareHandler } from "hono";
|
|
|
2
2
|
import { configuredPiiSubjectKms, PII_CIPHERTEXT_PREFIX } from "../crypto";
|
|
3
3
|
|
|
4
4
|
const isProductionEnv = () => process.env["NODE_ENV"] === "production";
|
|
5
|
-
|
|
5
|
+
// Version-agnostic: catches both the current PII_CIPHERTEXT_PREFIX and any
|
|
6
|
+
// older/decrypt-only format version still present in unmigrated rows.
|
|
7
|
+
const CIPHERTEXT_MARKER = "kumiko-pii:v";
|
|
8
|
+
const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^"\s<>\\]*/g;
|
|
6
9
|
|
|
7
10
|
// A PII subject ciphertext never belongs in an API response — its presence
|
|
8
11
|
// means a raw DB read (fetchOne/selectMany) leaked to the surface. Dev/test
|
|
@@ -19,7 +22,7 @@ export function piiCiphertextResponseGuard(): MiddlewareHandler {
|
|
|
19
22
|
if (!contentType.includes("application/json")) return;
|
|
20
23
|
const text = await c.res.clone().text();
|
|
21
24
|
// skip: clean response — the common case
|
|
22
|
-
if (!text.includes(
|
|
25
|
+
if (!text.includes(CIPHERTEXT_MARKER)) return;
|
|
23
26
|
|
|
24
27
|
const detail =
|
|
25
28
|
`[api] JSON response for ${c.req.method} ${c.req.path} contains a PII ciphertext ` +
|
package/src/api/server.ts
CHANGED
|
@@ -50,7 +50,7 @@ import { PUBLIC_API_PATHS } from "./api-constants";
|
|
|
50
50
|
import { type AnonymousAccessConfig, authMiddleware, getUser } from "./auth-middleware";
|
|
51
51
|
import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
|
|
52
52
|
import { csrfMiddleware } from "./csrf-middleware";
|
|
53
|
-
import { createJwtHelper, type JwtHelper } from "./jwt";
|
|
53
|
+
import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
|
|
54
54
|
import { observabilityMiddleware } from "./observability-middleware";
|
|
55
55
|
import { assertOriginGuardConfig, originMiddleware } from "./origin-middleware";
|
|
56
56
|
import { piiCiphertextResponseGuard } from "./pii-leak-guard";
|
|
@@ -69,8 +69,13 @@ import { createSseRoute } from "./sse-route";
|
|
|
69
69
|
export type ServerOptions = {
|
|
70
70
|
registry: Registry;
|
|
71
71
|
context: AppContext;
|
|
72
|
-
jwtSecret: string;
|
|
72
|
+
jwtSecret: string | JwtKeyring;
|
|
73
73
|
jwtIssuer?: string;
|
|
74
|
+
// JWT lifetime in seconds. Explicit always wins. When omitted, the default
|
|
75
|
+
// depends on `auth.sessionChecker`: wired (revocation possible) keeps the
|
|
76
|
+
// long-lived 24h default; unwired (stateless JWTs, no revocation) drops to
|
|
77
|
+
// 1h so a leaked stateless token has a much smaller exposure window.
|
|
78
|
+
jwtTtl?: number;
|
|
74
79
|
dispatcherOptions?: Omit<DispatcherOptions, "lifecycle">;
|
|
75
80
|
systemHooks?: SystemHooks;
|
|
76
81
|
eventDedup?: EventDedup;
|
|
@@ -94,6 +99,8 @@ export type ServerOptions = {
|
|
|
94
99
|
pollIntervalMs?: number;
|
|
95
100
|
batchSize?: number;
|
|
96
101
|
maxAttempts?: number;
|
|
102
|
+
rearmCooldownMs?: number;
|
|
103
|
+
maxRearmCount?: number;
|
|
97
104
|
// Opt out of building the dispatcher even if consumers exist — e.g. ops
|
|
98
105
|
// runs a dedicated dispatcher process, or a test needs to control the
|
|
99
106
|
// consumer lifecycle manually.
|
|
@@ -238,7 +245,15 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
238
245
|
);
|
|
239
246
|
}
|
|
240
247
|
|
|
241
|
-
|
|
248
|
+
// Stateless JWTs (no sessionChecker → no revocation) default to a shorter
|
|
249
|
+
// TTL than session-backed ones, since a leaked stateless token can't be
|
|
250
|
+
// revoked and stays valid until it expires. Explicit jwtTtl always wins.
|
|
251
|
+
const defaultJwtTtl = options.auth?.sessionChecker ? 24 * 60 * 60 : 60 * 60;
|
|
252
|
+
const jwt = createJwtHelper(
|
|
253
|
+
options.jwtSecret,
|
|
254
|
+
options.jwtIssuer,
|
|
255
|
+
options.jwtTtl ?? defaultJwtTtl,
|
|
256
|
+
);
|
|
242
257
|
const sseBroker = options.sseBroker ?? createSseBroker();
|
|
243
258
|
|
|
244
259
|
// Resolve the per-process instance identifier. Prefer explicit
|
|
@@ -566,8 +581,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
566
581
|
// middleware can reject revoked sids on every request.
|
|
567
582
|
const jwtGuard = authMiddleware(jwt, {
|
|
568
583
|
...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
|
|
569
|
-
...(options.auth?.
|
|
570
|
-
...(options.auth?.patResolver ? { patResolver: options.auth.patResolver } : {}),
|
|
584
|
+
...(options.auth?.tokenVerifier ? { tokenVerifier: options.auth.tokenVerifier } : {}),
|
|
571
585
|
...(options.auth?.resolveTenantLifecycleStatus
|
|
572
586
|
? { resolveTenantLifecycleStatus: options.auth.resolveTenantLifecycleStatus }
|
|
573
587
|
: {}),
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// #1163: Bun.SQL can hand out a closed connection under load (AbortError
|
|
2
|
+
// "The connection was closed."). Pure reads retry exactly once on a fresh
|
|
3
|
+
// pool checkout; tx handles, non-matching errors, and genuine user aborts
|
|
4
|
+
// must NOT retry.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { buildEntityTable } from "../../db/table-builder";
|
|
8
|
+
import { selectMany } from "../query";
|
|
9
|
+
|
|
10
|
+
function closedConnectionError(): Error {
|
|
11
|
+
return Object.assign(new Error("The connection was closed."), { name: "AbortError" });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type FakeClient = {
|
|
15
|
+
unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
|
|
16
|
+
begin?: () => never;
|
|
17
|
+
calls: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function fakeClient(failures: Error[], opts: { tx?: boolean } = {}): FakeClient {
|
|
21
|
+
const remaining = [...failures];
|
|
22
|
+
const client: FakeClient = {
|
|
23
|
+
calls: 0,
|
|
24
|
+
unsafe: async () => {
|
|
25
|
+
client.calls++;
|
|
26
|
+
const err = remaining.shift();
|
|
27
|
+
if (err) throw err;
|
|
28
|
+
return [{ id: "r1", title: "ok", tenant_id: "t1", inserted_at: null, updated_at: null }];
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
// A top-level pool client has begin(); a transaction handle does not.
|
|
32
|
+
if (!opts.tx)
|
|
33
|
+
client.begin = () => {
|
|
34
|
+
throw new Error("not used in test");
|
|
35
|
+
};
|
|
36
|
+
return client;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const table = buildEntityTable("note", {
|
|
40
|
+
fields: { title: { type: "text", required: true } },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("selectMany — closed-connection retry (#1163)", () => {
|
|
44
|
+
test("retries once on AbortError 'connection was closed' and returns rows", async () => {
|
|
45
|
+
const db = fakeClient([closedConnectionError()]);
|
|
46
|
+
const rows = await selectMany(db, table);
|
|
47
|
+
expect(rows).toHaveLength(1);
|
|
48
|
+
expect(rows[0]?.title).toBe("ok");
|
|
49
|
+
expect(db.calls).toBe(2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("gives up after the single retry when the connection stays closed", async () => {
|
|
53
|
+
const db = fakeClient([closedConnectionError(), closedConnectionError()]);
|
|
54
|
+
await expect(selectMany(db, table)).rejects.toThrow("connection was closed");
|
|
55
|
+
expect(db.calls).toBe(2);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("never retries on a transaction handle (no begin)", async () => {
|
|
59
|
+
const db = fakeClient([closedConnectionError()], { tx: true });
|
|
60
|
+
await expect(selectMany(db, table)).rejects.toThrow("connection was closed");
|
|
61
|
+
expect(db.calls).toBe(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("does not retry a genuine user abort (different message)", async () => {
|
|
65
|
+
const userAbort = Object.assign(new Error("The operation was aborted."), {
|
|
66
|
+
name: "AbortError",
|
|
67
|
+
});
|
|
68
|
+
const db = fakeClient([userAbort]);
|
|
69
|
+
await expect(selectMany(db, table)).rejects.toThrow("operation was aborted");
|
|
70
|
+
expect(db.calls).toBe(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("does not retry generic query errors", async () => {
|
|
74
|
+
const syntax = Object.assign(new Error("syntax error at or near"), { name: "PostgresError" });
|
|
75
|
+
const db = fakeClient([syntax]);
|
|
76
|
+
await expect(selectMany(db, table)).rejects.toThrow("syntax error");
|
|
77
|
+
expect(db.calls).toBe(1);
|
|
78
|
+
});
|
|
79
|
+
});
|
package/src/bun-db/query.ts
CHANGED
|
@@ -562,6 +562,38 @@ function buildWhereClause(
|
|
|
562
562
|
return { sqlText: conditions.join(" AND "), values };
|
|
563
563
|
}
|
|
564
564
|
|
|
565
|
+
// #1163: under load the Bun.SQL pool can hand out a connection the server
|
|
566
|
+
// already closed ("The connection was closed.", AbortError code 20) — no
|
|
567
|
+
// validate-on-checkout exists. One retry re-checks out a fresh connection;
|
|
568
|
+
// safe for pure reads. Tx handles are never retried: their transaction is
|
|
569
|
+
// dead once the connection dropped, and a retry would run on the same dead
|
|
570
|
+
// handle. Detection is name+message (not name alone) so a genuine user
|
|
571
|
+
// abort (AbortSignal cancel) is NOT retried.
|
|
572
|
+
function isClosedConnectionError(err: unknown): boolean {
|
|
573
|
+
if (err === null || typeof err !== "object") return false;
|
|
574
|
+
const e = err as { name?: unknown; message?: unknown };
|
|
575
|
+
return (
|
|
576
|
+
e.name === "AbortError" &&
|
|
577
|
+
typeof e.message === "string" &&
|
|
578
|
+
/connection was closed/i.test(e.message)
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function unsafeRead<TRow>(
|
|
583
|
+
db: AnyDb,
|
|
584
|
+
sqlText: string,
|
|
585
|
+
params: readonly unknown[],
|
|
586
|
+
): Promise<readonly TRow[]> {
|
|
587
|
+
const raw = asRawClient(db);
|
|
588
|
+
try {
|
|
589
|
+
return (await raw.unsafe(sqlText, params)) as readonly TRow[];
|
|
590
|
+
} catch (err) {
|
|
591
|
+
// TransactionSql has savepoint(), only a top-level pool client has begin().
|
|
592
|
+
if (typeof raw.begin !== "function" || !isClosedConnectionError(err)) throw err;
|
|
593
|
+
return (await raw.unsafe(sqlText, params)) as readonly TRow[];
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
565
597
|
// biome-ignore lint/suspicious/noExplicitAny: opt-in default loosens row type for unannotated test fixtures
|
|
566
598
|
export async function selectMany<TRow = any>(
|
|
567
599
|
db: AnyDb,
|
|
@@ -598,7 +630,7 @@ export async function selectMany<TRow = any>(
|
|
|
598
630
|
}
|
|
599
631
|
sqlText += ` LIMIT ${options.limit}`;
|
|
600
632
|
}
|
|
601
|
-
const raw = (await
|
|
633
|
+
const raw = (await unsafeRead(db, sqlText, values)) as readonly Record<string, unknown>[];
|
|
602
634
|
return coerceRows(raw, info) as readonly TRow[];
|
|
603
635
|
}
|
|
604
636
|
|
|
@@ -832,7 +864,7 @@ export async function countWhere(
|
|
|
832
864
|
sqlText += ` WHERE ${w.sqlText}`;
|
|
833
865
|
values = w.values;
|
|
834
866
|
}
|
|
835
|
-
const rows = (await
|
|
867
|
+
const rows = (await unsafeRead(db, sqlText, values)) as readonly { count: number }[];
|
|
836
868
|
return rows[0]?.count ?? 0;
|
|
837
869
|
}
|
|
838
870
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Shared core for the standalone consumer-ops CLI (status | restart).
|
|
2
|
+
//
|
|
3
|
+
// A dead event consumer (halt-on-poison after maxAttempts) previously had no
|
|
4
|
+
// recovery surface in the standalone prod bundle — only raw SQL against
|
|
5
|
+
// kumiko_event_consumers. Mirrors schema-cli.ts's shape (single runXCli(argv,
|
|
6
|
+
// out) entry point, own DB connection) so `kumiko-consumer` ships the same
|
|
7
|
+
// way `kumiko-schema` does.
|
|
8
|
+
|
|
9
|
+
import { createDbConnection } from "./db";
|
|
10
|
+
import { getConsumerState, restartConsumer } from "./pipeline";
|
|
11
|
+
import { ensureTemporalPolyfill } from "./time";
|
|
12
|
+
|
|
13
|
+
export type ConsumerCliOut = {
|
|
14
|
+
readonly log: (line: string) => void;
|
|
15
|
+
readonly err: (line: string) => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function parseInstanceIdFlag(argv: readonly string[]): string | undefined {
|
|
19
|
+
const i = argv.indexOf("--instance-id");
|
|
20
|
+
return i === -1 ? undefined : argv[i + 1];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function runConsumerCli(
|
|
24
|
+
argv: readonly string[],
|
|
25
|
+
out: ConsumerCliOut,
|
|
26
|
+
): Promise<number> {
|
|
27
|
+
// The standalone bundle never runs runProdApp/runDevApp's boot, which is
|
|
28
|
+
// where Temporal normally gets installed — ConsumerRecoveryState.updatedAt
|
|
29
|
+
// is a Temporal.Instant, so without this every subcommand throws "Temporal
|
|
30
|
+
// is not defined" (same failure mode as schema-cli, see its polyfill test).
|
|
31
|
+
await ensureTemporalPolyfill();
|
|
32
|
+
const sub = argv[0];
|
|
33
|
+
|
|
34
|
+
if (sub !== "status" && sub !== "restart") {
|
|
35
|
+
out.log("");
|
|
36
|
+
out.log(" Subcommands:");
|
|
37
|
+
out.log(" status <name> [--instance-id <id>] Zeigt Status + Cursor eines Consumers");
|
|
38
|
+
out.log(" restart <name> [--instance-id <id>] Reaktiviert einen dead-Consumer (idle)");
|
|
39
|
+
out.log("");
|
|
40
|
+
return sub === undefined ? 0 : 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const name = argv[1];
|
|
44
|
+
if (!name) {
|
|
45
|
+
out.err(` Usage: consumer ${sub} <name> [--instance-id <id>]`);
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
const instanceId = parseInstanceIdFlag(argv);
|
|
49
|
+
|
|
50
|
+
const dbUrl = process.env["DATABASE_URL"];
|
|
51
|
+
if (!dbUrl) {
|
|
52
|
+
out.err(" DATABASE_URL not set.");
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
const { db, close } = createDbConnection(dbUrl);
|
|
56
|
+
try {
|
|
57
|
+
if (sub === "status") {
|
|
58
|
+
const state = await getConsumerState(db, name, instanceId);
|
|
59
|
+
if (!state) {
|
|
60
|
+
out.err(` Consumer "${name}" (instance_id="${instanceId ?? "__shared__"}") not found.`);
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
out.log("");
|
|
64
|
+
out.log(` ${state.name} (instance_id="${state.instanceId}")`);
|
|
65
|
+
out.log(` status: ${state.status}`);
|
|
66
|
+
out.log(` cursor: ${state.lastProcessedEventId}`);
|
|
67
|
+
out.log(` attempts: ${state.attempts}`);
|
|
68
|
+
out.log(` rearmCount: ${state.rearmCount}`);
|
|
69
|
+
out.log(` lastError: ${state.lastError ?? "-"}`);
|
|
70
|
+
out.log(` updatedAt: ${state.updatedAt.toString()}`);
|
|
71
|
+
out.log("");
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// restart
|
|
76
|
+
const result = await restartConsumer(db, name, instanceId);
|
|
77
|
+
out.log("");
|
|
78
|
+
out.log(` ✓ ${result.name} (instance_id="${result.instanceId}") → ${result.status}`);
|
|
79
|
+
out.log("");
|
|
80
|
+
return 0;
|
|
81
|
+
} catch (e) {
|
|
82
|
+
out.err(` ✗ ${e instanceof Error ? e.message : String(e)}`);
|
|
83
|
+
return 1;
|
|
84
|
+
} finally {
|
|
85
|
+
await close();
|
|
86
|
+
}
|
|
87
|
+
}
|