@cosmicdrift/kumiko-framework 0.158.2 → 0.160.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.
Files changed (168) hide show
  1. package/package.json +7 -2
  2. package/src/__tests__/consumer-cli.integration.test.ts +110 -0
  3. package/src/api/__tests__/api.test.ts +65 -0
  4. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  5. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
  6. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
  7. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  8. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  9. package/src/api/__tests__/jwt.test.ts +150 -1
  10. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
  11. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  12. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  13. package/src/api/api-constants.ts +5 -0
  14. package/src/api/auth-middleware.ts +48 -59
  15. package/src/api/auth-routes.ts +51 -17
  16. package/src/api/index.ts +3 -3
  17. package/src/api/jwt.ts +148 -7
  18. package/src/api/pii-leak-guard.ts +5 -2
  19. package/src/api/routes.ts +57 -0
  20. package/src/api/server.ts +19 -5
  21. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  22. package/src/bun-db/query.ts +46 -27
  23. package/src/consumer-cli.ts +87 -0
  24. package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
  25. package/src/crypto/blind-index.ts +8 -4
  26. package/src/crypto/event-pii.ts +1 -0
  27. package/src/crypto/kms-adapter.ts +2 -118
  28. package/src/crypto/pii-field-encryption.ts +49 -15
  29. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  30. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  31. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +396 -0
  32. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
  33. package/src/db/blind-index-cleanup.ts +3 -1
  34. package/src/db/connection.ts +3 -11
  35. package/src/db/cursor.ts +1 -18
  36. package/src/db/dialect.ts +8 -19
  37. package/src/db/encryption.ts +2 -3
  38. package/src/db/entity-table-meta-types.ts +2 -0
  39. package/src/db/entity-table-meta.ts +16 -90
  40. package/src/db/event-store-executor.ts +4 -96
  41. package/src/db/queries/backfill-pii.ts +1 -0
  42. package/src/db/queries/event-consumer.ts +35 -2
  43. package/src/db/table-builder.ts +2 -19
  44. package/src/db/tenant-db.ts +6 -55
  45. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  46. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
  47. package/src/engine/__tests__/boot-validator.test.ts +46 -0
  48. package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
  49. package/src/engine/__tests__/define-roles.test.ts +21 -0
  50. package/src/engine/__tests__/engine.test.ts +28 -0
  51. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  52. package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
  53. package/src/engine/__tests__/registry.test.ts +40 -0
  54. package/src/engine/__tests__/store-table.test.ts +12 -0
  55. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  56. package/src/engine/boot-validator/action-wiring.ts +1 -1
  57. package/src/engine/boot-validator/boot-check.ts +21 -0
  58. package/src/engine/boot-validator/entity-handler.ts +10 -1
  59. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  60. package/src/engine/boot-validator/gdpr-storage.ts +0 -112
  61. package/src/engine/boot-validator/index.ts +3 -9
  62. package/src/engine/boot-validator/screens.ts +1 -1
  63. package/src/engine/define-feature.ts +2 -0
  64. package/src/engine/define-handler.ts +11 -91
  65. package/src/engine/entity-handlers.ts +15 -27
  66. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
  67. package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
  68. package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
  69. package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
  70. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
  71. package/src/engine/feature-ast/extractors/handlers.ts +19 -2
  72. package/src/engine/feature-ast/extractors/index.ts +1 -0
  73. package/src/engine/feature-ast/index.ts +2 -0
  74. package/src/engine/feature-ast/parse.ts +3 -0
  75. package/src/engine/feature-ast/patch.ts +2 -0
  76. package/src/engine/feature-ast/patcher.ts +21 -0
  77. package/src/engine/feature-ast/patterns.ts +16 -0
  78. package/src/engine/feature-ast/render.ts +15 -0
  79. package/src/engine/feature-builder-state.ts +6 -0
  80. package/src/engine/feature-config-events-jobs.ts +1 -1
  81. package/src/engine/feature-entity-handlers.ts +36 -2
  82. package/src/engine/feature-ui-extensions.ts +5 -1
  83. package/src/engine/field-helpers.ts +31 -0
  84. package/src/engine/handler-helpers.ts +26 -0
  85. package/src/engine/hook-helpers.ts +14 -0
  86. package/src/engine/index.ts +5 -2
  87. package/src/engine/ownership.ts +22 -76
  88. package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
  89. package/src/engine/pattern-library/library.ts +2 -0
  90. package/src/engine/pattern-library/mixed-schemas.ts +37 -0
  91. package/src/engine/registry-facade.ts +9 -0
  92. package/src/engine/registry-ingest.ts +10 -0
  93. package/src/engine/registry-state.ts +3 -0
  94. package/src/engine/registry-validate.ts +1 -1
  95. package/src/engine/screen-helpers.ts +54 -0
  96. package/src/engine/tier-resolver-extension.ts +3 -2
  97. package/src/engine/types/config.ts +2 -497
  98. package/src/engine/types/define-handler.ts +2 -0
  99. package/src/engine/types/entity-handlers.ts +2 -0
  100. package/src/engine/types/event-type-map.ts +1 -37
  101. package/src/engine/types/feature.ts +2 -976
  102. package/src/engine/types/fields.ts +2 -697
  103. package/src/engine/types/handlers.ts +2 -839
  104. package/src/engine/types/hooks.ts +2 -184
  105. package/src/engine/types/http-route.ts +1 -72
  106. package/src/engine/types/identifiers.ts +1 -47
  107. package/src/engine/types/index.ts +66 -33
  108. package/src/engine/types/nav.ts +2 -67
  109. package/src/engine/types/ownership.ts +2 -0
  110. package/src/engine/types/projection.ts +2 -165
  111. package/src/engine/types/relations.ts +1 -51
  112. package/src/engine/types/screen.ts +2 -793
  113. package/src/engine/types/step.ts +2 -334
  114. package/src/engine/types/target-ref.ts +1 -21
  115. package/src/engine/types/tree-node.ts +1 -129
  116. package/src/engine/types/workspace.ts +2 -42
  117. package/src/entrypoint/index.ts +2 -2
  118. package/src/errors/write-error-info.ts +6 -22
  119. package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
  120. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
  121. package/src/event-store/errors.ts +2 -35
  122. package/src/event-store/event-store.ts +28 -51
  123. package/src/event-store/events-schema.ts +1 -10
  124. package/src/event-store/index.ts +3 -2
  125. package/src/event-store/snapshot.ts +11 -35
  126. package/src/event-store/types.ts +2 -0
  127. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  128. package/src/files/file-handle.ts +2 -19
  129. package/src/files/provider-resolver.ts +3 -5
  130. package/src/files/types.ts +5 -54
  131. package/src/i18n/required-surface-keys.ts +1 -1
  132. package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
  133. package/src/logging/types.ts +1 -7
  134. package/src/observability/types/index.ts +1 -29
  135. package/src/observability/types/metric.ts +1 -56
  136. package/src/observability/types/provider.ts +1 -32
  137. package/src/observability/types/span.ts +1 -58
  138. package/src/pipeline/__tests__/dispatcher.test.ts +134 -1
  139. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  140. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
  141. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
  142. package/src/pipeline/dispatch-shared.ts +51 -3
  143. package/src/pipeline/dispatch-stream.ts +74 -0
  144. package/src/pipeline/dispatcher-utils.ts +1 -1
  145. package/src/pipeline/dispatcher.ts +7 -0
  146. package/src/pipeline/entity-cache.ts +2 -33
  147. package/src/pipeline/event-consumer-state.ts +28 -3
  148. package/src/pipeline/event-dispatcher-admin.ts +4 -0
  149. package/src/pipeline/event-dispatcher-delivery.ts +29 -3
  150. package/src/pipeline/event-dispatcher.ts +27 -1
  151. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  152. package/src/pipeline/system-hooks.ts +7 -0
  153. package/src/rate-limit/resolver.ts +10 -30
  154. package/src/search/types.ts +1 -39
  155. package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
  156. package/src/secrets/__tests__/envelope.test.ts +1 -1
  157. package/src/secrets/envelope-cipher.ts +17 -45
  158. package/src/secrets/types.ts +2 -177
  159. package/src/stack/__tests__/event-collector.test.ts +42 -0
  160. package/src/testing/__tests__/late-bound.test.ts +25 -0
  161. package/src/testing/__tests__/wait-for.test.ts +53 -0
  162. package/src/testing/boot-validator-fixture.ts +1 -1
  163. package/src/testing/file-provider-contract.ts +84 -0
  164. package/src/testing/handler-context.ts +1 -1
  165. package/src/testing/index.ts +1 -0
  166. package/src/time/geo-tz.ts +1 -32
  167. package/src/time/tz-context.ts +9 -56
  168. package/src/ui-types/index.ts +7 -7
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/routes.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type Context, Hono } from "hono";
2
+ import { streamSSE } from "hono/streaming";
2
3
  import type { ContentfulStatusCode } from "hono/utils/http-status";
