@cosmicdrift/kumiko-framework 0.165.0 → 2.0.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 (146) hide show
  1. package/package.json +5 -3
  2. package/src/__tests__/consumer-cli.integration.test.ts +32 -0
  3. package/src/__tests__/schema-cli.integration.test.ts +1 -1
  4. package/src/api/__tests__/api.test.ts +267 -19
  5. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  6. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +16 -0
  7. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +2 -1
  8. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +64 -1
  9. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
  10. package/src/api/__tests__/batch.integration.test.ts +21 -2
  11. package/src/api/__tests__/jwt.test.ts +52 -2
  12. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +72 -0
  13. package/src/api/__tests__/sse-broker.test.ts +57 -0
  14. package/src/api/__tests__/sse-route.test.ts +4 -0
  15. package/src/api/auth-routes.ts +165 -33
  16. package/src/api/index.ts +1 -0
  17. package/src/api/jwt.ts +22 -1
  18. package/src/api/routes.ts +103 -35
  19. package/src/api/server.ts +17 -1
  20. package/src/api/sse-broker.ts +39 -0
  21. package/src/bun-db/index.ts +1 -0
  22. package/src/bun-db/query.ts +12 -3
  23. package/src/consumer-cli.ts +60 -13
  24. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +14 -1
  25. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  26. package/src/db/__tests__/migrate-runner.test.ts +61 -0
  27. package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
  28. package/src/db/__tests__/tenant-db-where-merge.test.ts +6 -2
  29. package/src/db/api.ts +2 -2
  30. package/src/db/bun-provider.ts +2 -2
  31. package/src/db/connection.ts +6 -3
  32. package/src/db/dialect.ts +1 -6
  33. package/src/db/entity-table-meta-types.ts +1 -1
  34. package/src/db/event-store-executor-context.ts +2 -3
  35. package/src/db/event-store-executor-read.ts +2 -3
  36. package/src/db/event-store-executor-write.ts +8 -0
  37. package/src/db/index.ts +8 -1
  38. package/src/db/located-timestamp.ts +4 -0
  39. package/src/db/migrate-runner.ts +107 -11
  40. package/src/db/pg-error.ts +8 -0
  41. package/src/db/postgres-provider.ts +2 -2
  42. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  43. package/src/db/queries/ddl.ts +45 -0
  44. package/src/db/queries/event-store.ts +97 -5
  45. package/src/db/queries/test-stack.ts +4 -30
  46. package/src/db/reference-data.ts +2 -3
  47. package/src/db/replay-migration-sql.ts +114 -12
  48. package/src/db/tenant-db.ts +2 -4
  49. package/src/engine/__tests__/engine.test.ts +30 -0
  50. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  51. package/src/engine/__tests__/store-table.test.ts +2 -2
  52. package/src/engine/boot-validator/nav.ts +5 -0
  53. package/src/engine/constants.ts +32 -6
  54. package/src/engine/create-app.ts +11 -0
  55. package/src/engine/effective-features.ts +12 -2
  56. package/src/engine/extensions/user-data.ts +12 -4
  57. package/src/engine/feature-ui-extensions.ts +2 -2
  58. package/src/engine/hook-helpers.ts +3 -1
  59. package/src/engine/index.ts +1 -1
  60. package/src/engine/ownership.ts +4 -3
  61. package/src/engine/registry-ingest.ts +14 -14
  62. package/src/engine/registry-state.ts +4 -1
  63. package/src/engine/schema-builder.ts +1 -0
  64. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  65. package/src/engine/steps/_duration-utils.ts +2 -0
  66. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  67. package/src/engine/types/config.ts +1 -1
  68. package/src/engine/types/define-handler.ts +1 -1
  69. package/src/engine/types/entity-handlers.ts +1 -1
  70. package/src/engine/types/event-type-map.ts +1 -1
  71. package/src/engine/types/feature.ts +1 -1
  72. package/src/engine/types/fields.ts +1 -1
  73. package/src/engine/types/handlers.ts +1 -1
  74. package/src/engine/types/hooks.ts +1 -1
  75. package/src/engine/types/http-route.ts +1 -1
  76. package/src/engine/types/nav.ts +1 -1
  77. package/src/engine/types/ownership.ts +1 -1
  78. package/src/engine/types/projection.ts +1 -1
  79. package/src/engine/types/relations.ts +1 -1
  80. package/src/engine/types/screen.ts +1 -1
  81. package/src/engine/types/step.ts +1 -1
  82. package/src/engine/types/target-ref.ts +1 -1
  83. package/src/engine/types/tree-node.ts +1 -1
  84. package/src/engine/types/workspace.ts +1 -1
  85. package/src/engine/validate-projection-allowlist.ts +5 -5
  86. package/src/errors/classes.ts +21 -0
  87. package/src/errors/index.ts +1 -0
  88. package/src/errors/write-error-info.ts +6 -2
  89. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  90. package/src/event-store/__tests__/event-store.integration.test.ts +32 -0
  91. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +22 -4
  92. package/src/event-store/admin-api.ts +11 -4
  93. package/src/event-store/event-store.ts +19 -4
  94. package/src/event-store/types.ts +1 -1
  95. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  96. package/src/files/__tests__/local-provider.test.ts +31 -0
  97. package/src/files/__tests__/write-stream.test.ts +3 -3
  98. package/src/files/index.ts +1 -1
  99. package/src/files/local-provider.ts +6 -1
  100. package/src/files/types.ts +8 -1
  101. package/src/jobs/__tests__/jobs.integration.test.ts +167 -7
  102. package/src/jobs/job-runner.ts +41 -11
  103. package/src/logging/types.ts +1 -1
  104. package/src/observability/index.ts +1 -0
  105. package/src/observability/standard-metrics.ts +35 -2
  106. package/src/observability/types/index.ts +1 -1
  107. package/src/observability/types/metric.ts +1 -1
  108. package/src/observability/types/provider.ts +1 -1
  109. package/src/observability/types/span.ts +1 -1
  110. package/src/pipeline/__tests__/dispatcher.test.ts +151 -0
  111. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  112. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +83 -0
  113. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +107 -97
  114. package/src/pipeline/dispatch-shared.ts +59 -6
  115. package/src/pipeline/dispatch-stream.ts +32 -11
  116. package/src/pipeline/dispatcher.ts +7 -1
  117. package/src/pipeline/event-consumer-state.ts +16 -13
  118. package/src/pipeline/event-dispatcher-delivery.ts +20 -6
  119. package/src/pipeline/event-dispatcher.ts +22 -0
  120. package/src/pipeline/index.ts +2 -0
  121. package/src/pipeline/system-hooks.ts +87 -0
  122. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  123. package/src/rate-limit/resolver.ts +6 -2
  124. package/src/schema-cli.ts +24 -12
  125. package/src/search/__tests__/reindex-entity.integration.test.ts +24 -1
  126. package/src/search/reindex-entity.ts +31 -2
  127. package/src/search/types.ts +1 -1
  128. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +6 -2
  129. package/src/stack/db.ts +2 -1
  130. package/src/stack/push-entity-projection-tables.ts +2 -1
  131. package/src/stack/request-helper.ts +20 -1
  132. package/src/stack/table-helpers.ts +6 -4
  133. package/src/stack/test-stack.ts +18 -15
  134. package/src/testing/__tests__/late-bound.test.ts +7 -0
  135. package/src/testing/__tests__/wait-for.test.ts +6 -0
  136. package/src/testing/file-provider-contract.ts +26 -6
  137. package/src/testing/index.ts +1 -0
  138. package/src/testing/late-bound.ts +5 -3
  139. package/src/testing/wait-for.ts +3 -0
  140. package/src/testing/without-ambient-temporal.ts +14 -0
  141. package/src/time/geo-tz.ts +1 -1
  142. package/src/time/polyfill.ts +21 -38
  143. package/src/time/tz-context.ts +37 -31
  144. package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
  145. package/src/utils/safe-json.ts +13 -1
  146. package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import type { Context } from "hono";
