@cosmicdrift/kumiko-bundled-features 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 (163) hide show
  1. package/package.json +7 -6
  2. package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
  3. package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
  4. package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
  5. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
  6. package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
  7. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
  8. package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
  9. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
  10. package/src/auth-email-password/constants.ts +15 -0
  11. package/src/auth-email-password/email-templates.ts +32 -0
  12. package/src/auth-email-password/errors.ts +9 -0
  13. package/src/auth-email-password/feature.ts +37 -5
  14. package/src/auth-email-password/handlers/change-password.write.ts +2 -1
  15. package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
  16. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
  17. package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
  18. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
  19. package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
  20. package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
  21. package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
  22. package/src/auth-email-password/i18n.ts +42 -0
  23. package/src/auth-email-password/index.ts +2 -0
  24. package/src/auth-email-password/lockout-store.ts +10 -5
  25. package/src/auth-email-password/password-policy.ts +2108 -0
  26. package/src/auth-email-password/signed-token.ts +1 -0
  27. package/src/auth-email-password/unlock-token.ts +28 -0
  28. package/src/auth-email-password/web/__tests__/auth-client.test.ts +525 -0
  29. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
  30. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +57 -0
  31. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +126 -2
  32. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +125 -0
  33. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +131 -0
  34. package/src/auth-email-password/web/auth-client.ts +33 -0
  35. package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
  36. package/src/auth-email-password/web/index.ts +6 -0
  37. package/src/auth-email-password/web/login-screen.tsx +15 -0
  38. package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
  39. package/src/auth-foundation/__tests__/feature.test.ts +33 -0
  40. package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
  41. package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
  42. package/src/auth-foundation/boot-checks.ts +91 -0
  43. package/src/auth-foundation/feature.ts +103 -0
  44. package/src/auth-foundation/index.ts +23 -0
  45. package/src/auth-foundation/types.ts +107 -0
  46. package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
  47. package/src/auth-mfa/__tests__/verify.integration.test.ts +44 -0
  48. package/src/auth-mfa/handlers/verify.write.ts +1 -1
  49. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
  50. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
  51. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
  52. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
  53. package/src/channel-email/pii-guard.ts +6 -3
  54. package/src/config/__tests__/app-overrides.test.ts +11 -0
  55. package/src/config/__tests__/backing-secrets.integration.test.ts +18 -0
  56. package/src/config/__tests__/cascade.integration.test.ts +39 -0
  57. package/src/config/__tests__/config.integration.test.ts +14 -0
  58. package/src/config/__tests__/pii-encrypted.integration.test.ts +23 -2
  59. package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
  60. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
  61. package/src/config/feature.ts +2 -4
  62. package/src/config/handlers/set.write.ts +7 -1
  63. package/src/config/resolver.ts +1 -1
  64. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
  65. package/src/delivery/handlers/log.query.ts +1 -1
  66. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
  67. package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
  68. package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
  69. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
  70. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
  71. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +154 -0
  72. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
  73. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
  74. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +331 -0
  75. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
  76. package/src/inbound-provider-inmemory/feature.ts +2 -2
  77. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
  78. package/src/jobs/handlers/detail.query.ts +1 -1
  79. package/src/jobs/handlers/list.query.ts +1 -1
  80. package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
  81. package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
  82. package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
  83. package/src/managed-pages/handlers/branding.query.ts +5 -1
  84. package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
  85. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
  86. package/src/personal-access-tokens/feature.ts +23 -8
  87. package/src/personal-access-tokens/handlers/list.query.ts +1 -1
  88. package/src/personal-access-tokens/index.ts +0 -14
  89. package/src/personal-access-tokens/resolver.ts +2 -2
  90. package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
  91. package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
  92. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
  93. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
  94. package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
  95. package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
  96. package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
  97. package/src/sessions/__tests__/test-helpers.ts +15 -2
  98. package/src/sessions/feature.ts +31 -3
  99. package/src/sessions/handlers/detail.query.ts +2 -2
  100. package/src/sessions/handlers/list.query.ts +4 -2
  101. package/src/sessions/handlers/mine.query.ts +4 -2
  102. package/src/sessions/testing.ts +14 -1
  103. package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
  104. package/src/shared/decrypt-stored-pii.ts +12 -5
  105. package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
  106. package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
  107. package/src/tags/web/__tests__/tag-manager.test.tsx +277 -8
  108. package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
  109. package/src/tenant/handlers/invitations.query.ts +4 -2
  110. package/src/tenant/handlers/members.query.ts +2 -2
  111. package/src/tenant/seeding.ts +2 -2
  112. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
  113. package/src/tenant-lifecycle/boot-checks.ts +53 -0
  114. package/src/tenant-lifecycle/feature.ts +7 -0
  115. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  116. package/src/tier-engine/feature.ts +2 -2
  117. package/src/user-data-rights/COMPLIANCE.md +20 -0
  118. package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
  119. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
  120. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
  121. package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
  122. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
  123. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
  124. package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
  125. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
  126. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
  127. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
  128. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
  129. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
  130. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
  131. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
  132. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
  133. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
  134. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
  135. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
  136. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
  137. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
  138. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
  139. package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
  140. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
  141. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
  142. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +184 -1
  143. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
  144. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
  145. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
  146. package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
  147. package/src/user-data-rights/boot-checks.ts +86 -0
  148. package/src/user-data-rights/feature.ts +116 -6
  149. package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
  150. package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  151. package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
  152. package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
  153. package/src/user-data-rights/run-export-jobs.ts +3 -3
  154. package/src/user-data-rights/run-forget-cleanup.ts +1 -1
  155. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
  156. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
  157. package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
  158. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
  159. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
  160. package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
  161. package/src/user-profile/__tests__/profile-screen.test.tsx +1 -0
  162. package/src/user-profile/handlers/change-email.write.ts +1 -1
  163. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.158.2",
