@cosmicdrift/kumiko-bundled-features 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 (152) hide show
  1. package/package.json +10 -6
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
  3. package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
  4. package/src/audit/__tests__/audit.integration.test.ts +50 -21
  5. package/src/audit/handlers/list.query.ts +1 -0
  6. package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
  7. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
  8. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
  9. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
  10. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
  11. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
  12. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
  13. package/src/auth-email-password/handlers/login.write.ts +8 -0
  14. package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
  15. package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
  16. package/src/auth-email-password/i18n.ts +2 -0
  17. package/src/auth-email-password/seeding.ts +41 -23
  18. package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
  19. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
  20. package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
  21. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
  22. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
  23. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
  24. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
  25. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
  26. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
  27. package/src/auth-email-password/web/auth-client.ts +32 -10
  28. package/src/auth-email-password/web/auth-gate.tsx +17 -21
  29. package/src/auth-email-password/web/client-plugin.ts +15 -2
  30. package/src/auth-email-password/web/index.ts +5 -1
  31. package/src/auth-email-password/web/login-screen.tsx +7 -3
  32. package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
  33. package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
  34. package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
  35. package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
  36. package/src/auth-foundation/boot-checks.ts +74 -69
  37. package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
  38. package/src/auth-foundation/types.ts +3 -3
  39. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
  40. package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
  41. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +79 -1
  42. package/src/auth-mfa/handlers/disable.write.ts +16 -1
  43. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
  44. package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
  45. package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
  46. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
  47. package/src/auth-mfa/handlers/verify.write.ts +28 -24
  48. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
  49. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
  50. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
  51. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
  52. package/src/auth-mfa/web/i18n.ts +4 -0
  53. package/src/auth-mfa/web/mfa-client.ts +13 -2
  54. package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
  55. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
  56. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
  57. package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
  58. package/src/config/__tests__/cascade.integration.test.ts +13 -2
  59. package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
  60. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
  61. package/src/config/db/queries/resolver.ts +3 -3
  62. package/src/config/handlers/reencrypt.job.ts +36 -24
  63. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
  64. package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
  65. package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
  66. package/src/document-ingest-foundation/entity.ts +10 -3
  67. package/src/document-ingest-foundation/feature.ts +26 -8
  68. package/src/document-ingest-foundation/index.ts +1 -0
  69. package/src/document-ingest-foundation/pages.ts +34 -0
  70. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
  71. package/src/feature-toggles/compose-tier-resolver.ts +77 -8
  72. package/src/feature-toggles/global-feature-state-table.ts +15 -12
  73. package/src/files-provider-s3/s3-provider.ts +12 -1
  74. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
  75. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
  76. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
  77. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
  78. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
  79. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
  80. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
  81. package/src/jobs/__tests__/job-instant.test.ts +15 -0
  82. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
  83. package/src/jobs/feature.ts +6 -5
  84. package/src/jobs/job-instant.ts +9 -0
  85. package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
  86. package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
  87. package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
  88. package/src/page-render/__tests__/markdown.test.ts +29 -0
  89. package/src/page-render/markdown.ts +1 -9
  90. package/src/personal-access-tokens/feature.ts +1 -1
  91. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
  92. package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
  93. package/src/sessions/__tests__/sessions.integration.test.ts +72 -1
  94. package/src/sessions/feature.ts +12 -0
  95. package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
  96. package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
  97. package/src/sessions/handlers/revoke.write.ts +13 -0
  98. package/src/sessions/index.ts +7 -0
  99. package/src/sessions/session-callbacks.ts +58 -5
  100. package/src/sessions/session-revoked-event.ts +29 -0
  101. package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
  102. package/src/shared/chunked-entity-migration.ts +2 -2
  103. package/src/shared/grace-period.test.ts +26 -0
  104. package/src/shared/grace-period.ts +20 -0
  105. package/src/shared/index.ts +2 -0
  106. package/src/shared/map-with-concurrency.test.ts +104 -0
  107. package/src/shared/map-with-concurrency.ts +38 -0
  108. package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
  109. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
  110. package/src/subscription-mollie/verify-webhook.ts +1 -0
  111. package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
  112. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
  113. package/src/subscription-stripe/verify-webhook.ts +3 -2
  114. package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
  115. package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
  116. package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
  117. package/src/tenant/handlers/invitations.query.ts +13 -15
  118. package/src/tenant/handlers/members.query.ts +12 -12
  119. package/src/tenant/seeding.ts +110 -16
  120. package/src/tenant-lifecycle/boot-checks.ts +11 -4
  121. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +4 -6
  122. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
  123. package/src/tenant-settings/config.ts +44 -0
  124. package/src/tenant-settings/constants.ts +16 -0
  125. package/src/tenant-settings/feature.ts +49 -0
  126. package/src/tenant-settings/index.ts +8 -0
  127. package/src/tenant-settings/tenant-defaults.ts +67 -0
  128. package/src/user/handlers/find-for-auth.query.ts +1 -1
  129. package/src/user/seeding.ts +8 -0
  130. package/src/user-data-rights/COMPLIANCE.md +1 -1
  131. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
  132. package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
  133. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
  134. package/src/user-data-rights/__tests__/download.integration.test.ts +12 -41
  135. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
  136. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
  137. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +120 -0
  138. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
  139. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
  140. package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
  141. package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
  142. package/src/user-data-rights/feature.ts +45 -45
  143. package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
  144. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
  145. package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
  146. package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
  147. package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
  148. package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
  149. package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
  150. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
  151. package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
  152. package/src/user-data-rights/run-forget-cleanup.ts +27 -7
