@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.
Files changed (109) hide show
  1. package/package.json +7 -2
  2. package/src/__tests__/consumer-cli.integration.test.ts +110 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +16 -1
  4. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  5. package/src/api/__tests__/jwt.test.ts +150 -1
  6. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  7. package/src/api/api-constants.ts +4 -0
  8. package/src/api/auth-middleware.ts +48 -59
  9. package/src/api/auth-routes.ts +51 -17
  10. package/src/api/index.ts +3 -3
  11. package/src/api/jwt.ts +148 -7
  12. package/src/api/pii-leak-guard.ts +5 -2
  13. package/src/api/server.ts +19 -5
  14. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  15. package/src/bun-db/query.ts +34 -2
  16. package/src/consumer-cli.ts +87 -0
  17. package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
  18. package/src/crypto/blind-index.ts +8 -4
  19. package/src/crypto/event-pii.ts +1 -0
  20. package/src/crypto/pii-field-encryption.ts +49 -15
  21. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  22. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +305 -0
  23. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
  24. package/src/db/blind-index-cleanup.ts +3 -1
  25. package/src/db/connection.ts +3 -11
  26. package/src/db/encryption.ts +2 -3
  27. package/src/db/entity-table-meta-types.ts +92 -0
  28. package/src/db/entity-table-meta.ts +16 -90
  29. package/src/db/queries/backfill-pii.ts +1 -0
  30. package/src/db/queries/event-consumer.ts +35 -2
  31. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  32. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
  33. package/src/engine/__tests__/define-roles.test.ts +21 -0
  34. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  35. package/src/engine/__tests__/store-table.test.ts +12 -0
  36. package/src/engine/boot-validator/action-wiring.ts +1 -1
  37. package/src/engine/boot-validator/boot-check.ts +21 -0
  38. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  39. package/src/engine/boot-validator/gdpr-storage.ts +0 -112
  40. package/src/engine/boot-validator/index.ts +3 -9
  41. package/src/engine/boot-validator/screens.ts +1 -1
  42. package/src/engine/define-feature.ts +1 -0
  43. package/src/engine/define-handler.ts +10 -91
  44. package/src/engine/entity-handlers.ts +15 -27
  45. package/src/engine/feature-builder-state.ts +3 -0
  46. package/src/engine/feature-config-events-jobs.ts +1 -1
  47. package/src/engine/feature-entity-handlers.ts +1 -1
  48. package/src/engine/feature-ui-extensions.ts +5 -1
  49. package/src/engine/field-helpers.ts +31 -0
  50. package/src/engine/handler-helpers.ts +26 -0
  51. package/src/engine/hook-helpers.ts +14 -0
  52. package/src/engine/index.ts +2 -2
  53. package/src/engine/ownership.ts +22 -76
  54. package/src/engine/registry-validate.ts +1 -1
  55. package/src/engine/screen-helpers.ts +54 -0
  56. package/src/engine/tier-resolver-extension.ts +3 -2
  57. package/src/engine/types/define-handler.ts +94 -0
  58. package/src/engine/types/entity-handlers.ts +30 -0
  59. package/src/engine/types/event-type-map.ts +1 -37
  60. package/src/engine/types/feature.ts +45 -0
  61. package/src/engine/types/fields.ts +19 -31
  62. package/src/engine/types/handlers.ts +7 -26
  63. package/src/engine/types/hooks.ts +1 -15
  64. package/src/engine/types/http-route.ts +1 -72
  65. package/src/engine/types/identifiers.ts +1 -47
  66. package/src/engine/types/index.ts +34 -9
  67. package/src/engine/types/ownership.ts +83 -0
  68. package/src/engine/types/relations.ts +1 -51
  69. package/src/engine/types/screen.ts +0 -46
  70. package/src/engine/types/target-ref.ts +1 -21
  71. package/src/engine/types/tree-node.ts +1 -129
  72. package/src/entrypoint/index.ts +2 -2
  73. package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
  74. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
  75. package/src/event-store/event-store.ts +28 -32
  76. package/src/event-store/events-schema.ts +1 -10
  77. package/src/event-store/index.ts +3 -2
  78. package/src/event-store/types.ts +22 -0
  79. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  80. package/src/files/file-handle.ts +2 -19
  81. package/src/i18n/required-surface-keys.ts +1 -1
  82. package/src/logging/types.ts +1 -7
  83. package/src/observability/types/index.ts +1 -29
  84. package/src/observability/types/metric.ts +1 -56
  85. package/src/observability/types/provider.ts +1 -32
  86. package/src/observability/types/span.ts +1 -58
  87. package/src/pipeline/__tests__/dispatcher.test.ts +38 -1
  88. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  89. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
  90. package/src/pipeline/dispatch-shared.ts +12 -2
  91. package/src/pipeline/entity-cache.ts +2 -33
  92. package/src/pipeline/event-consumer-state.ts +28 -3
  93. package/src/pipeline/event-dispatcher-admin.ts +4 -0
  94. package/src/pipeline/event-dispatcher-delivery.ts +29 -3
  95. package/src/pipeline/event-dispatcher.ts +27 -1
  96. package/src/pipeline/system-hooks.ts +7 -0
  97. package/src/search/types.ts +1 -39
  98. package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
  99. package/src/secrets/__tests__/envelope.test.ts +1 -1
  100. package/src/secrets/envelope-cipher.ts +13 -39
  101. package/src/stack/__tests__/event-collector.test.ts +42 -0
  102. package/src/testing/__tests__/late-bound.test.ts +25 -0
  103. package/src/testing/__tests__/wait-for.test.ts +53 -0
  104. package/src/testing/boot-validator-fixture.ts +1 -1
  105. package/src/testing/file-provider-contract.ts +84 -0
  106. package/src/testing/handler-context.ts +1 -1
  107. package/src/testing/index.ts +1 -0
  108. package/src/time/geo-tz.ts +1 -32
  109. package/src/ui-types/index.ts +7 -7