3
+ "version": "0.160.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -48,6 +48,7 @@
48
48
  "./subscription-stripe": "./src/subscription-stripe/index.ts",
49
49
  "./subscription-mollie": "./src/subscription-mollie/index.ts",
50
50
  "./foundation-shared": "./src/foundation-shared/index.ts",
51
+ "./auth-foundation": "./src/auth-foundation/index.ts",
51
52
  "./mail-foundation": "./src/mail-foundation/index.ts",
52
53
  "./inbound-mail-foundation": "./src/inbound-mail-foundation/index.ts",
53
54
  "./inbound-provider-inmemory": "./src/inbound-provider-inmemory/index.ts",
@@ -116,11 +117,11 @@
116
117
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
117
118
  },
118
119
  "dependencies": {
119
- "@cosmicdrift/kumiko-dispatcher-live": "0.158.2",
120
- "@cosmicdrift/kumiko-framework": "0.158.2",
121
- "@cosmicdrift/kumiko-headless": "0.158.2",
122
- "@cosmicdrift/kumiko-renderer": "0.158.2",
123
- "@cosmicdrift/kumiko-renderer-web": "0.158.2",
120
+ "@cosmicdrift/kumiko-dispatcher-live": "0.160.0",
121
+ "@cosmicdrift/kumiko-framework": "0.160.0",
122
+ "@cosmicdrift/kumiko-headless": "0.160.0",
123
+ "@cosmicdrift/kumiko-renderer": "0.160.0",
124
+ "@cosmicdrift/kumiko-renderer-web": "0.160.0",
124
125
  "@mollie/api-client": "^4.5.0",
125
126
  "imapflow": "^1.3.3",
126
127
  "mailparser": "^3.9.8",
@@ -0,0 +1,283 @@
1
+ // Account-unlock integration tests (#1266) — the self-service escape hatch
2
+ // for the account-lockout monotonic counter: after a lock expires, the next
3
+ // wrong password immediately re-locks (see account-lockout.integration.test.ts
4
+ // "expired lock + wrong password → immediate re-lock"). This flow gives a
5
+ // legitimate user a way out via mailbox-ownership proof instead of being
6
+ // stuck retrying.
7
+
8
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
+ import { randomBytes } from "node:crypto";
10
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
11
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
12
+ import {
13
+ setupTestStack,
14
+ type TestStack,
15
+ TestUsers,
16
+ unsafeCreateEntityTable,
17
+ unsafePushTables,
18
+ } from "@cosmicdrift/kumiko-framework/stack";
19
+ import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
20
+ import { Temporal } from "temporal-polyfill";
21
+ import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
22
+ import { createConfigFeature } from "../../config";
23
+ import { createConfigResolver } from "../../config/resolver";
24
+ import { configValuesTable } from "../../config/table";
25
+ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery";
26
+ import { notificationPreferencesTable } from "../../delivery/tables";
27
+ import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
28
+ import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
29
+ import { hashPassword } from "../../shared";
30
+ import { createTemplateResolverFeature } from "../../template-resolver/feature";
31
+ import { createTenantFeature } from "../../tenant";
32
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
33
+ import { tenantEntity } from "../../tenant/schema/tenant";
34
+ import { seedTenantMembership } from "../../tenant/testing";
35
+ import { UserHandlers } from "../../user";
36
+ import { createUserFeature } from "../../user/feature";
37
+ import { userEntity, userTable } from "../../user/schema/user";
38
+ import { AuthErrors, AuthHandlers } from "../constants";
39
+ import { createAuthEmailPasswordFeature } from "../feature";
40
+ import { getLockoutState, type LockoutState } from "../lockout-store";
41
+ import { signUnlockToken } from "../unlock-token";
42
+
43
+ const emailTransport = createInMemoryTransport();
44
+
45
+ let stack: TestStack;
46
+ const systemAdmin = TestUsers.systemAdmin;
47
+ const encryptionKey = randomBytes(32).toString("base64");
48
+ const unlockSecret = randomBytes(32).toString("base64");
49
+ const appUnlockUrl = "https://app.example.com/unlock-account";
50
+
51
+ const MAX_ATTEMPTS = 3;
52
+ const LOCK_MINUTES = 1;
53
+
54
+ beforeAll(async () => {
55
+ const encryption = createTestEnvelopeCipher(encryptionKey);
56
+ const resolver = createConfigResolver({ cipher: encryption });
57
+
58
+ stack = await setupTestStack({
59
+ features: [
60
+ createConfigFeature(),
61
+ createUserFeature(),
62
+ createTenantFeature(),
63
+ createTemplateResolverFeature(),
64
+ createRendererFoundationFeature(),
65
+ createDeliveryFeature(),
66
+ createRendererSimpleFeature(),
67
+ createChannelEmailFeature({
68
+ transport: emailTransport,
69
+ renderer: simpleRenderer,
70
+ resolveEmail: async () => "unused@test.local",
71
+ }),
72
+ createAuthEmailPasswordFeature({
73
+ accountLockout: {
74
+ maxFailedAttempts: MAX_ATTEMPTS,
75
+ lockoutDurationMinutes: LOCK_MINUTES,
76
+ },
77
+ accountUnlock: { hmacSecret: unlockSecret, tokenTtlMinutes: 15, appUrl: appUnlockUrl },
78
+ }),
79
+ ],
80
+ extraContext: (deps) => ({
81
+ ...createDeliveryTestContext(deps),
82
+ configResolver: resolver,
83
+ configEncryption: encryption,
84
+ }),
85
+ authConfig: {
86
+ membershipQuery: "tenant:query:memberships",
87
+ loginHandler: AuthHandlers.login,
88
+ loginErrorStatusMap: {
89
+ [AuthErrors.invalidCredentials]: 401,
90
+ [AuthErrors.noMembership]: 403,
91
+ [AuthErrors.accountLocked]: 423,
92
+ },
93
+ accountUnlock: {
94
+ requestHandler: AuthHandlers.requestAccountUnlock,
95
+ confirmHandler: AuthHandlers.confirmAccountUnlock,
96
+ },
97
+ },
98
+ });
99
+
100
+ await unsafeCreateEntityTable(stack.db, userEntity);
101
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
102
+ await unsafePushTables(stack.db, {
103
+ configValuesTable,
104
+ tenantMembershipsTable,
105
+ notificationPreferencesTable,
106
+ });
107
+ });
108
+
109
+ afterAll(async () => {
110
+ await stack.cleanup();
111
+ });
112
+
113
+ beforeEach(async () => {
114
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
115
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
116
+ await stack.redis.flushNamespace();
117
+ emailTransport.sent.length = 0;
118
+ });
119
+
120
+ async function seedLoginUser(
121
+ email: string,
122
+ password: string,
123
+ ): Promise<{ id: string; tenantId: TenantId }> {
124
+ const hash = await hashPassword(password);
125
+ const created = await stack.http.writeOk<{ id: string }>(
126
+ UserHandlers.create,
127
+ { email, passwordHash: hash, displayName: email.split("@")[0] ?? "user" },
128
+ systemAdmin,
129
+ );
130
+ const tenantId: TenantId = "00000000-0000-4000-8000-000000000001" as TenantId;
131
+ await seedTenantMembership(stack.db, { userId: created.id, tenantId, roles: ["User"] });
132
+ return { id: created.id, tenantId };
133
+ }
134
+
135
+ async function loginAttempt(email: string, password: string): Promise<Response> {
136
+ return stack.http.raw("POST", "/api/auth/login", { email, password });
137
+ }
138
+
139
+ async function post(path: string, body: unknown): Promise<Response> {
140
+ return stack.http.raw("POST", path, body);
141
+ }
142
+
143
+ async function readLockoutState(userId: string): Promise<LockoutState | null> {
144
+ return getLockoutState(stack.redis.redis, userId);
145
+ }
146
+
147
+ async function lockAccount(email: string): Promise<void> {
148
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
149
+ await loginAttempt(email, `wrong-${i}`);
150
+ }
151
+ }
152
+
153
+ describe("POST /auth/request-account-unlock", () => {
154
+ test("known email → 200, delivery sends mail with unlock URL", async () => {
155
+ await seedLoginUser("alice@example.com", "initial-pw!");
156
+
157
+ const res = await post("/api/auth/request-account-unlock", { email: "alice@example.com" });
158
+
159
+ expect(res.status).toBe(200);
160
+ expect(await res.json()).toEqual({ isSuccess: true });
161
+ expect(emailTransport.sent).toHaveLength(1);
162
+ const sent = emailTransport.sent[0];
163
+ if (!sent) throw new Error("no email sent");
164
+ expect(sent.to).toBe("alice@example.com");
165
+ expect(sent.html).toContain(`${appUnlockUrl}?token=`);
166
+ });
167
+
168
+ test("unknown email → 200 with NO mail sent (enumeration-safe)", async () => {
169
+ const res = await post("/api/auth/request-account-unlock", { email: "ghost@example.com" });
170
+
171
+ expect(res.status).toBe(200);
172
+ expect(emailTransport.sent).toHaveLength(0);
173
+ });
174
+ });
175
+
176
+ describe("POST /auth/confirm-account-unlock", () => {
177
+ test("valid token on a locked account → clears failureCount AND lockedUntil", async () => {
178
+ const seed = await seedLoginUser("bob@example.com", "right-pw");
179
+ await lockAccount("bob@example.com");
180
+ expect((await readLockoutState(seed.id))?.lockedUntil).not.toBeNull();
181
+
182
+ const { token } = signUnlockToken(seed.id, 15, unlockSecret);
183
+ const res = await post("/api/auth/confirm-account-unlock", { token });
184
+
185
+ expect(res.status).toBe(200);
186
+ expect(await res.json()).toEqual({ isSuccess: true });
187
+ expect(await readLockoutState(seed.id)).toBeNull();
188
+ });
189
+
190
+ // This is the actual bug fix from #1266: the monotonic counter otherwise
191
+ // re-locks on the very next wrong password once the lock window expires.
192
+ // Confirming the unlock token must clear the COUNT key, not just the
193
+ // until key — otherwise the escape hatch doesn't actually escape anything.
194
+ test("after unlock, a subsequent wrong password does NOT immediately re-lock", async () => {
195
+ const seed = await seedLoginUser("carol@example.com", "right-pw");
196
+ await lockAccount("carol@example.com");
197
+
198
+ const { token } = signUnlockToken(seed.id, 15, unlockSecret);
199
+ await post("/api/auth/confirm-account-unlock", { token });
200
+
201
+ const res = await loginAttempt("carol@example.com", "still-wrong-once");
202
+ expect(res.status).toBe(401);
203
+ const body = await res.json();
204
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
205
+
206
+ const state = await readLockoutState(seed.id);
207
+ expect(state?.failureCount).toBe(1);
208
+ expect(state?.lockedUntil).toBeNull();
209
+ });
210
+
211
+ test("unlocked account can log in again with the correct password", async () => {
212
+ const seed = await seedLoginUser("dana@example.com", "right-pw");
213
+ await lockAccount("dana@example.com");
214
+
215
+ const { token } = signUnlockToken(seed.id, 15, unlockSecret);
216
+ await post("/api/auth/confirm-account-unlock", { token });
217
+
218
+ const res = await loginAttempt("dana@example.com", "right-pw");
219
+ expect(res.status).toBe(200);
220
+ });
221
+
222
+ test("confirming on a non-locked account is a harmless no-op", async () => {
223
+ const seed = await seedLoginUser("erin@example.com", "right-pw");
224
+
225
+ const { token } = signUnlockToken(seed.id, 15, unlockSecret);
226
+ const res = await post("/api/auth/confirm-account-unlock", { token });
227
+
228
+ expect(res.status).toBe(200);
229
+ expect(await readLockoutState(seed.id)).toBeNull();
230
+ });
231
+
232
+ test("replaying the same unlock token twice is harmless (no burn-store)", async () => {
233
+ const seed = await seedLoginUser("frank@example.com", "right-pw");
234
+ await lockAccount("frank@example.com");
235
+
236
+ const { token } = signUnlockToken(seed.id, 15, unlockSecret);
237
+ const res1 = await post("/api/auth/confirm-account-unlock", { token });
238
+ const res2 = await post("/api/auth/confirm-account-unlock", { token });
239
+
240
+ expect(res1.status).toBe(200);
241
+ expect(res2.status).toBe(200);
242
+ });
243
+
244
+ test("tampered token → 422 invalid_unlock_token; lockout state untouched", async () => {
245
+ const seed = await seedLoginUser("greta@example.com", "right-pw");
246
+ await lockAccount("greta@example.com");
247
+ const { token } = signUnlockToken(seed.id, 15, unlockSecret);
248
+ const tampered = `${token.slice(0, -3)}XXX`;
249
+
250
+ const res = await post("/api/auth/confirm-account-unlock", { token: tampered });
251
+
252
+ expect(res.status).toBe(422);
253
+ const body = await res.json();
254
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidUnlockToken);
255
+ expect((await readLockoutState(seed.id))?.lockedUntil).not.toBeNull();
256
+ });
257
+
258
+ test("token signed with a different secret → 422", async () => {
259
+ const seed = await seedLoginUser("henry@example.com", "right-pw");
260
+ const { token } = signUnlockToken(seed.id, 15, "wrong-secret-wrong-secret-wrong!!");
261
+
262
+ const res = await post("/api/auth/confirm-account-unlock", { token });
263
+
264
+ expect(res.status).toBe(422);
265
+ });
266
+
267
+ test("expired token → 422 invalid_unlock_token", async () => {
268
+ const seed = await seedLoginUser("iris@example.com", "right-pw");
269
+ const past = Temporal.Now.instant().subtract({ minutes: 30 });
270
+ const { token } = signUnlockToken(seed.id, 15, unlockSecret, past);
271
+
272
+ const res = await post("/api/auth/confirm-account-unlock", { token });
273
+
274
+ expect(res.status).toBe(422);
275
+ const body = await res.json();
276
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidUnlockToken);
277
+ });
278
+
279
+ test("malformed body → 400 invalid_body", async () => {
280
+ const res = await post("/api/auth/confirm-account-unlock", { wrong: "shape" });
281
+ expect(res.status).toBe(400);
282
+ });
283
+ });
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { parseAuthUserRow } from "../auth-user-row";
3
+
4
+ describe("parseAuthUserRow", () => {
5
+ test("returns null for null/undefined", () => {
6
+ expect(parseAuthUserRow(null)).toBeNull();
7
+ expect(parseAuthUserRow(undefined)).toBeNull();
8
+ });
9
+
10
+ test("returns null for a non-object value", () => {
11
+ expect(parseAuthUserRow("nope")).toBeNull();
12
+ expect(parseAuthUserRow(42)).toBeNull();
13
+ });
14
+
15
+ test("returns null when id is missing or not a string", () => {
16
+ expect(parseAuthUserRow({})).toBeNull();
17
+ expect(parseAuthUserRow({ id: 123 })).toBeNull();
18
+ });
19
+
20
+ test("returns the row as-is once id is a string", () => {
21
+ const row = {
22
+ id: "u1",
23
+ email: "a@example.com",
24
+ version: 3,
25
+ passwordHash: "hash",
26
+ status: "active",
27
+ };
28
+ expect(parseAuthUserRow(row)).toEqual(row);
29
+ });
30
+
31
+ test("passes through a row with only id set — other fields stay undefined", () => {
32
+ const result = parseAuthUserRow({ id: "u2" });
33
+ expect(result?.id).toBe("u2");
34
+ expect(result?.email).toBeUndefined();
35
+ });
36
+ });
@@ -236,6 +236,27 @@ describe("scenario 5: change-password success", () => {
236
236
  });