3
4
  import type { SessionUser } from "../engine/types/handlers";
4
5
  import {
@@ -16,6 +17,7 @@ import { Routes } from "./api-constants";
16
17
  import { getUser } from "./auth-middleware";
17
18
  import { patAllows } from "./pat-scope";
18
19
  import { requestContext } from "./request-context";
20
+ import { SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route";
19
21
 
20
22
  export function createApiRoutes(dispatcher: Dispatcher) {
21
23
  const api = new Hono();
@@ -121,6 +123,61 @@ export function createApiRoutes(dispatcher: Dispatcher) {
121
123
  }
122
124
  });
123
125
 
126
+ // Dispatcher-driven SSE, full auth/CSRF/rate-limit chain (unlike the
127
+ // broker-based /sse route). Frame contract for clients: "chunk" (one per
128
+ // yielded value, JSON-encoded), "ping" (heartbeat, empty data), "done"
129
+ // (terminal, empty data), "error" (terminal, JSON error envelope — the
130
+ // response status stays 200 since SSE headers are already flushed before
131
+ // dispatch gates run on the generator's first pull).
132
+ api.post(Routes.stream, async (c) => {
133
+ const user = getUser(c);
134
+ const body = await c.req.json<{ type: string; payload: unknown }>();
135
+ const requestId = requestContext.get()?.requestId;
136
+
137
+ try {
138
+ assertPatAllowed(user, body.type);
139
+ } catch (e) {
140
+ return queryErrorResponse(c, toKumiko(e), body.type);
141
+ }
142
+
143
+ return streamSSE(c, async (stream) => {
144
+ const generator = dispatcher.stream(body.type, body.payload, user);
145
+ stream.onAbort(() => {
146
+ void generator.return(undefined);
147
+ });
148
+
149
+ try {
150
+ let pending = generator.next();
151
+ while (true) {
152
+ let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
153
+ const heartbeat = new Promise<"heartbeat">((resolve) => {
154
+ heartbeatTimer = setTimeout(() => resolve("heartbeat"), SSE_HEARTBEAT_INTERVAL_MS);
155
+ });
156
+ let outcome: Awaited<typeof pending> | "heartbeat";
157
+ try {
158
+ outcome = await Promise.race([pending, heartbeat]);
159
+ } finally {
160
+ clearTimeout(heartbeatTimer);
161
+ }
162
+
163
+ if (outcome === "heartbeat") {
164
+ await stream.writeSSE({ event: "ping", data: "" });
165
+ continue;
166
+ }
167
+ if (outcome.done) break;
168
+ await stream.writeSSE({ event: "chunk", data: stringifyJson(outcome.value) });
169
+ pending = generator.next();
170
+ }
171
+ await stream.writeSSE({ event: "done", data: "" });
172
+ } catch (e) {
173
+ const err = toKumiko(e);
174
+ logServerFault(err, requestId, body.type);
175
+ const { error } = serializeError(err, requestId);
176
+ await stream.writeSSE({ event: "error", data: stringifyJson(error) });
177
+ }
178
+ });
179
+ });
180
+
124
181
  return api;
125
182
  }