@@ -14,7 +14,7 @@
14
14
  // decryptForRead), so it must peel that PII layer itself before the envelope
15
15
  // cipher ever sees the value, mirroring decryptForRead's PII-then-envelope
16
16
  // order — an envelope decrypt on a still-PII-wrapped string throws
17
- // "legacy single-key format" instead of rotating anything.
17
+ // "[envelope-cipher] stored value is not valid JSON" instead of rotating anything.
18
18
  //
19
19
  // Idempotent: re-running skips rows already on the current kekVersion.
20
20
  // Every write is a normal executor.update — after a full run even a
@@ -66,17 +66,20 @@ export type MfaReencryptJobResult = {
66
66
  readonly stoppedReason: ChunkedMigrationStopReason;
67
67
  };
68
68
 
69
- function needsReencrypt(value: string, targetVersion: number): boolean {
70
- // legacy single-key format (base64 — can never start with "{")
71
- if (!value.startsWith("{")) return true;
69
+ type EnvelopeClassification = "rotate" | "current" | "unrecognized";
70
+
71
+ // After PII peel (when configured), values must be current-cipher envelopes.
72
+ // Non-JSON / non-envelope is not a supported legacy decrypt path — fail the
73
+ // row instead of treating it as "needs rotate" (#1541, mirrors config #1513).
74
+ function classifyEnvelope(value: string, targetVersion: number): EnvelopeClassification {
75
+ let parsed: unknown;
72
76
  try {
73
- const parsed: unknown = JSON.parse(value);
74
- if (!isStoredEnvelope(parsed)) return true;
75
- return parsed.kekVersion !== targetVersion;
77
+ parsed = JSON.parse(value);
76
78
  } catch {
77
- // malformed JSON — let the decrypt attempt surface the real error
78
- return true;
79
+ return "unrecognized";
79
80
  }
81
+ if (!isStoredEnvelope(parsed)) return "unrecognized";
82
+ return parsed.kekVersion === targetVersion ? "current" : "rotate";
80
83
  }
81
84
 
82
85
  export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
@@ -152,7 +155,7 @@ export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<vo
152
155
  async function migrateRow(row: UserMfaRow): Promise<"migrated" | "skipped" | "failed"> {
153
156
  const tenantId = row.tenantId as TenantId; // @cast-boundary db-row
154
157
 
155
- // Peel the PII layer first (if active) so needsReencrypt/cipher.decrypt
158
+ // Peel the PII layer first (if active) so classifyEnvelope/cipher.decrypt
156
159
  // below only ever see envelope strings, same order as decryptForRead.
157
160
  let envelopeValues: { totpSecret: string; recoveryCodes: string } = row;
158
161
  if (piiKms) {
@@ -163,20 +166,26 @@ export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<vo
163
166
  envelopeValues = unwrapped as { totpSecret: string; recoveryCodes: string }; // @cast-boundary engine-payload
164
167
  }
165
168
 
166
- const totpNeedsRotation = needsReencrypt(envelopeValues.totpSecret, targetVersion);
167
- const recoveryNeedsRotation = needsReencrypt(envelopeValues.recoveryCodes, targetVersion);
168
- if (!totpNeedsRotation && !recoveryNeedsRotation) {
169
+ const totpClass = classifyEnvelope(envelopeValues.totpSecret, targetVersion);
170
+ const recoveryClass = classifyEnvelope(envelopeValues.recoveryCodes, targetVersion);
171
+ if (totpClass === "unrecognized" || recoveryClass === "unrecognized") {
172
+ ctx.log?.warn?.(
173
+ `[auth-mfa:reencrypt] row ${row.id} has a field that is not a current-cipher envelope, not re-encryptable`,
174
+ );
175
+ return "failed";
176
+ }
177
+ if (totpClass === "current" && recoveryClass === "current") {
169
178
  alreadyCurrent++;
170
179
  return "skipped";
171
180
  }
172
181
 
173
- // decrypt failure (missing legacy key, unknown kekVersion, tamper)
174
- // throws → counted as failed via onRowError; the row stays untouched.
182
+ // decrypt failure (unknown kekVersion, tamper) throws → counted as
183
+ // failed via onRowError; the row stays untouched.
175
184
  const changes: Record<string, string> = {};
176
- if (totpNeedsRotation) {
185
+ if (totpClass === "rotate") {
177
186
  changes["totpSecret"] = await cipher.decrypt(envelopeValues.totpSecret, { tenantId });
178
187
  }
179
- if (recoveryNeedsRotation) {
188
+ if (recoveryClass === "rotate") {
180
189
  changes["recoveryCodes"] = await cipher.decrypt(envelopeValues.recoveryCodes, { tenantId });
181
190
  }
182
191
 
@@ -1,5 +1,6 @@
1
1
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
4
  import { z } from "zod";
4
5
  import { findUserMfaRow } from "../db/queries";
5
6
  import { invalidTotpCode, mfaNotEnabled } from "../errors";
@@ -34,7 +35,20 @@ export function createRegenerateRecoveryHandler(opts: RegenerateRecoveryOptions)
34
35
  // (deliberately) one of the very codes about to be replaced both
35
36
  // count, since either way the presented code is consumed/irrelevant
36
37
  // afterward.
37
- const replay = ctx.redis ? { redis: ctx.redis, userId: event.user.id } : undefined;
38
+ //
39
+ // Fail closed, matching verify.write.ts/enable-confirm-preauth.write.ts
40
+ // (#1467): without ctx.redis, verifyMfaFactor's TOTP-replay burn is
41
+ // skipped, letting an observed code be replayed within the ±1-step
42
+ // window to regenerate (and thus invalidate) recovery codes — a
43
+ // session-authed route, so pre-unwiring JWTs stay a valid attack path.
44
+ if (!ctx.redis) {
45
+ return writeFailure(
46
+ new InternalError({
47
+ message: "regenerate-recovery (mfa) requires ctx.redis for TOTP replay protection",
48
+ }),
49
+ );
50
+ }
51
+ const replay = { redis: ctx.redis, userId: event.user.id };
38
52
  const verify = await verifyMfaFactor(row, event.payload.code, replay);
39
53
  if (!verify.ok) return invalidTotpCode();
40
54
 
@@ -5,6 +5,7 @@ import {
5
5
  defineWriteHandler,
6
6
  type SessionUser,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
8
9
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
9
10
  import { z } from "zod";
10
11
  import { burnToken } from "../../shared";
@@ -53,17 +54,31 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
53
54
  if (!verified.ok) return invalidChallengeToken();
54
55
  const { userId, tenantId } = verified.payload;
55
56
 
57
+ // Fail closed, matching enable-confirm-preauth.write.ts (#1467):
58
+ // this route sits behind a challenge token from a successful login,
59
+ // but its own secret is still just a short TOTP/recovery code —
60
+ // silently skipping the brute-force cap and single-use burn when
61
+ // ctx.redis is absent would let a stolen challenge token be guessed
62
+ // against unboundedly, and let a recovery code be replayed via a
63
+ // reissued challenge (mfa-verify-attempts.ts's counter is deliberately
64
+ // NOT reset by reissuance, precisely to survive this).
65
+ if (!ctx.redis) {
66
+ return writeFailure(
67
+ new InternalError({
68
+ message: "verify (mfa) requires ctx.redis for the brute-force cap and single-use burn",
69
+ }),
70
+ );
71
+ }
72
+
56
73
  // Per-account brute-force cap — survives challenge-token reissuance
57
74
  // on purpose (see mfa-verify-attempts.ts header). Checked BEFORE the
58
75
  // TOTP/recovery verify so a locked account can't be guessed against.
59
- if (ctx.redis) {
60
- const state = await getMfaVerifyLockoutState(ctx.redis, userId);
61
- if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
62
- const now = Date.now();
63
- if (state.lockedUntil > now) {
64
- const retryAfterSeconds = Math.max(1, Math.ceil((state.lockedUntil - now) / 1000));
65
- return tooManyAttempts(retryAfterSeconds);
66
- }
76
+ const state = await getMfaVerifyLockoutState(ctx.redis, userId);
77
+ if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
78
+ const now = Date.now();
79
+ if (state.lockedUntil > now) {
80
+ const retryAfterSeconds = Math.max(1, Math.ceil((state.lockedUntil - now) / 1000));
81
+ return tooManyAttempts(retryAfterSeconds);
67
82
  }
68
83
  }
69
84
 
@@ -78,12 +93,10 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
78
93
  // bad code, no detail leak about account state.
79
94
  if (!row) return invalidChallengeToken();
80
95
 
81
- const replay = ctx.redis ? { redis: ctx.redis, userId } : undefined;
96
+ const replay = { redis: ctx.redis, userId };
82
97
  const verify = await verifyMfaFactor(row, event.payload.code, replay);
83
98
  if (!verify.ok) {
84
- if (ctx.redis) {
85
- await recordFailedMfaVerifyAttempt(ctx.redis, userId, maxAttempts, lockoutMinutes);
86
- }
99
+ await recordFailedMfaVerifyAttempt(ctx.redis, userId, maxAttempts, lockoutMinutes);
87
100
  return invalidTotpCode();
88
101
  }
89
102
 
@@ -95,15 +108,8 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
95
108
  // is reached, so this burn only needs to guard a DIFFERENT code
96
109
  // (recovery, or a still-valid-but-not-yet-burned TOTP step) reused
97
110
  // against the same challenge token.
98
- if (ctx.redis) {
99
- const burnResult = await burnToken(
100
- ctx.redis,
101
- "mfa-challenge",
102
- userId,
103
- verified.expiresAtMs,
104
- );
105
- if (burnResult === "already-used") return invalidChallengeToken();
106
- }
111
+ const burnResult = await burnToken(ctx.redis, "mfa-challenge", userId, verified.expiresAtMs);
112
+ if (burnResult === "already-used") return invalidChallengeToken();
107
113
 
108
114
  // Recovery-code use consumes the code — persist the reduced hash-list
109
115
  // immediately so it can't be replayed. Without this a recovery code
@@ -121,9 +127,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
121
127
  if (!updateResult.isSuccess) return updateResult;
122
128
  }
123
129
 
124
- if (ctx.redis) {
125
- await clearMfaVerifyAttempts(ctx.redis, userId);
126
- }
130
+ await clearMfaVerifyAttempts(ctx.redis, userId);
127
131
 
128
132
  // Re-derive the full session the way login.write.ts would have, for
129
133
  // the SAME tenant the challenge token already committed to (no
@@ -1,12 +1,16 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
  import { confirmMfaSetupPreauth, startMfaSetupPreauth, verifyMfaChallenge } from "../mfa-client";
3
3
 
4
+ const originalFetch = globalThis.fetch;
5
+
4
6
  beforeEach(() => {
5
7
  globalThis.fetch = mock(
6
8
  async () => new Response(null, { status: 200 }),
7
9
  ) as unknown as typeof fetch;
8
10
  });
9
- afterEach(() => {});
11
+ afterEach(() => {
12
+ globalThis.fetch = originalFetch;
13
+ });
10
14
 
11
15
  function jsonResponse(body: unknown, status = 200): Response {
12
16
  return new Response(JSON.stringify(body), { status });
@@ -1,4 +1,4 @@
1
- import { beforeEach, describe, expect, mock, test } from "bun:test";
1
+ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
  import {
3
3
  createStaticLocaleResolver,
4
4
  LocaleProvider,
@@ -34,6 +34,15 @@ mock.module("qrcode/lib/browser", () => ({
34
34
  default: { toString: mock(async () => "<svg></svg>") },
35
35
  }));
36
36
 
37
+ // bun's mock.module is process-global and outlives this file — without
38
+ // restoring it, any later suite in the same test run that needs the real
39
+ // useDispatcher from @cosmicdrift/kumiko-renderer silently gets this mock
40
+ // instead (green-by-accident or a confusing foreign-suite failure,
41
+ // depending on test order).
42
+ afterAll(() => {
43
+ mock.module("@cosmicdrift/kumiko-renderer", () => actual_renderer);
44
+ });
45
+
37
46
  const { MfaEnableScreen } = await import("../mfa-enable-screen");
38
47
 
39
48
  const session = makeSessionApi();
@@ -242,7 +242,11 @@ describe("MfaSetupPreauthScreen", () => {
242
242
  expect(confirm.disabled).toBe(false);
243
243
  });
244
244
 
245
- test("confirm schlägt mit invalid_setup_token fehl → übersetztes Banner (Neustart-Pfad)", async () => {
245
+ // #1470: the banner told the user to "start over", but setup stayed
246
+ // non-null — the intro block (with the only "start setup" button) never
247
+ // came back, leaving the user stuck without a reload (which loses the
248
+ // preauthSetupToken entirely).
249
+ test("confirm schlägt mit invalid_setup_token fehl → Neustart-Pfad: Start-Button kommt zurück (#1470)", async () => {
246
250
  globalThis.fetch = mock(async (input: RequestInfo | URL) => {
247
251
  const url = String(input);
248
252
  if (url.includes("preauth-enable-start")) return jsonResponse(startBody);
@@ -272,5 +276,104 @@ describe("MfaSetupPreauthScreen", () => {
272
276
  screen.getByText("Die Einrichtung ist abgelaufen. Bitte erneut starten."),
273
277
  ).toBeTruthy();
274
278
  });
279
+ // The actual fix: intro re-appears with a working start button, the QR/
280
+ // recovery-codes section from the dead setup is gone.
281
+ expect(screen.getByRole("button", { name: "Einrichtung starten" })).toBeTruthy();
282
+ expect(screen.queryByText("ABCD1234")).toBeNull();
283
+ });
284
+
285
+ test("confirm schlägt mit invalid_challenge_token fehl → auch Neustart-Pfad (#1470)", async () => {
286
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
287
+ const url = String(input);
288
+ if (url.includes("preauth-enable-start")) return jsonResponse(startBody);
289
+ if (url.includes("preauth-confirm")) {
290
+ return jsonResponse({ isSuccess: false, error: "invalid_challenge_token" });
291
+ }
292
+ return jsonResponse({ isSuccess: false, error: "unexpected_url" }, 500);
293
+ }) as unknown as typeof fetch;
294
+
295
+ render(
296
+ <Wrapper>
297
+ <MfaSetupPreauthScreen preauthSetupToken="preauth-1" accountLabel="user@example.com" />
298
+ </Wrapper>,
299
+ );
300
+ fireEvent.click(screen.getByRole("button", { name: "Einrichtung starten" }));
301
+ await waitFor(() => {
302
+ expect(screen.getByText("ABCD1234")).toBeTruthy();
303
+ });
304
+ fireEvent.click(screen.getByLabelText("Ich habe die Recovery-Codes gespeichert."));
305
+ fireEvent.change(screen.getByLabelText(/^Code aus der Authenticator-App/), {
306
+ target: { value: "123456" },
307
+ });
308
+ fireEvent.click(screen.getByRole("button", { name: "Einrichtung abschließen" }));
309
+
310
+ await waitFor(() => {
311
+ expect(screen.getByRole("button", { name: "Einrichtung starten" })).toBeTruthy();
312
+ });
313
+ });
314
+
315
+ // kumiko-framework#1470: confirmMfaSetupPreauth already extracts
316
+ // retryAfterSeconds from error.details (see mfa-client.test.ts), but the
317
+ // screen used to drop it and show a generic "too many attempts" banner
318
+ // with no wait-time. Pins that the seconds actually reach the UI.
319
+ test("confirm schlägt mit too_many_attempts fehl → Banner zeigt retryAfterSeconds (#1470)", async () => {
320
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
321
+ const url = String(input);
322
+ if (url.includes("preauth-enable-start")) return jsonResponse(startBody);
323
+ if (url.includes("preauth-confirm")) {
324
+ return jsonResponse({
325
+ isSuccess: false,
326
+ error: { code: "too_many_attempts", details: { retryAfterSeconds: 42 } },
327
+ });
328
+ }
329
+ return jsonResponse({ isSuccess: false, error: "unexpected_url" }, 500);
330
+ }) as unknown as typeof fetch;
331
+
332
+ render(
333
+ <Wrapper>
334
+ <MfaSetupPreauthScreen preauthSetupToken="preauth-1" accountLabel="user@example.com" />
335
+ </Wrapper>,
336
+ );
337
+ fireEvent.click(screen.getByRole("button", { name: "Einrichtung starten" }));
338
+ await waitFor(() => {
339
+ expect(screen.getByText("ABCD1234")).toBeTruthy();
340
+ });
341
+ fireEvent.click(screen.getByLabelText("Ich habe die Recovery-Codes gespeichert."));
342
+ fireEvent.change(screen.getByLabelText(/^Code aus der Authenticator-App/), {
343
+ target: { value: "123456" },
344
+ });
345
+ fireEvent.click(screen.getByRole("button", { name: "Einrichtung abschließen" }));
346
+
347
+ await waitFor(() => {
348
+ expect(
349
+ screen.getByText(
350
+ "Zu viele Fehlversuche. Bitte in 42 Sekunden erneut versuchen oder neu einloggen.",
351
+ ),
352
+ ).toBeTruthy();
353
+ });
354
+ });
355
+
356
+ // kumiko-framework#1470: server validates z.string().length(6) — a 7th
357
+ // pasted digit must not make the button look submittable, it would only
358
+ // 400 as invalid_body.
359
+ test("7-stelliger Code hält den Bestätigen-Button disabled", async () => {
360
+ render(
361
+ <Wrapper>
362
+ <MfaSetupPreauthScreen preauthSetupToken="preauth-1" accountLabel="user@example.com" />
363
+ </Wrapper>,
364
+ );
365
+ fireEvent.click(screen.getByRole("button", { name: "Einrichtung starten" }));
366
+ await waitFor(() => {
367
+ expect(screen.getByText("ABCD1234")).toBeTruthy();
368
+ });
369
+ fireEvent.click(screen.getByLabelText("Ich habe die Recovery-Codes gespeichert."));
370
+ fireEvent.change(screen.getByLabelText(/^Code aus der Authenticator-App/), {
371
+ target: { value: "1234567" },
372
+ });
373
+
374
+ const confirm = screen.getByRole("button", {
375
+ name: "Einrichtung abschließen",
376
+ }) as HTMLButtonElement;
377
+ expect(confirm.disabled).toBe(true);
275
378
  });
276
379
  });
@@ -25,12 +25,16 @@ function renderScreen(ui: ReactElement, session = makeSessionApi()): RenderResul
25
25
  );
26
26
  }
27
27
 
28
+ const originalFetch = globalThis.fetch;
29
+
28
30
  beforeEach(() => {
29
31
  globalThis.fetch = mock(
30
32
  async () => new Response(null, { status: 200 }),
31
33
  ) as unknown as typeof fetch;
32
34
  });
33
- afterEach(() => {});
35
+ afterEach(() => {
36
+ globalThis.fetch = originalFetch;
37
+ });
34
38
 
35
39
  describe("MfaVerifyScreen", () => {
36
40
  test("rendert Titel + Code-Feld + Submit-Button (de)", () => {
@@ -20,6 +20,8 @@ export const defaultTranslations: TranslationsByLocale = {
20
20
  "Die Anmeldung ist abgelaufen. Bitte erneut einloggen.",
21
21
  "auth.mfa.errors.challengeExpired": "Die Anmeldung ist abgelaufen. Bitte erneut einloggen.",
22
22
  "auth.mfa.errors.tooManyAttempts": "Zu viele Fehlversuche. Bitte erneut einloggen.",
23
+ "auth.mfa.errors.tooManyAttemptsWithSeconds":
24
+ "Zu viele Fehlversuche. Bitte in {seconds} Sekunden erneut versuchen oder neu einloggen.",
23
25
  "auth.mfa.errors.verifyFailed": "Bestätigung fehlgeschlagen.",
24
26
  "auth.mfa.errors.mfaAlreadyEnabled": "Zwei-Faktor-Authentifizierung ist bereits aktiv.",
25
27
  "auth.mfa.errors.mfaNotEnabled": "Zwei-Faktor-Authentifizierung ist nicht aktiv.",
@@ -80,6 +82,8 @@ export const defaultTranslations: TranslationsByLocale = {
80
82
  "auth.mfa.errors.invalidChallengeToken": "Your sign-in has expired. Please sign in again.",
81
83
  "auth.mfa.errors.challengeExpired": "Your sign-in has expired. Please sign in again.",
82
84
  "auth.mfa.errors.tooManyAttempts": "Too many failed attempts. Please sign in again.",
85
+ "auth.mfa.errors.tooManyAttemptsWithSeconds":
86
+ "Too many failed attempts. Try again in {seconds} seconds, or sign in again.",
83
87
  "auth.mfa.errors.verifyFailed": "Verification failed.",
84
88
  "auth.mfa.errors.mfaAlreadyEnabled": "Two-factor authentication is already enabled.",
85
89
  "auth.mfa.errors.mfaNotEnabled": "Two-factor authentication is not enabled.",
@@ -93,7 +93,13 @@ export async function startMfaSetupPreauth(
93
93
  setupToken?: string;
94
94
  otpauthUri?: string;
95
95
  recoveryCodes?: readonly string[];
96
- error?: { code?: string; message?: string; details?: { reason?: string } } | string;
96
+ error?:
97
+ | {
98
+ code?: string;
99
+ message?: string;
100
+ details?: { reason?: string; retryAfterSeconds?: number };
101
+ }
102
+ | string;
97
103
  };
98
104
  if (
99
105
  body.isSuccess === true &&
@@ -115,9 +121,14 @@ export async function startMfaSetupPreauth(
115
121
  return { kind: "failure", error: { reason: err } };
116
122
  }
117
123
  const reason = err?.details?.reason ?? err?.code ?? "setup_failed";
124
+ const retry = err?.details?.retryAfterSeconds;
118
125
  return {
119
126
  kind: "failure",
120
- error: { reason, ...(err?.message !== undefined && { message: err.message }) },
127
+ error: {
128
+ reason,
129
+ ...(err?.message !== undefined && { message: err.message }),
130
+ ...(retry !== undefined && { retryAfterSeconds: retry }),
131
+ },
121
132
  };
122
133
  }
123
134
 
@@ -161,7 +161,7 @@ export function MfaEnableScreen({
161
161
  variant="primary"
162
162
  onClick={() => void confirmSetup()}
163
163
  loading={busy}
164
- disabled={busy || !acknowledged || code.length < 6}
164
+ disabled={busy || !acknowledged || code.length !== 6}
165
165
  >
166
166
  {t("auth.mfa.enable.confirm")}
167
167
  </Button>
@@ -85,14 +85,17 @@ export function MfaSetupPreauthScreen({
85
85
  const [code, setCode] = useState("");
86
86
  const [busy, setBusy] = useState(false);
87
87
  const [error, setError] = useState<string | null>(null);
88
+ const [retryAfterSeconds, setRetryAfterSeconds] = useState<number | null>(null);
88
89
 
89
90
  const startSetup = async (): Promise<void> => {
90
91
  setBusy(true);
91
92
  setError(null);
93
+ setRetryAfterSeconds(null);
92
94
  try {
93
95
  const res = await startMfaSetupPreauth(preauthSetupToken, accountLabel);
94
96
  if (res.kind !== "success") {
95
97
  setError(res.error.reason);
98
+ setRetryAfterSeconds(res.error.retryAfterSeconds ?? null);
96
99
  return;
97
100
  }
98
101
  // errorCorrectionLevel "H" (~30% redundancy) — more resilient to
@@ -121,10 +124,24 @@ export function MfaSetupPreauthScreen({
121
124
  if (!setup) return;
122
125
  setBusy(true);
123
126
  setError(null);
127
+ setRetryAfterSeconds(null);
124
128
  try {
125
129
  const res = await confirmMfaSetupPreauth(setup.setupToken, code);
126
130
  if (res.kind !== "success") {
127
131
  setError(res.error.reason);
132
+ setRetryAfterSeconds(res.error.retryAfterSeconds ?? null);
133
+ // An expired or already-burned setupToken can never succeed again —
134
+ // drop back to the intro so the "start setup" button reappears,
135
+ // otherwise the user is stuck on this screen with no way forward
136
+ // short of a full reload (which loses the preauthSetupToken).
137
+ if (
138
+ res.error.reason === "invalid_setup_token" ||
139
+ res.error.reason === "invalid_challenge_token"
140
+ ) {
141
+ setSetup(null);
142
+ setAcknowledged(false);
143
+ setCode("");
144
+ }
128
145
  return;
129
146
  }
130
147
  onSuccess?.();
@@ -146,7 +163,13 @@ export function MfaSetupPreauthScreen({
146
163
  subtitle={subtitle ?? t("auth.mfa.setup.subtitle")}
147
164
  >
148
165
  <div className="p-6 pt-0 flex flex-col gap-4">
149
- {error !== null ? <Banner variant="error">{t(reasonToKey(error))}</Banner> : null}
166
+ {error !== null ? (
167
+ <Banner variant="error">
168
+ {error === "too_many_attempts" && retryAfterSeconds !== null
169
+ ? t("auth.mfa.errors.tooManyAttemptsWithSeconds", { seconds: retryAfterSeconds })
170
+ : t(reasonToKey(error))}
171
+ </Banner>
172
+ ) : null}
150
173
 
151
174
  {!setup && (
152
175
  <Section
@@ -175,7 +198,7 @@ export function MfaSetupPreauthScreen({
175
198
  type="submit"
176
199
  variant="primary"
177
200
  loading={busy}
178
- disabled={busy || !acknowledged || code.length < 6}
201
+ disabled={busy || !acknowledged || code.length !== 6}
179
202
  >
180
203
  {t("auth.mfa.setup.confirm")}
181
204
  </Button>
@@ -25,9 +25,19 @@ export type SubscriptionProviderContractFixture = {
25
25
  };
26
26
  };
27
27
 
28
+ export type SubscriptionProviderContractOptions = {
29
+ // Providers without portal/cancel support (e.g. Mollie) must say so
30
+ // explicitly — a bare `if (!plugin.x) return` inside the test body makes
31
+ // the skip invisible in test output (a coverage illusion: Stripe's
32
+ // portal/cancel tests and Mollie's "skip" both show as green) (#1339).
33
+ readonly hasPortal: boolean;
34
+ readonly hasCancel: boolean;
35
+ };
36
+
28
37
  export function describeSubscriptionProviderContract(
29
38
  name: string,
30
39
  factory: () => SubscriptionProviderContractFixture | Promise<SubscriptionProviderContractFixture>,
40
+ options: SubscriptionProviderContractOptions,
31
41
  ): void {
32
42
  describe(`${name} — SubscriptionProviderPlugin contract`, () => {
33
43
  test("implements verifyAndParseWebhook", async () => {
@@ -52,18 +62,32 @@ export function describeSubscriptionProviderContract(
52
62
  expect(result.url.length).toBeGreaterThan(0);
53
63
  });
54
64
 
55
- test("createPortalSession returns a hosted-page url — providers without a portal skip", async () => {
56
- const { plugin, ctx, portal } = await factory();
57
- if (!plugin.createPortalSession || !portal) return;
58
- const result = await plugin.createPortalSession(ctx, portal);
59
- expect(typeof result.url).toBe("string");
60
- expect(result.url.length).toBeGreaterThan(0);
61
- });
65
+ (options.hasPortal ? test : test.skip)(
66
+ "createPortalSession returns a hosted-page url",
67
+ async () => {
68
+ const { plugin, ctx, portal } = await factory();
69
+ if (!plugin.createPortalSession || !portal) {
70
+ throw new Error(
71
+ "describeSubscriptionProviderContract: hasPortal:true but the fixture has no createPortalSession/portal",
72
+ );
73
+ }
74
+ const result = await plugin.createPortalSession(ctx, portal);
75
+ expect(typeof result.url).toBe("string");
76
+ expect(result.url.length).toBeGreaterThan(0);
77
+ },
78
+ );
62
79
 
63
- test("cancelSubscription resolves without throwing — providers without cancel-support skip", async () => {
64
- const { plugin, ctx, cancelSubscriptionId } = await factory();
65
- if (!plugin.cancelSubscription || !cancelSubscriptionId) return;
66
- await expect(plugin.cancelSubscription(ctx, cancelSubscriptionId)).resolves.toBeUndefined();
67
- });
80
+ (options.hasCancel ? test : test.skip)(
81
+ "cancelSubscription resolves without throwing",
82
+ async () => {
83
+ const { plugin, ctx, cancelSubscriptionId } = await factory();
84
+ if (!plugin.cancelSubscription || !cancelSubscriptionId) {
85
+ throw new Error(
86
+ "describeSubscriptionProviderContract: hasCancel:true but the fixture has no cancelSubscription/cancelSubscriptionId",
87
+ );
88
+ }
89
+ await expect(plugin.cancelSubscription(ctx, cancelSubscriptionId)).resolves.toBeUndefined();
90
+ },
91
+ );
68
92
  });
69
93
  }
@@ -207,6 +207,10 @@ describe("config backing=secrets — fail-loud when secrets unwired", () => {
207
207
  test("cascade read on backing=secrets without secretsReader throws", async () => {
208
208
  // Write succeeds on the wired stack; then resolve via a bare resolver
209
209
  // (no secretsReader) so readBackingSecret fails loud on the cascade path.
210
+ // Direct resolver call is deliberate here, not a bypass: stack.http always
211
+ // uses a properly-wired resolver (secretsReader present) — there's no
212
+ // HTTP path that reaches an unwired resolver, so this is the only way to
213
+ // exercise the missing-secretsReader failure mode (kumiko-framework#1437).
210
214
  await stack.http.writeOk(
211
215
  ConfigHandlers.set,
212
216
  { key: API_KEY, value: "sk-live-planted", scope: "system" },
@@ -528,8 +528,19 @@ describe("config:query:cascade handler", () => {
528
528
  });
529
529
  });
530
530
 
531
- // getAll is a bulk reader — never reached via the cascade query handlers
532
- // above, so it stays uncovered unless called directly on the resolver.
531
+ // getAll is public ConfigResolver surface (packages/types/src/config.ts)
532
+ // for app authors that need every config key for a scope at once (bulk
533
+ // export/backup tooling, admin dashboards) — NOT wired to any handler in
534
+ // this repo. `resolver.getAll(...)` is the ONLY call site in the whole
535
+ // codebase (verified via grep, kumiko-framework#1529); there is no handler
536
+ // to route this through, so calling it directly isn't the
537
+ // handfed-context/createTestDispatcher anti-pattern the "echte HTTP-Calls"
538
+ // rule targets — that rule is about bypassing a REAL handler's dispatch
539
+ // path, and none exists here to bypass. This deliberately stays in the
540
+ // .integration.test.ts file rather than a standalone .test.ts because it
541
+ // shares the cascadeFeature/seed fixture above (duplicating that ~100-line
542
+ // setup just to rename the file would cost more than it buys — see
543
+ // kumiko-framework#1437).
533
544
  describe("getAll", () => {
534
545
  test("getAll picks the most specific row (user > tenant > system)", async () => {
535
546
  await stack.http.writeOk(
@@ -141,10 +141,10 @@ describe("piiEncrypted config keys", () => {
141
141
  });
142
142
 
143
143
  test("write without a configured KMS fails loud instead of silently storing plaintext", async () => {
144
- resetPiiSubjectKmsForTests();
145
- expect(configuredPiiSubjectKms()).toBeUndefined();
146
-
147
144
  try {
145
+ resetPiiSubjectKmsForTests();
146
+ expect(configuredPiiSubjectKms()).toBeUndefined();
147
+
148
148
  const res = await stack.http.write(
149
149
  ConfigHandlers.set,
150
150
  { key: BILLING_ADDRESS_KEY, value: "no-kms-value" },
@@ -166,10 +166,10 @@ describe("piiEncrypted config keys", () => {
166
166
  tenantAdmin,
167
167
  );
168
168
 
169
- resetPiiSubjectKmsForTests();
170
- expect(configuredPiiSubjectKms()).toBeUndefined();
171
-
172
169
  try {
170
+ resetPiiSubjectKmsForTests();
171
+ expect(configuredPiiSubjectKms()).toBeUndefined();
172
+
173
173
  const res = await stack.http.query(ConfigQueries.values, {}, tenantAdmin);
174
174
  expect(res.status).toBe(500);
175
175
  } finally {