237
237
  });
238
238
 
239
+ describe("scenario 5c: change-password common-password rejection (#1340)", () => {
240
+ test("common newPassword → 400 (schema rejects breach-list password)", async () => {
241
+ const seed = await seedLoginUser({ email: "common-cp@example.com", password: "good-old-pw" });
242
+ const signedIn = createTestUser({ id: seed.id, tenantId: seed.tenantId, roles: ["User"] });
243
+
244
+ const error = await stack.http.writeErr(
245
+ AuthHandlers.changePassword,
246
+ { oldPassword: "good-old-pw", newPassword: "password1" },
247
+ signedIn,
248
+ );
249
+ expect(error.httpStatus).toBe(400);
250
+
251
+ // Old password still works — the rejected change never landed.
252
+ const loginRes = await stack.http.raw("POST", "/api/auth/login", {
253
+ email: "common-cp@example.com",
254
+ password: "good-old-pw",
255
+ });
256
+ expect(loginRes.status).toBe(200);
257
+ });
258
+ });
259
+
239
260
  describe("scenario 5b: change-password when the aggregate stream tenant != session tenant (sysadmin pattern)", () => {
240
261
  test("user whose stream lives in a non-session tenant still changes password", async () => {
241
262
  // Sysadmin pattern: created via systemAdmin (stream = systemAdmin.tenantId),
@@ -286,7 +286,7 @@ describe("POST /auth/verify-email", () => {
286
286
  // memberships + recoverable stream", never blanket-opens zero-membership.
287
287
  // Build a fully-populated user row with NO event stream: seed a normal
288
288
  // user (gets all NOT-NULL columns), capture its row, then re-key it to a
289
- // fresh id + email. getAggregateStreamTenant(orphanId) finds no events
289
+ // fresh id + email. getUnscopedAggregateStreamTenant(orphanId) finds no events
290
290
  // (the stream lives under the original id), and no membership is seeded
291
291
  // → tenantOrder is empty.
292
292
  const donor = await seedUser({ email: "donor@example.com", password: "donor-pw-1234" });
@@ -0,0 +1,15 @@
1
+ import { expect, test } from "bun:test";
2
+ import { isCommonPassword, passwordSchema } from "../password-policy";
3
+
4
+ test("rejects a breach-list password (case-insensitive)", () => {
5
+ expect(isCommonPassword("Password1")).toBe(true);
6
+ expect(passwordSchema.safeParse("password1").success).toBe(false);
7
+ });
8
+
9
+ test("accepts a long unique password", () => {
10
+ expect(passwordSchema.safeParse("aX9-not-in-any-list-42").success).toBe(true);
11
+ });
12
+
13
+ test("still enforces min length", () => {
14
+ expect(passwordSchema.safeParse("tiny").success).toBe(false);
15
+ });
@@ -1,5 +1,6 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
3
4
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
4
5
  import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
6
  import {
@@ -76,6 +77,7 @@ beforeAll(async () => {
76
77
  // "user.postSave" that triggers autoRevokeOnPasswordChange whenever
77
78
  // the passwordHash delta is present. Integration-test proves the
78
79
  // reset-flow's changes.passwordHash triggers the same hook.
80
+ authFoundationFeature,
79
81
  createSessionsFeature({
80
82
  autoRevokeOnPasswordChange: async (userId) => {
81
83
  autoRevokeCalls.push(userId);
@@ -252,6 +254,18 @@ describe("POST /auth/reset-password", () => {
252
254
  expect(res.status).toBe(400);
253
255
  });
254
256
 
257
+ test("common newPassword → 400 (schema rejects breach-list password)", async () => {
258
+ const seed = await seedUser({ email: "frank@example.com", password: "original" });
259
+ const { token } = signResetToken(seed.id, 15, resetSecret);
260
+
261
+ const res = await post("/api/auth/reset-password", {
262
+ token,
263
+ newPassword: "password1",
264
+ });
265
+
266
+ expect(res.status).toBe(400);
267
+ });
268
+
255
269
  test("expired token via the route → 422 invalid_reset_token", async () => {
256
270
  const seed = await seedUser({ email: "time@example.com", password: "once-valid-1234" });
257
271
  // Sign with now set far in the past so expiry already fired.
@@ -348,7 +362,7 @@ describe("POST /auth/reset-password", () => {
348
362
  // never blanket-opens zero-membership.
349
363
  // Build a fully-populated user row with NO event stream: seed a normal
350
364
  // user (gets all NOT-NULL columns), capture its row, then re-key it to a
351
- // fresh id + email. getAggregateStreamTenant(orphanId) finds no events
365
+ // fresh id + email. getUnscopedAggregateStreamTenant(orphanId) finds no events
352
366
  // (the stream lives under the original id), and no membership is seeded
353
367
  // → tenantOrder is empty.
354
368
  const donor = await seedUser({ email: "donor@example.com", password: "donor-pw-1234" });
@@ -1,8 +1,6 @@
1
- // Tests the `sessionStrictMode` flag on AuthRoutesConfig. When enabled, a
2
- // JWT that arrives WITHOUT a `jti` is rejected at the middleware — useful
3
- // after a rolling deploy has been emitting sids longer than the JWT TTL,
4
- // so legacy stateless tokens are expected to have expired. Default false
5
- // keeps pre-upgrade tokens working; this suite flips it on and asserts.
1
+ // Pins the default sessionChecker behavior: a JWT that arrives WITHOUT a
2
+ // `jti` is rejected at the middleware. There is no relaxed mode any app
3
+ // that wires sessionChecker gets this for free.
6
4
 
7
5
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
6
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
@@ -21,12 +19,8 @@ let stack: TestStack;
21
19
  const TENANT: TenantId = testTenantId(1);
22
20
  const userId = TestUsers.systemAdmin.id;
23
21
 
24
- // Stub checker that always accepts. The strictMode branch runs BEFORE the
25
- // checker is even consulted (no jti nothing to check), so the stub never
26
- // fires in the strict-mode path. It's present to satisfy the framework's
27
- // "you wired sessionChecker, so we'll run it if we have an sid" contract.
28
- // Accepts the full AuthSessionChecker signature (sid + expectedUserId)
29
- // even though it doesn't use the args.
22
+ // Stub checker that always accepts. Accepts the full AuthSessionChecker
23
+ // signature (sid + expectedUserId) even though it doesn't use the args.
30
24
  async function stubChecker(_sid: string, _expectedUserId: string): Promise<"live"> {
31
25
  return "live";
32
26
  }
@@ -42,7 +36,6 @@ beforeAll(async () => {
42
36
  authConfig: {
43
37
  membershipQuery: "tenant:query:memberships",
44
38
  sessionChecker: stubChecker,
45
- sessionStrictMode: true,
46
39
  },
47
40
  });
48
41
  });
@@ -51,7 +44,7 @@ afterAll(async () => {
51
44
  await stack.cleanup();
52
45
  });
53
46
 
54
- describe("sessionStrictMode: sidless JWTs are rejected", () => {
47
+ describe("sessionChecker wired: sidless JWTs are rejected", () => {
55
48
  test("JWT without jti → 401 with reason=no_sid", async () => {
56
49
  // Hand-signed JWT that carries id + tenantId + roles but NO jti. The
57
50
  // standard testing request-helper signs JWTs the same way on user
@@ -93,7 +86,7 @@ describe("sessionStrictMode: sidless JWTs are rejected", () => {
93
86
  expect(res.status).not.toBe(401);
94
87
  // Narrow the "not 401" to exclude other 4xx middleware errors too.
95
88
  // A 403 from access-layer or a 400 from shape-validation wouldn't
96
- // come from sessionStrictMode, but either would be a different code
89
+ // come from the sid gate, but either would be a different code
97
90
  // path than the one we're testing — flag them if they surface.
98
91
  expect(res.status).not.toBe(403);
99
92
  expect(res.status).not.toBe(400);
@@ -320,4 +320,16 @@ describe("POST /api/auth/signup-confirm", () => {
320
320
  const loginAttacker = await postLogin(email, attackerPassword);
321
321
  expect(loginAttacker.status).not.toBe(200);
322
322
  });
323
+
324
+ test("common password → 400 (schema rejects breach-list password) (#1340)", async () => {
325
+ const email = "common-signup@example.com";
326
+ const token = await requestSignup(email);
327
+
328
+ const confirmRes = await postSignupConfirm(token, "password1");
329
+ expect(confirmRes.status).toBe(400);
330
+
331
+ // No user created — the rejected confirm never landed.
332
+ const userRows = await selectMany(stack.db, userTable, { email });
333
+ expect(userRows).toHaveLength(0);
334
+ });
323
335
  });
@@ -19,6 +19,12 @@ export const AuthHandlers = {
19
19
  resetPassword: "auth-email-password:write:reset-password",
20
20
  requestEmailVerification: "auth-email-password:write:request-email-verification",
21
21
  verifyEmail: "auth-email-password:write:verify-email",
22
+ // Account-Unlock Magic-Link (fixes the lockout-DoS where the monotonic
23
+ // failure-counter re-locks immediately after expiry, #1266). Mirrors
24
+ // reset: request mints an HMAC token + mails it, confirm clears the
25
+ // Redis lockout state.
26
+ requestAccountUnlock: "auth-email-password:write:request-account-unlock",
27
+ confirmAccountUnlock: "auth-email-password:write:confirm-account-unlock",
22
28
  // Magic-Link Self-Signup (Pre-Activation-Token-Pattern). request mintet
23
29
  // einen opaken Random-Token, speichert ihn bidirektional in Redis und
24
30
  // sendet eine Aktivierungs-Mail. confirm löst den Token ein und legt
@@ -59,6 +65,11 @@ export const AuthErrors = {
59
65
  invalidVerificationToken: "invalid_verification_token",
60
66
  verificationNotConfigured: "email_verification_not_configured",
61
67
  emailNotVerified: "email_not_verified",
68
+ // Account-Unlock: mirrors the reset-token handling — every verify
69
+ // failure (malformed/bad-signature/expired) collapses to this single
70
+ // code so a probing client can't distinguish tampered from stale.
71
+ invalidUnlockToken: "invalid_unlock_token",
72
+ unlockNotConfigured: "account_unlock_not_configured",
62
73
  // Self-Signup: alle confirm-Failures (unbekannter Token, schon
63
74
  // konsumiert, abgelaufen) collapsen auf diesen Code — gleicher
64
75
  // anti-enumeration-Trade-off wie reset/verify.
@@ -103,6 +114,10 @@ export const AuthErrors = {
103
114
  export const AUTH_LOCKOUT_DEFAULT_MAX_FAILED_ATTEMPTS = 5;
104
115
  export const AUTH_LOCKOUT_DEFAULT_DURATION_MINUTES = 15;
105
116
 
117
+ // Account-unlock token TTL — same as reset (15min): short-lived, the user
118
+ // clicks it right after hitting the lockout screen.
119
+ export const AUTH_UNLOCK_DEFAULT_TTL_MINUTES = 15;
120
+
106
121
  export const AUTH_RESET_DEFAULT_TTL_MINUTES = 15;
107
122
  // Verification tokens live longer by default because the user may not be
108
123
  // at their computer the moment they sign up — 24h covers "verify after
@@ -73,6 +73,14 @@ const STRINGS = {
73
73
  inviteExpiry: (when: string) => `Der Link läuft am ${when} ab.`,
74
74
  inviteIgnore:
75
75
  "Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren.",
76
+ unlockSubject: (app: string) => `${app} — Konto entsperren`,
77
+ unlockGreeting: "Hallo,",
78
+ unlockIntro: (app: string) =>
79
+ `dein ${app}-Konto wurde nach mehreren fehlgeschlagenen Anmeldeversuchen vorübergehend gesperrt. Klicke auf den folgenden Link, um es sofort zu entsperren:`,
80
+ unlockButton: "Konto entsperren",
81
+ unlockExpiry: (when: string) => `Der Link läuft am ${when} ab.`,
82
+ unlockIgnore:
83
+ "Falls du diese Sperre nicht ausgelöst hast, kannst du diese E-Mail ignorieren — die Sperre läuft von selbst ab.",
76
84
  },
77
85
  en: {
78
86
  resetSubject: (app: string) => `${app} — Reset your password`,
@@ -105,6 +113,14 @@ const STRINGS = {
105
113
  inviteButton: "Accept invitation",
106
114
  inviteExpiry: (when: string) => `The link expires on ${when}.`,
107
115
  inviteIgnore: "If you weren't expecting this invitation, you can ignore this email.",
116
+ unlockSubject: (app: string) => `${app} — Unlock your account`,
117
+ unlockGreeting: "Hi,",
118
+ unlockIntro: (app: string) =>
119
+ `your ${app} account was temporarily locked after several failed sign-in attempts. Click the link below to unlock it immediately:`,
120
+ unlockButton: "Unlock account",
121
+ unlockExpiry: (when: string) => `The link expires on ${when}.`,
122
+ unlockIgnore:
123
+ "If you didn't trigger this lock, you can ignore this email — the lock expires on its own.",
108
124
  },
109
125
  } as const;
110
126
 
@@ -151,6 +167,22 @@ export function renderResetPasswordEmail(args: RenderTokenContentArgs): AuthMail
151
167
  });
152
168
  }
153
169
 
170
+ export function renderUnlockAccountEmail(args: RenderTokenContentArgs): AuthMailContent {
171
+ const locale = args.locale ?? "en";
172
+ const appName = args.appName ?? (locale === "de" ? "Konto" : "Account");
173
+ const t = STRINGS[locale];
174
+ return tokenMailContent({
175
+ subject: t.unlockSubject(appName),
176
+ header: t.unlockButton,
177
+ greeting: t.unlockGreeting,
178
+ intro: t.unlockIntro(appName),
179
+ buttonLabel: t.unlockButton,
180
+ buttonUrl: args.url,
181
+ expiry: t.unlockExpiry(formatExpiry(args.expiresAt)),
182
+ ignore: t.unlockIgnore,
183
+ });
184
+ }
185
+
154
186
  export function renderVerifyEmail(args: RenderTokenContentArgs): AuthMailContent {
155
187
  const locale = args.locale ?? "en";
156
188
  const appName = args.appName ?? (locale === "de" ? "Konto" : "Account");
@@ -37,6 +37,15 @@ export function invalidResetToken() {
37
37
  );
38
38
  }
39
39
 
40
+ // @wrapper-known error-helper
41
+ export function invalidUnlockToken() {
42
+ return writeFailure(
43
+ new UnprocessableError(AuthErrors.invalidUnlockToken, {
44
+ i18nKey: "auth.errors.invalidUnlockToken",
45
+ }),
46
+ );
47
+ }
48
+
40
49
  // @wrapper-known error-helper
41
50
  export function invalidVerificationToken() {
42
51
  return writeFailure(