2
3
  import { Hono } from "hono";
3
4
  import { deleteCookie, setCookie } from "hono/cookie";
@@ -217,6 +218,12 @@ export type SessionCreator = (user: SessionUser, meta: SessionMetadata) => Promi
217
218
  // there's nothing to revoke.
218
219
  export type SessionRevoker = (sid: string) => Promise<void>;
219
220
 
221
+ // Mass-revoke every live session for a user. Used by password-change and
222
+ // "sign out everywhere" (sessions feature) and by auth-foundation's
223
+ // SessionStore extension point — canonical home so both can re-export
224
+ // the same type instead of restating it.
225
+ export type SessionMassRevoker = (userId: string) => Promise<number>;
226
+
220
227
  // Status reported by the session-store to the auth-middleware. The concrete
221
228
  // type lives on auth-middleware to keep the tight coupling visible there;
222
229
  // auth-routes just re-uses the alias for the AuthRoutesConfig surface.
@@ -274,15 +281,21 @@ export type AuthRoutesConfig = {
274
281
  // /auth/mfa/preauth-enable-start dispatches { preauthSetupToken,
275
282
  // accountLabel } to this handler with a guest identity — no session is
276
283
  // minted, the response is just { setupToken, otpauthUri, recoveryCodes }
277
- // (same shape as auth-mfa's own enable-start.write.ts). No dedicated
278
- // rate-limiter here (unlike mfaVerifyRateLimit): the route already
279
- // inherits the generic L2 authEndpointRateLimit on /api/auth/*, and this
280
- // handler checks no TOTP code, so there's no per-account guessing
281
- // surface to cap — that cap belongs on the later confirm handler.
284
+ // (same shape as auth-mfa's own enable-start.write.ts).
282
285
  mfaPreauthEnableStartHandler?: string;
283
286
  // Maps mfaPreauthEnableStartHandler error codes to HTTP status codes,
284
287
  // same pattern as mfaVerifyErrorStatusMap.
285
288
  mfaPreauthEnableStartErrorStatusMap?: Readonly<Record<string, number>>;
289
+ // Rate-limit for POST /auth/mfa/preauth-enable-start, keyed by client IP.
290
+ // Defaults to in-memory 10/5min. Pass `null` to disable. This route does
291
+ // NOT inherit the generic L2 authEndpointRateLimit on /api/auth/* by
292
+ // default (that's opt-in per-app via runProdApp's rateLimit.auth), and a
293
+ // preauthSetupToken is valid for the full setup-token TTL and not
294
+ // single-use until preauth-confirm burns it — an unrate-limited replay
295
+ // means every hit re-derives a fresh TOTP secret + 8 argon2id recovery-
296
+ // code hashes (recovery-codes.ts, Promise.all), a memory-hard CPU
297
+ // amplifier reachable from a single stolen/leaked token.
298
+ mfaPreauthEnableStartRateLimit?: LoginRateLimiter | null;
286
299
  // Optional: qualified write handler completing the enrollment started by
287
300
  // mfaPreauthEnableStartHandler — takes the secret-carrying setupToken
288
301
  // from that step plus a TOTP code. When set, POST /auth/mfa/preauth-
@@ -398,6 +411,20 @@ export type AuthRoutesConfig = {
398
411
  // subdomain can then forge authenticated state-changing requests. Prefer
399
412
  // setting `allowedOrigins`.
400
413
  unsafeSkipOriginCheck?: boolean;
414
+ // Number of trusted reverse-proxy hops between the client and this
415
+ // process that APPEND (not overwrite) their peer address to
416
+ // `x-forwarded-for` (e.g. nginx `$proxy_add_x_forwarded_for`). Used to
417
+ // derive the client IP for every auth rate-limiter (login, mfa-verify,
418
+ // preauth-enable-start, preauth-confirm) and for requestMeta's session
419
+ // IP — kumiko-framework#1539. Default 0 = legacy behavior, trust the
420
+ // first XFF entry (or x-real-ip) unconditionally; spoofable by design,
421
+ // kept as the default so unconfigured deployments don't regress into a
422
+ // shared "unknown" bucket (mfa-verify/preauth-confirm key on bare IP, no
423
+ // email composite — collapsing everyone into one bucket is a DoS worse
424
+ // than the spoofing hole). Set this to your real proxy hop count (1 for
425
+ // a single ingress/reverse-proxy, 2 for edge-LB + ingress, etc.) to close
426
+ // the hole; see clientIpOf's doc comment for the extraction algorithm.
427
+ trustedProxyHops?: number;
401
428
  };
402
429
 
403
430
  export type PasswordResetConfig = {
@@ -452,15 +479,73 @@ export type SignupConfig = {
452
479
  confirmHandler: string;
453
480
  };
454
481
 
482
+ // Derives the caller IP from proxy headers, single source for the
483
+ // rate-limiter keys below and requestMeta. kumiko-framework#1523/#1522/#1539:
484
+ // `x-forwarded-for` is attacker-controlled unless we know how many trusted
485
+ // proxy hops sit between the client and this process — `trustedProxyHops`
486
+ // (AuthRoutesConfig) is that count.
487
+ //
488
+ // hops === 0 (default): legacy behavior — trust the first XFF entry (or
489
+ // x-real-ip) at face value. Kept as the default so unconfigured
490
+ // deployments don't regress: collapsing everyone into a single "unknown"
491
+ // bucket would turn mfa-verify/preauth-confirm's pure-IP-keyed limiter
492
+ // into a one-request-locks-out-everyone DoS, which is worse than the
493
+ // spoofing hole this issue is about. Apps behind a proxy MUST set this to
494
+ // close the hole — see AuthRoutesConfig.trustedProxyHops.
495
+ //
496
+ // hops >= 1: each trusted proxy appends (not overwrites) its peer address
497
+ // to XFF (e.g. nginx `$proxy_add_x_forwarded_for`), so the last `hops`
498
+ // entries are proxy-supplied and everything before them — including the
499
+ // real client — sits at `entries[length - hops]`. Anything the client
500
+ // itself prepended lands further left and is ignored. If the header has
501
+ // fewer entries than `hops`, the proxy chain is shorter than configured
502
+ // (misconfiguration or a bypassed hop) — fall back to "unknown" rather
503
+ // than trusting a shorter, potentially attacker-controlled chain.
504
+ // x-real-ip is NOT consulted in this branch: unlike XFF it has no
505
+ // standardized hop-count semantics, so there's no safe way to validate it
506
+ // against a configured hop count.
507
+ //
508
+ // Callers that need a hard boundary regardless of this config
509
+ // (preauth-enable-start) additionally key on something the caller can't
510
+ // freely choose (see preauthTokenKeyOf below).
511
+ function clientIpOf(
512
+ c: { req: { header(name: string): string | undefined } },
513
+ trustedProxyHops = 0,
514
+ ): string {
515
+ if (trustedProxyHops <= 0) {
516
+ return (
517
+ c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
518
+ c.req.header("x-real-ip") ??
519
+ "unknown"
520
+ );
521
+ }
522
+ const entries = (c.req.header("x-forwarded-for") ?? "")
523
+ .split(",")
524
+ .map((entry) => entry.trim())
525
+ .filter((entry) => entry.length > 0);
526
+ if (entries.length < trustedProxyHops) {
527
+ return "unknown";
528
+ }
529
+ return entries[entries.length - trustedProxyHops] ?? "unknown";
530
+ }
531
+
532
+ // Second rate-limit axis for preauth-enable-start: unlike the IP, a
533
+ // preauthSetupToken isn't freely choosable by the attacker (it's minted by
534
+ // login.write.ts), so hashing it into a bucket key still caps the replay
535
+ // even when the IP-based bucket is bypassed via header rotation.
536
+ function preauthTokenKeyOf(token: string): string {
537
+ return `preauth-token:${createHash("sha256").update(token).digest("hex")}`;
538
+ }
539
+
455
540
  // Extract `ip` and `user-agent` for the sessionCreator.
456
541
  // Hono's `c.req.header(...)` returns undefined for missing headers; we coerce
457
542
  // them to "unknown" rather than throwing because auth-routes are a public
458
543
  // surface and we don't want header-sniffing bugs to break login.
459
- function requestMeta(c: { req: { header(name: string): string | undefined } }): SessionMetadata {
460
- const ip =
461
- c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
462
- c.req.header("x-real-ip") ??
463
- "unknown";
544
+ function requestMeta(
545
+ c: { req: { header(name: string): string | undefined } },
546
+ trustedProxyHops = 0,
547
+ ): SessionMetadata {
548
+ const ip = clientIpOf(c, trustedProxyHops);
464
549
  const userAgent = c.req.header("user-agent") ?? "unknown";
465
550
  return { ip, userAgent };
466
551
  }
@@ -534,6 +619,12 @@ export function createInMemoryLoginRateLimiter(
534
619
  // each replica its own bucket. namespace separates the login-key keyspace
535
620
  // from the mfa-verify one (they share the same LoginRateLimiter shape but
536
621
  // key on different values).
622
+ // Fail-closed on Redis outage, unlike the in-memory limiter (which never
623
+ // throws): `check`/`reset` propagate any Redis error, so callers 500
624
+ // instead of falling back to unlimited attempts. Deliberate — Redis is
625
+ // already required infra for a multi-replica deployment, and for a
626
+ // security-relevant limiter "briefly unavailable" should read as "briefly
627
+ // down", not "briefly unlimited".
537
628
  export function createRedisLoginRateLimiter(
538
629
  redis: Redis,
539
630
  maxAttempts = 10,
@@ -545,10 +636,26 @@ export function createRedisLoginRateLimiter(
545
636
  return {
546
637
  async check(key) {
547
638
  const redisKey = `${prefix}${key}`;
548
- const count = await redis.incr(redisKey);
549
- if (count === 1) {
550
- await redis.pexpire(redisKey, windowMs);
551
- }
639
+ // INCR then PEXPIRE was two round-trips: a crash/network blip between
640
+ // them (only possible right after count===1, when the key is fresh)
641
+ // left the key permanently without a TTL — the window never resets,
642
+ // the counter only grows, and the bucket is locked out until an
643
+ // operator manually resets/deletes the key. One atomic eval closes
644
+ // the gap: the TTL is set in the same script invocation that creates
645
+ // the key, so no observer (including a crash) can see count===1
646
+ // without the expiry already applied. The `PTTL < 0` branch also
647
+ // heals keys that already exist without a TTL from before this fix
648
+ // shipped (PTTL returns -1 for "no expiry set, key exists") — without
649
+ // it, those pre-existing keys would grow forever with no way back to
650
+ // a normal window.
651
+ const count = (await redis.eval(
652
+ `local c = redis.call('INCR', KEYS[1])
653
+ if c == 1 or redis.call('PTTL', KEYS[1]) < 0 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
654
+ return c`,
655
+ 1,
656
+ redisKey,
657
+ windowMs,
658
+ )) as number;
552
659
  return count <= maxAttempts;
553
660
  },
554
661
  async reset(key) {
@@ -568,6 +675,21 @@ export function createAuthRoutes(
568
675
  // working. High-security apps can opt into "strict" — see AuthRoutesConfig.
569
676
  const cookieSameSite = config.cookieSameSite ?? "lax";
570
677
  const cookieDomain = config.cookieDomain;
678
+ // Single hop-count-aware IP getter for every rate-limit call site below —
679
+ // see AuthRoutesConfig.trustedProxyHops / clientIpOf's doc comment. Fail
680
+ // loud on a non-finite/negative value rather than letting it silently
681
+ // reach clientIpOf, where NaN/negative behaves like "chain too short"
682
+ // and collapses every request into the shared "unknown" bucket.
683
+ if (
684
+ config.trustedProxyHops !== undefined &&
685
+ (!Number.isInteger(config.trustedProxyHops) || config.trustedProxyHops < 0)
686
+ ) {
687
+ throw new Error(
688
+ `createAuthRoutes: trustedProxyHops must be a non-negative integer, got ${config.trustedProxyHops}.`,
689
+ );
690
+ }
691
+ const trustedProxyHops = config.trustedProxyHops ?? 0;
692
+ const getClientIp = (c: Context): string => clientIpOf(c, trustedProxyHops);
571
693
 
572
694
  // Shared tail of every route that ends a request logged-in: create the
573
695
  // session record (if wired), sign the JWT, set the auth+csrf cookies. Was
@@ -577,7 +699,7 @@ export function createAuthRoutes(
577
699
  async function mintSessionAndRespond(c: Context, session: SessionUser): Promise<string> {
578
700
  let sessionForJwt = session;
579
701
  if (config.sessionCreator) {
580
- const sid = await config.sessionCreator(session, requestMeta(c));
702
+ const sid = await config.sessionCreator(session, requestMeta(c, trustedProxyHops));
581
703
  sessionForJwt = { ...session, sid };
582
704
  }
583
705
  const token = await jwt.sign(sessionForJwt);
@@ -612,13 +734,7 @@ export function createAuthRoutes(
612
734
  }
613
735
  const body = parsed.data;
614
736
 
615
- // Client IP derivation is shared between rate-limit check and reset,
616
- // so compute once. Falls back to "unknown" when no proxy header is
617
- // present — consistent bucket for direct-to-server test setups.
618
- const clientIp =
619
- c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
620
- c.req.header("x-real-ip") ??
621
- "unknown";
737
+ const clientIp = getClientIp(c);
622
738
  const rateLimitKey = `${clientIp}|${body.email.toLowerCase()}`;
623
739
 
624
740
  if (rateLimiter) {
@@ -719,10 +835,7 @@ export function createAuthRoutes(
719
835
  }
720
836
  const body = parsed.data;
721
837
 
722
- const clientIp =
723
- c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
724
- c.req.header("x-real-ip") ??
725
- "unknown";
838
+ const clientIp = getClientIp(c);
726
839
 
727
840
  if (rateLimiter) {
728
841
  const allowed = await rateLimiter.check(clientIp);
@@ -771,12 +884,17 @@ export function createAuthRoutes(
771
884
  // No JWT is minted here (unlike /auth/login and /auth/mfa/verify) — the
772
885
  // handler's response carries a secret-bearing setupToken that a later
773
886
  // pre-auth confirm step (the same shape auth-mfa's own enable-confirm
774
- // consumes) verifies. No dedicated rate-limiter: see
775
- // AuthRoutesConfig.mfaPreauthEnableStartHandler's doc comment for why
776
- // the generic L2 /api/auth/* limiter is enough here.
887
+ // consumes) verifies. Rate-limited like mfaVerifyRateLimit/
888
+ // mfaPreauthConfirmRateLimit a preauthSetupToken is valid and
889
+ // not-single-use for its whole TTL, so without this cap a replay is a
890
+ // memory-hard CPU amplifier (see AuthRoutesConfig doc comment).
777
891
  if (config.mfaPreauthEnableStartHandler) {
778
892
  const mfaPreauthEnableStartQn = config.mfaPreauthEnableStartHandler;
779
893
  const statusMap = config.mfaPreauthEnableStartErrorStatusMap ?? {};
894
+ const rateLimiter =
895
+ config.mfaPreauthEnableStartRateLimit === null
896
+ ? null
897
+ : (config.mfaPreauthEnableStartRateLimit ?? createInMemoryLoginRateLimiter());
780
898
 
781
899
  api.post(Routes.authMfaPreauthEnableStart, async (c) => {
782
900
  const raw = await c.req.json().catch(() => null);
@@ -786,6 +904,23 @@ export function createAuthRoutes(
786
904
  }
787
905
  const body = parsed.data;
788
906
 
907
+ const clientIp = getClientIp(c);
908
+
909
+ if (rateLimiter) {
910
+ const allowed = await rateLimiter.check(clientIp);
911
+ if (!allowed) {
912
+ return c.json({ isSuccess: false, error: "rate_limited" }, 429);
913
+ }
914
+ // Second axis, independent of the IP-derived bucket above: the
915
+ // preauthSetupToken is minted by login.write.ts and not freely
916
+ // choosable, so this cap survives an attacker rotating
917
+ // x-forwarded-for to bypass the IP bucket (kumiko-framework#1522).
918
+ const tokenAllowed = await rateLimiter.check(preauthTokenKeyOf(body.preauthSetupToken));
919
+ if (!tokenAllowed) {
920
+ return c.json({ isSuccess: false, error: "rate_limited" }, 429);
921
+ }
922
+ }
923
+
789
924
  const result = await dispatcher.write(mfaPreauthEnableStartQn, body, GUEST_USER);
790
925
 
791
926
  if (!result.isSuccess) {
@@ -839,10 +974,7 @@ export function createAuthRoutes(
839
974
  }
840
975
  const body = parsed.data;
841
976
 
842
- const clientIp =
843
- c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
844
- c.req.header("x-real-ip") ??
845
- "unknown";
977
+ const clientIp = getClientIp(c);
846
978
 
847
979
  if (rateLimiter) {
848
980
  const allowed = await rateLimiter.check(clientIp);
package/src/api/index.ts CHANGED
@@ -17,6 +17,7 @@ export type {
17
17
  LoginRateLimiter,
18
18
  SessionChecker,
19
19
  SessionCreator,
20
+ SessionMassRevoker,
20
21
  SessionMetadata,
21
22
  SessionRevoker,
22
23
  } from "./auth-routes";
package/src/api/jwt.ts CHANGED
@@ -191,7 +191,13 @@ export function loadJwtSecretOrKeyring(
191
191
  const keys: Record<string, string> = {};
192
192
  for (const [name, value] of Object.entries(env)) {
193
193
  const match = name.match(JWT_KEY_VAR_PATTERN);
194
- if (!match || !value) continue;
194
+ if (!match) continue;
195
+ if (!value) {
196
+ throw new Error(
197
+ `[jwt] ${name} is set but empty — an empty versioned secret is almost certainly a ` +
198
+ "deploy mistake, not an intentional skip. Unset the var entirely if it's unused.",
199
+ );
200
+ }
195
201
  assertMinLength(name, value);
196
202
  // biome-ignore lint/style/noNonNullAssertion: regex group 1 always present
197
203
  keys[`v${match[1]!}`] = value;
@@ -225,5 +231,20 @@ export function loadJwtSecretOrKeyring(
225
231
  `Check JWT_SECRET_V${currentRaw} is set.`,
226
232
  );
227
233
  }
234
+ // Carry the pre-rotation plain JWT_SECRET into the ring as a verify-only
235
+ // legacy key: sessions signed before JWT_SECRET_V<n> was first set have no
236
+ // `kid`, so verifyWithKeyring's no-kid fallback tries every key in `keys` —
237
+ // if the plain secret isn't one of them, every in-flight session breaks the
238
+ // moment rotation is adopted, exactly the mass-invalidation this keyring
239
+ // exists to avoid. Never becomes signKid — only JWT_SECRET_V<n> can sign.
240
+ // Retirement: has no automatic expiry — verifies unbounded as long as
241
+ // JWT_SECRET stays set. Operators must explicitly unset JWT_SECRET once
242
+ // max token TTL (`ttlSeconds`) has elapsed since cutover to actually
243
+ // retire a rotated-out secret (see run-prod-app.ts boot warning).
244
+ const legacySecret = env["JWT_SECRET"];
245
+ if (legacySecret) {
246
+ assertMinLength("JWT_SECRET", legacySecret);
247
+ keys["legacy"] = legacySecret;
248
+ }
228
249
  return { keys, signKid };
229
250
  }
package/src/api/routes.ts CHANGED
@@ -19,7 +19,25 @@ import { patAllows } from "./pat-scope";
19
19
  import { requestContext } from "./request-context";
20
20
  import { SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route";
21
21
 
22
- export function createApiRoutes(dispatcher: Dispatcher) {
22
+ // SSE frame event names for POST /api/stream (framework-owned; dispatcher-live
23
+ // has no dependency on this package and keeps its own copy in sse-stream.ts —
24
+ // a drift between the two fails the real-HTTP frame assertions in api.test.ts).
25
+ export const StreamFrame = {
26
+ chunk: "chunk",
27
+ ping: "ping",
28
+ done: "done",
29
+ error: "error",
30
+ } as const;
31
+
32
+ export type ApiRoutesOptions = {
33
+ // Override the SSE heartbeat interval (ms). Production uses
34
+ // SSE_HEARTBEAT_INTERVAL_MS; tests pass a short value so the pre-pull
35
+ // race + ping path can be exercised without a 15s wait.
36
+ readonly sseHeartbeatMs?: number;
37
+ };
38
+
39
+ export function createApiRoutes(dispatcher: Dispatcher, options: ApiRoutesOptions = {}) {
40
+ const heartbeatMs = options.sseHeartbeatMs ?? SSE_HEARTBEAT_INTERVAL_MS;
23
41
  const api = new Hono();
24
42
 
25
43
  api.post(Routes.write, async (c) => {
@@ -124,35 +142,76 @@ export function createApiRoutes(dispatcher: Dispatcher) {
124
142
  });
125
143
 
126
144
  // 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).
145
+ // broker-based /sse route). Frame contract for clients: StreamFrame.chunk
146
+ // (one per yielded value, JSON-encoded), .ping (heartbeat, empty data),
147
+ // .done (terminal, empty data), .error (terminal, JSON error envelope —
148
+ // only reachable once the stream is already open, i.e. failures from the
149
+ // second chunk onward). The generator's first `.next()` — which runs the
150
+ // dispatch gates (feature/rate-limit/access/validation) plus the handler's
151
+ // first yield — is raced against a heartbeat timeout BEFORE streamSSE, so
152
+ // a gate failure that settles in time maps to its real HTTP status via
153
+ // queryErrorResponse instead of a flushed-200 error frame (framework#1517).
132
154
  api.post(Routes.stream, async (c) => {
133
155
  const user = getUser(c);
134
156
  const body = await c.req.json<{ type: string; payload: unknown }>();
135
157
  const requestId = requestContext.get()?.requestId;
136
158
 
159
+ const generator = dispatcher.stream(body.type, body.payload, user);
137
160
  try {
138
161
  assertPatAllowed(user, body.type);
139
162
  } catch (e) {
140
163
  return queryErrorResponse(c, toKumiko(e), body.type);
141
164
  }
142
165
 
166
+ // stream.onAbort() only exists once streamSSE opens the response below.
167
+ // Hook the raw request signal directly so a client disconnect during the
168
+ // pre-pull still reclaims the generator instead of leaking a Redis
169
+ // subscription/DB cursor held open inside its still-unresolved first
170
+ // `.next()` (framework#1528).
171
+ const signal = c.req.raw.signal;
172
+ const onPrePullAbort = () => void generator.return(undefined);
173
+ signal.addEventListener("abort", onPrePullAbort);
174
+
175
+ const firstPull = generator.next();
176
+ let prePullTimer: ReturnType<typeof setTimeout> | undefined;
177
+ const settledInTime = await Promise.race([
178
+ firstPull.then(() => true).catch(() => true),
179
+ new Promise<false>((resolve) => {
180
+ prePullTimer = setTimeout(() => resolve(false), heartbeatMs);
181
+ }),
182
+ ]);
183
+ clearTimeout(prePullTimer);
184
+ signal.removeEventListener("abort", onPrePullAbort);
185
+
186
+ if (settledInTime) {
187
+ try {
188
+ await firstPull;
189
+ } catch (e) {
190
+ return queryErrorResponse(c, toKumiko(e), body.type);
191
+ }
192
+ }
193
+
194
+ if (signal.aborted) {
195
+ // Fire-and-forget: settledInTime === false means firstPull is by
196
+ // definition still pending — V8 queues a .return() request behind an
197
+ // in-flight .next(), so awaiting here would block the response until
198
+ // that pending pull resolves (which may be never for an idle stream).
199
+ void generator.return(undefined).catch(() => {});
200
+ return c.body(null, 499 as ContentfulStatusCode); // @cast-boundary non-standard client-closed-request status, Hono's union doesn't include it
201
+ }
202
+
143
203
  return streamSSE(c, async (stream) => {
144
- const generator = dispatcher.stream(body.type, body.payload, user);
145
204
  stream.onAbort(() => {
146
205
  void generator.return(undefined);
147
206
  });
148
207
 
149
208
  try {
150
- await pumpStream(stream, generator, SSE_HEARTBEAT_INTERVAL_MS);
209
+ await pumpStream(stream, generator, heartbeatMs, firstPull);
151
210
  } catch (e) {
152
211
  const err = toKumiko(e);
153
212
  logServerFault(err, requestId, body.type);
154
213
  const { error } = serializeError(err, requestId);
155
- await stream.writeSSE({ event: "error", data: stringifyJson(error) });
214
+ await stream.writeSSE({ event: StreamFrame.error, data: stringifyJson(error) });
156
215
  }
157
216
  });
158
217
  });
@@ -164,40 +223,49 @@ export type SseWriter = {
164
223
  readonly writeSSE: (message: { readonly event: string; readonly data: string }) => Promise<void>;
165
224
  };
166
225
 
167
- // Pull loop for /api/stream: races each generator.next() against a
168
- // heartbeat timer so a slow/idle handler still keeps the connection alive
169
- // ("ping" frames), forwards yielded chunks as "chunk" frames, and emits
170
- // "done" once the generator completes. Factored out of the route handler
171
- // so the heartbeat/ping and abort/cleanup paths are unit-testable with a
172
- // fast heartbeatMs instead of only reachable through SSE_HEARTBEAT_INTERVAL_MS
173
- // (15s) in a full HTTP round-trip.
226
+ // Races each generator.next() against a heartbeat timer so an idle handler keeps the SSE connection alive.
174
227
  export async function pumpStream(
175
228
  stream: SseWriter,
176
229
  generator: AsyncGenerator<unknown>,
177
230
  heartbeatMs: number,
231
+ // Pending (or already-settled) first `.next()` — see the /api/stream route,
232
+ // which races this against a heartbeat timeout before opening streamSSE.
233
+ firstPull?: Promise<IteratorResult<unknown>>,
178
234
  ): Promise<void> {
179
- let pending = generator.next();
180
- while (true) {
181
- let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
182
- const heartbeat = new Promise<"heartbeat">((resolve) => {
183
- heartbeatTimer = setTimeout(() => resolve("heartbeat"), heartbeatMs);
184
- });
185
- let outcome: Awaited<typeof pending> | "heartbeat";
186
- try {
187
- outcome = await Promise.race([pending, heartbeat]);
188
- } finally {
189
- clearTimeout(heartbeatTimer);
190
- }
235
+ let pending = firstPull ?? generator.next();
236
+ try {
237
+ while (true) {
238
+ let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
239
+ const heartbeat = new Promise<"heartbeat">((resolve) => {
240
+ heartbeatTimer = setTimeout(() => resolve("heartbeat"), heartbeatMs);
241
+ });
242
+ let outcome: Awaited<typeof pending> | "heartbeat";
243
+ try {
244
+ outcome = await Promise.race([pending, heartbeat]);
245
+ } finally {
246
+ clearTimeout(heartbeatTimer);
247
+ }
191
248
 
192
- if (outcome === "heartbeat") {
193
- await stream.writeSSE({ event: "ping", data: "" });
194
- continue;
249
+ if (outcome === "heartbeat") {
250
+ await stream.writeSSE({ event: StreamFrame.ping, data: "" });
251
+ continue;
252
+ }
253
+ if (outcome.done) break;
254
+ await stream.writeSSE({
255
+ event: StreamFrame.chunk,
256
+ data: stringifyJson(outcome.value ?? null),
257
+ });
258
+ pending = generator.next();
195
259
  }
196
- if (outcome.done) break;
197
- await stream.writeSSE({ event: "chunk", data: stringifyJson(outcome.value) });
198
- pending = generator.next();
260
+ await stream.writeSSE({ event: StreamFrame.done, data: "" });
261
+ } finally {
262
+ // Fire-and-forget: if writeSSE threw mid-loop, `pending` (the last
263
+ // generator.next()) may still be unresolved — V8 queues .return()
264
+ // behind an in-flight .next(), so awaiting here would hang until that
265
+ // pull settles (which may be never for a handler stuck on a dead
266
+ // Redis/DB subscription the disconnect just orphaned).
267
+ void generator.return(undefined).catch(() => {});
199
268
  }
200
- await stream.writeSSE({ event: "done", data: "" });
201
269
  }
202
270
 
203
271
  function jsonResponse(c: Context, body: unknown, status: ContentfulStatusCode = 200) {
package/src/api/server.ts CHANGED
@@ -34,6 +34,7 @@ import { createEventDispatcher } from "../pipeline/event-dispatcher";
34
34
  import { createLifecycleHooks, type SystemHooks } from "../pipeline/lifecycle-pipeline";
35
35
  import { createMultiStreamApplyContext } from "../pipeline/multi-stream-apply-context";
36
36
  import {
37
+ createAccessInvalidationEventConsumer,
37
38
  createJobTriggerEventConsumer,
38
39
  createSearchEventConsumer,
39
40
  createSseBroadcastEventConsumer,
@@ -113,7 +114,12 @@ export type ServerOptions = {
113
114
  // search enabled when the respective dependency (sseBroker /
114
115
  // context.searchAdapter) is available; jobTrigger enabled when a
115
116
  // jobRunner is wired via dispatcherOptions.
116
- systemConsumers?: { sse?: boolean; search?: boolean; jobTrigger?: boolean };
117
+ systemConsumers?: {
118
+ sse?: boolean;
119
+ search?: boolean;
120
+ jobTrigger?: boolean;
121
+ accessInvalidation?: boolean;
122
+ };
117
123
  // Raw postgres.js client for LISTEN/NOTIFY wake-up (Sprint E.4). When
118
124
  // present, `.start()` subscribes to EVENTS_PUBSUB_CHANNEL — delivery
119
125
  // latency drops from pollIntervalMs to TCP-round-trip. The poll timer
@@ -379,6 +385,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
379
385
  const dispatcher = createDispatcher(options.registry, contextWithObservability, {
380
386
  ...options.dispatcherOptions,
381
387
  lifecycle,
388
+ sseBroker,
382
389
  });
383
390
 
384
391
  // Async event-dispatcher — the replacement for the old transactional
@@ -414,6 +421,15 @@ export function buildServer(options: ServerOptions): KumikoServer {
414
421
  if (jobTriggerConsumerEnabled && jobRunnerForTriggers) {
415
422
  systemConsumers.push(createJobTriggerEventConsumer(jobRunnerForTriggers, options.registry));
416
423
  }
424
+ // Default ON (#1524 global-by-default). Tests that opt out of SSE via
425
+ // systemConsumers.accessInvalidation=false (test-stack mirrors sse off)
426
+ // skip the row so retention prune suites are not blocked by a lagging
427
+ // cursor=0 consumer they never drain.
428
+ const accessInvalidationEnabled =
429
+ options.eventDispatcher?.systemConsumers?.accessInvalidation ?? true;
430
+ if (accessInvalidationEnabled) {
431
+ systemConsumers.push(createAccessInvalidationEventConsumer(sseBroker));
432
+ }
417
433
 
418
434
  // MultiStreamProjections: one EventConsumer per MSP. Handler routes by
419
435
  // event.type into the MSP's apply map. MSPs aggregate cross-aggregate but
@@ -1,3 +1,4 @@
1
+ import { userAccessChannel } from "../engine/constants";
1
2
  import { generateId } from "../utils";
2
3
 
3
4
  export type SseClient = {
@@ -17,10 +18,18 @@ export type SseBroker = {
17
18
  pushToChannel(channel: string, event: SseEvent): void;
18
19
  getClientCount(channel: string): number;
19
20
  getTotalClientCount(): number;
21
+ // Internal (non-SSE-client) subscription, e.g. dispatch-stream watching
22
+ // for mid-stream access revocation. Kept separate from addClient/
23
+ // pushToChannel: those count towards getClientCount/getTotalClientCount
24
+ // (real SSE connections) and their send/close shape doesn't fit a plain
25
+ // callback listener. Returns an unsubscribe function.
26
+ subscribeAccessInvalidation(userId: string, onInvalidate: () => void): () => void;
27
+ publishAccessInvalidation(userId: string): void;
20
28
  };
21
29
 
22
30
  export function createSseBroker(): SseBroker {
23
31
  const channels = new Map<string, Map<string, SseClient>>();
32
+ const accessInvalidationListeners = new Map<string, Map<string, () => void>>();
24
33
 
25
34
  function getOrCreateChannel(channel: string): Map<string, SseClient> {
26
35
  let clients = channels.get(channel);
@@ -67,5 +76,35 @@ export function createSseBroker(): SseBroker {
67
76
  }
68
77
  return total;
69
78
  },
79
+
80
+ subscribeAccessInvalidation(userId, onInvalidate) {
81
+ const channel = userAccessChannel(userId);
82
+ const listenerId = generateId();
83
+ let listeners = accessInvalidationListeners.get(channel);
84
+ if (!listeners) {
85
+ listeners = new Map();
86
+ accessInvalidationListeners.set(channel, listeners);
87
+ }
88
+ listeners.set(listenerId, onInvalidate);
89
+ return () => {
90
+ const current = accessInvalidationListeners.get(channel);
91
+ // skip: already unsubscribed (e.g. stream ended after a publish already fired)
92
+ if (!current) return;
93
+ current.delete(listenerId);
94
+ if (current.size === 0) accessInvalidationListeners.delete(channel);
95
+ };
96
+ },
97
+
98
+ publishAccessInvalidation(userId) {
99
+ const channel = userAccessChannel(userId);
100
+ const listeners = accessInvalidationListeners.get(channel);
101
+ // skip: no live stream is watching this user right now
102
+ if (!listeners) return;
103
+ // Snapshot before iterating — a fired listener unsubscribes itself,
104
+ // which would mutate `listeners` mid-iteration otherwise.
105
+ for (const onInvalidate of [...listeners.values()]) {
106
+ onInvalidate();
107
+ }
108
+ },
70
109
  };
71
110
  }
@@ -11,6 +11,7 @@ export type {
11
11
  export { bunDbConnectionOptionsFromEnv, createBunDbConnection } from "./connection";
12
12
  export type { SelectOptions, TableInfo, WhereObject, WhereOperator, WhereValue } from "./query";
13
13
  export {
14
+ asEntityTableMeta,
14
15
  asRawClient,
15
16
  countWhere,
16
17
  type DeleteManyBatchedOptions,