126
183
 
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
+ });
@@ -19,6 +19,11 @@
19
19
  // drizzle's getTableName + getTableColumns (drizzle weiterhin als type-
20
20
  // reference, NICHT als runtime-API-call)
21
21
 
22
+ import type {
23
+ SelectOptions,
24
+ WhereObject,
25
+ WhereOperator,
26
+ } from "@cosmicdrift/kumiko-types/where-clause-types";
22
27
  import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index";
23
28
  import type { EntityTableMeta } from "../db/entity-table-meta";
24
29
  import { type NotExecutorOnly, toSnakeCase } from "../db/table-builder";
@@ -183,19 +188,13 @@ function assertNotTenantScoped(db: unknown, fnName: string): void {
183
188
 
184
189
  export type AnyDb = BunDbRunner | unknown;
185
190
 
186
- // WhereValue: primitive für eq, array für IN, null für IS NULL, oder
187
- // operator-object für range/comparisons.
188
- export type WhereOperator = {
189
- readonly gt?: unknown;
190
- readonly gte?: unknown;
191
- readonly lt?: unknown;
192
- readonly lte?: unknown;
193
- readonly ne?: unknown;
194
- readonly in?: readonly unknown[];
195
- readonly like?: string;
196
- };
197
- export type WhereValue = unknown | WhereOperator;
198
- export type WhereObject = Record<string, WhereValue>;
191
+ export type {
192
+ OrderByClause,
193
+ SelectOptions,
194
+ WhereObject,
195
+ WhereOperator,
196
+ WhereValue,
197
+ } from "@cosmicdrift/kumiko-types/where-clause-types";
199
198
 
200
199
  function isWhereOperator(v: unknown): v is WhereOperator {
201
200
  if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
@@ -206,18 +205,6 @@ function isWhereOperator(v: unknown): v is WhereOperator {
206
205
  const opKeys = ["gt", "gte", "lt", "lte", "ne", "in", "like"];
207
206
  return keys.every((k) => opKeys.includes(k));
208
207
  }
209
- export type OrderByClause = {
210
- readonly col: string;
211
- readonly direction?: "asc" | "desc";
212
- };
213
-
214
- export type SelectOptions = {
215
- readonly limit?: number;
216
- // Single column or array for multi-column tie-breaks (e.g.
217
- // [{col: "createdAt"}, {col: "id"}] for chronological-with-stable-id).
218
- readonly orderBy?: OrderByClause | readonly OrderByClause[];
219
- };
220
-
221
208
  // Akzeptiert EITHER. Beide haben einen tableName und field→column-mapping.
222
209
  // biome-ignore lint/suspicious/noExplicitAny: legacy drizzle pgTable surface
223
210
  type TableLike = EntityTableMeta | any;
@@ -562,6 +549,38 @@ function buildWhereClause(
562
549
  return { sqlText: conditions.join(" AND "), values };
563
550
  }
564
551
 
552
+ // #1163: under load the Bun.SQL pool can hand out a connection the server
553
+ // already closed ("The connection was closed.", AbortError code 20) — no
554
+ // validate-on-checkout exists. One retry re-checks out a fresh connection;
555
+ // safe for pure reads. Tx handles are never retried: their transaction is
556
+ // dead once the connection dropped, and a retry would run on the same dead
557
+ // handle. Detection is name+message (not name alone) so a genuine user
558
+ // abort (AbortSignal cancel) is NOT retried.
559
+ function isClosedConnectionError(err: unknown): boolean {
560
+ if (err === null || typeof err !== "object") return false;
561
+ const e = err as { name?: unknown; message?: unknown };
562
+ return (
563
+ e.name === "AbortError" &&
564
+ typeof e.message === "string" &&
565
+ /connection was closed/i.test(e.message)
566
+ );
567
+ }
568
+
569
+ async function unsafeRead<TRow>(
570
+ db: AnyDb,
571
+ sqlText: string,
572
+ params: readonly unknown[],
573
+ ): Promise<readonly TRow[]> {
574
+ const raw = asRawClient(db);
575
+ try {
576
+ return (await raw.unsafe(sqlText, params)) as readonly TRow[];
577
+ } catch (err) {
578
+ // TransactionSql has savepoint(), only a top-level pool client has begin().
579
+ if (typeof raw.begin !== "function" || !isClosedConnectionError(err)) throw err;
580
+ return (await raw.unsafe(sqlText, params)) as readonly TRow[];
581
+ }
582
+ }
583
+
565
584
  // biome-ignore lint/suspicious/noExplicitAny: opt-in default loosens row type for unannotated test fixtures
566
585
  export async function selectMany<TRow = any>(
567
586
  db: AnyDb,
@@ -598,7 +617,7 @@ export async function selectMany<TRow = any>(
598
617
  }
599
618
  sqlText += ` LIMIT ${options.limit}`;
600
619
  }
601
- const raw = (await asRawClient(db).unsafe(sqlText, values)) as readonly Record<string, unknown>[];
620
+ const raw = (await unsafeRead(db, sqlText, values)) as readonly Record<string, unknown>[];
602
621
  return coerceRows(raw, info) as readonly TRow[];
603
622
  }
604
623
 
@@ -832,7 +851,7 @@ export async function countWhere(
832
851
  sqlText += ` WHERE ${w.sqlText}`;
833
852
  values = w.values;
834
853
  }
835
- const rows = (await asRawClient(db).unsafe(sqlText, values)) as readonly { count: number }[];
854
+ const rows = (await unsafeRead(db, sqlText, values)) as readonly { count: number }[];
836
855
  return rows[0]?.count ?? 0;
837
856
  }
838
857
 
@@ -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
+ }