@@ -16,17 +16,11 @@ import {
16
16
  type AuthSessionStatus,
17
17
  CSRF_COOKIE_NAME,
18
18
  getUser,
19
- type PatResolver,
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: JWT_TTL_SECONDS,
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
- // When true, a JWT WITHOUT a sid is rejected. Use during deploy-rollouts
266
- // once all fresh JWTs emit a sid and the legacy stateless tokens are
267
- // expected to have expired. Default false keeps old tokens working.
268
- sessionStrictMode?: boolean;
269
- // Resolves bearer Personal Access Tokens (PAT_TOKEN_PREFIX) into a
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, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
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
- export function createJwtHelper(secret: string, issuer = "kumiko"): JwtHelper {
29
- const encodedSecret = new TextEncoder().encode(secret);
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({ alg: "HS256" })
120
+ .setProtectedHeader(header)
41
121
  .setSubject(String(user.id))
42
122
  .setIssuer(issuer)
43
- .setIssuedAt()
44
- .setExpirationTime("24h");
123
+ .setIssuedAt(nowSec)
124
+ .setExpirationTime(nowSec + ttlSeconds);
45
125
  if (user.sid) builder.setJti(user.sid);
46
126
 
47
- return builder.sign(encodedSecret);
127
+ return builder.sign(keyring.signKey);
48
128
  },
49
129
 
50
130
  async verify(token) {
51
- const { payload } = await jose.jwtVerify(token, encodedSecret, { issuer });
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
- const CIPHERTEXT_RE = /kumiko-pii:v1:[^"\s<>\\]*/g;
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(PII_CIPHERTEXT_PREFIX)) return;
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
- const jwt = createJwtHelper(options.jwtSecret, options.jwtIssuer);
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?.sessionStrictMode ? { strictMode: options.auth.sessionStrictMode } : {}),
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
+ });
@@ -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 asRawClient(db).unsafe(sqlText, values)) as readonly Record<string, unknown>[];
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 asRawClient(db).unsafe(sqlText, values)) as readonly { count: number }[];
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
+ }