@cosmicdrift/kumiko-bundled-features 0.113.1 → 1.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.
- package/package.json +6 -8
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +10 -9
- package/src/auth-email-password/__tests__/auth.integration.test.ts +5 -5
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +4 -7
- package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +3 -3
- package/src/auth-email-password/constants.ts +0 -4
- package/src/auth-email-password/feature.ts +4 -8
- package/src/auth-email-password/handlers/change-password.write.ts +11 -11
- package/src/auth-email-password/handlers/confirm-token-flow.ts +104 -33
- package/src/auth-email-password/handlers/login.write.ts +1 -4
- package/src/auth-email-password/password-hashing.ts +0 -11
- package/src/auth-email-password/stream-tenant.ts +31 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +1 -18
- package/src/auth-email-password/web/auth-gate.tsx +4 -3
- package/src/auth-email-password/web/invite-accept-screen.tsx +2 -2
- package/src/auth-email-password/web/reset-password-screen.tsx +11 -6
- package/src/auth-email-password/web/signup-complete-screen.tsx +5 -5
- package/src/auth-email-password/web/user-menu.tsx +1 -3
- package/src/auth-email-password/web/verify-email-screen.tsx +7 -2
- package/src/config/__tests__/config.integration.test.ts +8 -7
- package/src/config/__tests__/deserialize-value.test.ts +6 -3
- package/src/config/__tests__/inherited-redaction.integration.test.ts +3 -4
- package/src/config/feature.ts +8 -14
- package/src/config/handlers/set.write.ts +2 -2
- package/src/config/index.ts +3 -4
- package/src/config/resolver.ts +10 -34
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +0 -65
- package/src/custom-fields/constants.ts +0 -1
- package/src/custom-fields/events.ts +0 -6
- package/src/custom-fields/handlers/delete-system-field.write.ts +1 -2
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +1 -2
- package/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
- package/src/custom-fields/handlers/update-tenant-field.write.ts +0 -10
- package/src/custom-fields/wire-for-entity.ts +77 -116
- package/src/data-retention/__tests__/parse-override.test.ts +13 -13
- package/src/file-foundation/__tests__/file-foundation.integration.test.ts +3 -4
- package/src/folders/__tests__/folders.integration.test.ts +0 -3
- package/src/folders/web/__tests__/tree.test.ts +0 -8
- package/src/folders/web/folder-manager.tsx +3 -1
- package/src/folders/web/i18n.ts +2 -0
- package/src/folders/web/tree.ts +2 -13
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +0 -1
- package/src/folders-user-data/index.ts +4 -4
- package/src/jobs/__tests__/job-system-user.integration.test.ts +8 -4
- package/src/jobs/handlers/projection-rebuild.job.ts +4 -14
- package/src/ledger/__tests__/ledger.integration.test.ts +3 -8
- package/src/ledger/handlers/confirm-schedule-period.write.ts +4 -17
- package/src/ledger/recurring.ts +8 -17
- package/src/ledger/web/index.ts +2 -3
- package/src/legal-pages/feature.ts +3 -1
- package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +3 -4
- package/src/managed-pages/__tests__/branding-coerce.test.ts +6 -2
- package/src/managed-pages/feature.ts +3 -1
- package/src/readiness/__tests__/readiness.integration.test.ts +3 -4
- package/src/secrets/handlers/rotate.job.ts +98 -77
- package/src/secrets/secrets-context.ts +38 -7
- package/src/secrets/table.ts +7 -6
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +4 -6
- package/src/sessions/__tests__/sessions.integration.test.ts +3 -3
- package/src/sessions/feature.ts +18 -51
- package/src/sessions/index.ts +2 -2
- package/src/subscription-stripe/__tests__/runtime.test.ts +24 -14
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +5 -6
- package/src/tags/__tests__/tags.integration.test.ts +1 -12
- package/src/tags/entity.ts +1 -6
- package/src/tags/schemas.ts +2 -9
- package/src/tags/web/tag-manager.tsx +1 -1
- package/src/template-resolver/testing.ts +1 -2
- package/src/tenant/__tests__/tenant.integration.test.ts +4 -8
- package/src/tier-engine/__tests__/tier-engine.integration.test.ts +4 -7
- package/src/user/__tests__/user.integration.test.ts +0 -26
- package/src/user/handlers/find-for-auth.query.ts +5 -7
- package/src/user/index.ts +0 -4
- package/src/user-data-rights/__tests__/download.integration.test.ts +4 -7
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +3 -3
- package/src/user-data-rights/feature.ts +3 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +6 -4
- package/src/user-data-rights/lib/update-user-lifecycle.ts +24 -8
- package/src/user-data-rights/run-forget-cleanup.ts +0 -1
- package/src/user-data-rights/run-user-export.ts +4 -43
- package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +1 -7
- package/src/user-data-rights/web/client-plugin.tsx +1 -6
- package/src/user-data-rights/web/i18n.ts +10 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +9 -31
- package/src/user-profile/__tests__/profile-screen.test.tsx +0 -4
- package/src/user-profile/handlers/change-email.write.ts +8 -11
- package/src/user-profile/web/profile-screen.tsx +1 -12
- package/src/auth-email-password/__tests__/feature-options.test.ts +0 -33
- package/src/auth-email-password/password-hashing.test.ts +0 -35
- package/src/auth-email-password/web/__tests__/use-url-token.test.tsx +0 -29
- package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
- package/src/config/handlers/reencrypt.job.ts +0 -197
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +0 -204
- package/src/personal-access-tokens/__tests__/scopes.test.ts +0 -42
- package/src/personal-access-tokens/constants.ts +0 -33
- package/src/personal-access-tokens/feature.ts +0 -78
- package/src/personal-access-tokens/handlers/available-scopes.query.ts +0 -21
- package/src/personal-access-tokens/handlers/create.write.ts +0 -41
- package/src/personal-access-tokens/handlers/list.query.ts +0 -47
- package/src/personal-access-tokens/handlers/revoke.write.ts +0 -37
- package/src/personal-access-tokens/hash.ts +0 -17
- package/src/personal-access-tokens/index.ts +0 -45
- package/src/personal-access-tokens/resolver.ts +0 -58
- package/src/personal-access-tokens/roles.ts +0 -29
- package/src/personal-access-tokens/schema/api-token.ts +0 -56
- package/src/personal-access-tokens/scopes.ts +0 -43
- package/src/personal-access-tokens/web/client-plugin.tsx +0 -29
- package/src/personal-access-tokens/web/i18n.ts +0 -69
- package/src/personal-access-tokens/web/index.ts +0 -7
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +0 -251
- package/src/sessions/__tests__/auto-revoke-binding.test.ts +0 -71
- package/src/shared/chunked-entity-migration.ts +0 -89
- package/src/shared/index.ts +0 -7
- package/src/tags/__tests__/schemas.test.ts +0 -34
- package/src/tags/web/__tests__/tag-manager.test.tsx +0 -64
- package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +0 -186
- package/src/user/db/queries/stream-tenant-backfill.ts +0 -112
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +0 -152
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +0 -108
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.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>",
|
|
@@ -77,8 +77,6 @@
|
|
|
77
77
|
"./secrets": "./src/secrets/index.ts",
|
|
78
78
|
"./sessions": "./src/sessions/index.ts",
|
|
79
79
|
"./sessions/testing": "./src/sessions/testing.ts",
|
|
80
|
-
"./personal-access-tokens": "./src/personal-access-tokens/index.ts",
|
|
81
|
-
"./personal-access-tokens/web": "./src/personal-access-tokens/web/index.ts",
|
|
82
80
|
"./feature-toggles": "./src/feature-toggles/index.ts",
|
|
83
81
|
"./text-content": "./src/text-content/index.ts",
|
|
84
82
|
"./text-content/seeding": "./src/text-content/seeding.ts",
|
|
@@ -95,11 +93,11 @@
|
|
|
95
93
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
96
94
|
},
|
|
97
95
|
"dependencies": {
|
|
98
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
99
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
100
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
101
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
102
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
96
|
+
"@cosmicdrift/kumiko-dispatcher-live": "1.0.0",
|
|
97
|
+
"@cosmicdrift/kumiko-framework": "1.0.0",
|
|
98
|
+
"@cosmicdrift/kumiko-headless": "1.0.0",
|
|
99
|
+
"@cosmicdrift/kumiko-renderer": "1.0.0",
|
|
100
|
+
"@cosmicdrift/kumiko-renderer-web": "1.0.0",
|
|
103
101
|
"@mollie/api-client": "^4.5.0",
|
|
104
102
|
"@node-rs/argon2": "^2.0.2",
|
|
105
103
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
14
14
|
import { randomBytes } from "node:crypto";
|
|
15
15
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
|
+
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
16
17
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
18
|
import {
|
|
18
19
|
setupTestStack,
|
|
@@ -21,7 +22,6 @@ import {
|
|
|
21
22
|
unsafeCreateEntityTable,
|
|
22
23
|
unsafePushTables,
|
|
23
24
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
24
|
-
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
25
25
|
import { createConfigFeature } from "../../config";
|
|
26
26
|
import { createConfigResolver } from "../../config/resolver";
|
|
27
27
|
import { configValuesTable } from "../../config/table";
|
|
@@ -42,8 +42,8 @@ const systemAdmin = TestUsers.systemAdmin;
|
|
|
42
42
|
const encryptionKey = randomBytes(32).toString("base64");
|
|
43
43
|
|
|
44
44
|
beforeAll(async () => {
|
|
45
|
-
const encryption =
|
|
46
|
-
const resolver = createConfigResolver({
|
|
45
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
46
|
+
const resolver = createConfigResolver({ encryption });
|
|
47
47
|
|
|
48
48
|
stack = await setupTestStack({
|
|
49
49
|
features: [
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
13
|
import { randomBytes } from "node:crypto";
|
|
14
14
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
|
+
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
15
16
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
17
|
import {
|
|
17
18
|
setupTestStack,
|
|
@@ -20,7 +21,6 @@ import {
|
|
|
20
21
|
unsafeCreateEntityTable,
|
|
21
22
|
unsafePushTables,
|
|
22
23
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
23
|
-
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
24
24
|
import { createConfigFeature } from "../../config";
|
|
25
25
|
import { createConfigResolver } from "../../config/resolver";
|
|
26
26
|
import { configValuesTable } from "../../config/table";
|
|
@@ -48,8 +48,8 @@ const MAX_ATTEMPTS = 3;
|
|
|
48
48
|
const LOCK_MINUTES = 1;
|
|
49
49
|
|
|
50
50
|
beforeAll(async () => {
|
|
51
|
-
const encryption =
|
|
52
|
-
const resolver = createConfigResolver({
|
|
51
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
52
|
+
const resolver = createConfigResolver({ encryption });
|
|
53
53
|
|
|
54
54
|
stack = await setupTestStack({
|
|
55
55
|
features: [
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
|
+
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
4
5
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
6
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
7
|
import {
|
|
@@ -11,7 +12,7 @@ import {
|
|
|
11
12
|
unsafeCreateEntityTable,
|
|
12
13
|
unsafePushTables,
|
|
13
14
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
14
|
-
import {
|
|
15
|
+
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
15
16
|
import { createConfigFeature } from "../../config";
|
|
16
17
|
import { createConfigResolver } from "../../config/resolver";
|
|
17
18
|
import { configValuesTable } from "../../config/table";
|
|
@@ -67,8 +68,8 @@ const segmentsByUserAndTenant = new Map<string, string>();
|
|
|
67
68
|
const plansByTenant = new Map<TenantId, string>();
|
|
68
69
|
|
|
69
70
|
beforeAll(async () => {
|
|
70
|
-
const encryption =
|
|
71
|
-
const resolver = createConfigResolver({
|
|
71
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
72
|
+
const resolver = createConfigResolver({ encryption });
|
|
72
73
|
|
|
73
74
|
stack = await setupTestStack({
|
|
74
75
|
features: [
|
|
@@ -259,8 +260,8 @@ describe("scenario 2: switch-tenant recomputes claims (no stale tenant-A claims
|
|
|
259
260
|
|
|
260
261
|
describe("scenario 2.5: reserved separator + multi-feature isolation", () => {
|
|
261
262
|
test("one feature returns key with ':' → only that key dropped, other features merge cleanly", async () => {
|
|
262
|
-
const localEncryption =
|
|
263
|
-
const localResolver = createConfigResolver({
|
|
263
|
+
const localEncryption = createEncryptionProvider(encryptionKey);
|
|
264
|
+
const localResolver = createConfigResolver({ encryption: localEncryption });
|
|
264
265
|
// A polluted feature that tries to smuggle in a qualified-name-shaped
|
|
265
266
|
// inner key (injecting ":" would otherwise let it spoof another
|
|
266
267
|
// feature's prefix). Plus a clean companion to prove the drop is
|
|
@@ -336,8 +337,8 @@ describe("scenario 2.5: reserved separator + multi-feature isolation", () => {
|
|
|
336
337
|
|
|
337
338
|
describe("scenario 2.6: multi-feature drift warnings fire independently", () => {
|
|
338
339
|
test("feature A + feature B both return undeclared keys → each logs its own warning", async () => {
|
|
339
|
-
const localEncryption =
|
|
340
|
-
const localResolver = createConfigResolver({
|
|
340
|
+
const localEncryption = createEncryptionProvider(encryptionKey);
|
|
341
|
+
const localResolver = createConfigResolver({ encryption: localEncryption });
|
|
341
342
|
|
|
342
343
|
// Capture warn() calls to verify drift warnings fire per-feature, not
|
|
343
344
|
// globally collapsed.
|
|
@@ -424,8 +425,8 @@ describe("scenario 2.6: multi-feature drift warnings fire independently", () =>
|
|
|
424
425
|
|
|
425
426
|
describe("scenario 3: a broken claims hook does not break login", () => {
|
|
426
427
|
test("hook throws → login still succeeds, broken feature simply contributes nothing, warn logged with featureName+err", async () => {
|
|
427
|
-
const localEncryption =
|
|
428
|
-
const localResolver = createConfigResolver({
|
|
428
|
+
const localEncryption = createEncryptionProvider(encryptionKey);
|
|
429
|
+
const localResolver = createConfigResolver({ encryption: localEncryption });
|
|
429
430
|
|
|
430
431
|
// Capture warn-calls so the test can verify the resolver actually
|
|
431
432
|
// logged the broken hook with featureName + err.message — previously
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
|
+
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
4
5
|
import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
6
|
import {
|
|
6
7
|
createTestUser,
|
|
@@ -12,7 +13,6 @@ import {
|
|
|
12
13
|
unsafePushTables,
|
|
13
14
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
14
15
|
import {
|
|
15
|
-
createTestEnvelopeCipher,
|
|
16
16
|
expectErrorIncludes,
|
|
17
17
|
getSetCookieRaw,
|
|
18
18
|
getSetCookieValue,
|
|
@@ -37,8 +37,8 @@ const systemAdmin = TestUsers.systemAdmin;
|
|
|
37
37
|
const encryptionKey = randomBytes(32).toString("base64");
|
|
38
38
|
|
|
39
39
|
beforeAll(async () => {
|
|
40
|
-
const encryption =
|
|
41
|
-
const resolver = createConfigResolver({
|
|
40
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
41
|
+
const resolver = createConfigResolver({ encryption });
|
|
42
42
|
|
|
43
43
|
stack = await setupTestStack({
|
|
44
44
|
features: [
|
|
@@ -374,8 +374,8 @@ describe("scenario 7b: login rate limiting", () => {
|
|
|
374
374
|
let rlStack: TestStack;
|
|
375
375
|
|
|
376
376
|
beforeAll(async () => {
|
|
377
|
-
const encryption =
|
|
378
|
-
const resolver = createConfigResolver({
|
|
377
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
378
|
+
const resolver = createConfigResolver({ encryption });
|
|
379
379
|
const { createInMemoryLoginRateLimiter } = await import("@cosmicdrift/kumiko-framework/api");
|
|
380
380
|
|
|
381
381
|
rlStack = await setupTestStack({
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
|
+
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
4
5
|
import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
6
|
import {
|
|
6
7
|
setupTestStack,
|
|
@@ -9,11 +10,7 @@ import {
|
|
|
9
10
|
unsafeCreateEntityTable,
|
|
10
11
|
unsafePushTables,
|
|
11
12
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
12
|
-
import {
|
|
13
|
-
createTestEnvelopeCipher,
|
|
14
|
-
seedRow,
|
|
15
|
-
updateRows,
|
|
16
|
-
} from "@cosmicdrift/kumiko-framework/testing";
|
|
13
|
+
import { seedRow, updateRows } from "@cosmicdrift/kumiko-framework/testing";
|
|
17
14
|
import { Temporal } from "temporal-polyfill";
|
|
18
15
|
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
19
16
|
import { createConfigFeature } from "../../config";
|
|
@@ -53,8 +50,8 @@ const appVerifyUrl = "https://app.example.com/verify";
|
|
|
53
50
|
const appResetUrl = "https://app.example.com/reset";
|
|
54
51
|
|
|
55
52
|
beforeAll(async () => {
|
|
56
|
-
const encryption =
|
|
57
|
-
const resolver = createConfigResolver({
|
|
53
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
54
|
+
const resolver = createConfigResolver({ encryption });
|
|
58
55
|
|
|
59
56
|
stack = await setupTestStack({
|
|
60
57
|
features: [
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
9
|
import { pbkdf2Sync, randomBytes } from "node:crypto";
|
|
10
10
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
11
|
+
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
11
12
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
13
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
13
14
|
import {
|
|
@@ -17,7 +18,6 @@ import {
|
|
|
17
18
|
unsafeCreateEntityTable,
|
|
18
19
|
unsafePushTables,
|
|
19
20
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
20
|
-
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
21
21
|
import { createConfigFeature } from "../../config";
|
|
22
22
|
import { createConfigResolver } from "../../config/resolver";
|
|
23
23
|
import { configValuesTable } from "../../config/table";
|
|
@@ -50,8 +50,8 @@ function buildBmcStyleV3Hash(password: string, salt: Buffer): string {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
beforeAll(async () => {
|
|
53
|
-
const encryption =
|
|
54
|
-
const resolver = createConfigResolver({
|
|
53
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
54
|
+
const resolver = createConfigResolver({ encryption });
|
|
55
55
|
|
|
56
56
|
stack = await setupTestStack({
|
|
57
57
|
features: [
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
|
+
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
4
5
|
import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
6
|
import {
|
|
6
7
|
setupTestStack,
|
|
@@ -9,7 +10,7 @@ import {
|
|
|
9
10
|
unsafeCreateEntityTable,
|
|
10
11
|
unsafePushTables,
|
|
11
12
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
12
|
-
import {
|
|
13
|
+
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
13
14
|
import { Temporal } from "temporal-polyfill";
|
|
14
15
|
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
15
16
|
import { createConfigFeature } from "../../config";
|
|
@@ -50,8 +51,8 @@ const resetSecret = randomBytes(32).toString("base64");
|
|
|
50
51
|
const appResetUrl = "https://app.example.com/reset";
|
|
51
52
|
|
|
52
53
|
beforeAll(async () => {
|
|
53
|
-
const encryption =
|
|
54
|
-
const resolver = createConfigResolver({
|
|
54
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
55
|
+
const resolver = createConfigResolver({ encryption });
|
|
55
56
|
|
|
56
57
|
stack = await setupTestStack({
|
|
57
58
|
features: [
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
8
8
|
import { randomBytes } from "node:crypto";
|
|
9
9
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
10
11
|
import {
|
|
11
12
|
setupTestStack,
|
|
12
13
|
type TestStack,
|
|
13
14
|
unsafeCreateEntityTable,
|
|
14
15
|
unsafePushTables,
|
|
15
16
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
16
|
-
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
17
17
|
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
18
18
|
import { createConfigFeature } from "../../config";
|
|
19
19
|
import { createConfigResolver } from "../../config/resolver";
|
|
@@ -40,8 +40,8 @@ const resetSecret = randomBytes(32).toString("base64");
|
|
|
40
40
|
const verifySecret = randomBytes(32).toString("base64");
|
|
41
41
|
|
|
42
42
|
beforeAll(async () => {
|
|
43
|
-
const encryption =
|
|
44
|
-
const resolver = createConfigResolver({
|
|
43
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
44
|
+
const resolver = createConfigResolver({ encryption });
|
|
45
45
|
|
|
46
46
|
stack = await setupTestStack({
|
|
47
47
|
features: [
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
|
+
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
4
5
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
6
|
import {
|
|
6
7
|
setupTestStack,
|
|
@@ -9,7 +10,6 @@ import {
|
|
|
9
10
|
unsafeCreateEntityTable,
|
|
10
11
|
unsafePushTables,
|
|
11
12
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
12
|
-
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
13
13
|
import * as jose from "jose";
|
|
14
14
|
import { createConfigFeature } from "../../config";
|
|
15
15
|
import { createConfigResolver } from "../../config/resolver";
|
|
@@ -90,8 +90,8 @@ const TENANT_A: TenantId = "00000000-0000-4000-8000-0000000000a1";
|
|
|
90
90
|
const TENANT_B: TenantId = "00000000-0000-4000-8000-0000000000b1";
|
|
91
91
|
|
|
92
92
|
beforeAll(async () => {
|
|
93
|
-
const encryption =
|
|
94
|
-
const resolver = createConfigResolver({
|
|
93
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
94
|
+
const resolver = createConfigResolver({ encryption });
|
|
95
95
|
|
|
96
96
|
store = createFakeStore();
|
|
97
97
|
sidStream = deterministicSids();
|
|
@@ -6,10 +6,6 @@
|
|
|
6
6
|
// der server-side Zugriff (handlers, dispatcher) erhalten.
|
|
7
7
|
export const AUTH_EMAIL_PASSWORD_FEATURE = "auth-email-password" as const;
|
|
8
8
|
|
|
9
|
-
// Minimum length for reset/verify hmacSecret — mirrors the ≥32-char
|
|
10
|
-
// JWT_SECRET env check (HMAC-SHA256 key material).
|
|
11
|
-
export const MIN_HMAC_SECRET_LENGTH = 32;
|
|
12
|
-
|
|
13
9
|
// Qualified handler names. Non-CRUD handlers, no entity prefix.
|
|
14
10
|
export const AuthHandlers = {
|
|
15
11
|
login: "auth-email-password:write:login",
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { MIN_HMAC_SECRET_LENGTH } from "./constants";
|
|
4
3
|
import type { AuthMailLocale } from "./email-templates";
|
|
5
4
|
import { changePasswordWrite } from "./handlers/change-password.write";
|
|
6
5
|
import { createInviteAcceptHandler } from "./handlers/invite-accept.write";
|
|
@@ -123,17 +122,14 @@ export type AuthEmailPasswordOptions = {
|
|
|
123
122
|
export function createAuthEmailPasswordFeature(
|
|
124
123
|
opts: AuthEmailPasswordOptions = {},
|
|
125
124
|
): FeatureDefinition {
|
|
126
|
-
|
|
127
|
-
// makes reset/verify tokens forgeable — that's account takeover, so the
|
|
128
|
-
// factory fails fast instead of trusting the caller.
|
|
129
|
-
if (opts.passwordReset && opts.passwordReset.hmacSecret.length < MIN_HMAC_SECRET_LENGTH) {
|
|
125
|
+
if (opts.passwordReset && !opts.passwordReset.hmacSecret) {
|
|
130
126
|
throw new Error(
|
|
131
|
-
|
|
127
|
+
"[auth-email-password] passwordReset.hmacSecret must be non-empty when passwordReset is configured",
|
|
132
128
|
);
|
|
133
129
|
}
|
|
134
|
-
if (opts.emailVerification && opts.emailVerification.hmacSecret
|
|
130
|
+
if (opts.emailVerification && !opts.emailVerification.hmacSecret) {
|
|
135
131
|
throw new Error(
|
|
136
|
-
|
|
132
|
+
"[auth-email-password] emailVerification.hmacSecret must be non-empty when emailVerification is configured",
|
|
137
133
|
);
|
|
138
134
|
}
|
|
139
135
|
|
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
createSystemUser,
|
|
4
|
-
defineWriteHandler,
|
|
5
|
-
SYSTEM_TENANT_ID,
|
|
6
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { access, createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { getAggregateStreamTenant } from "@cosmicdrift/kumiko-framework/event-store";
|
|
7
3
|
import { z } from "zod";
|
|
8
|
-
import { UserHandlers, UserQueries } from "../../user";
|
|
4
|
+
import { USER_FEATURE, UserHandlers, UserQueries } from "../../user";
|
|
9
5
|
import { invalidCredentials } from "../errors";
|
|
10
6
|
import { hashPassword, verifyPassword } from "../password-hashing";
|
|
11
7
|
|
|
@@ -39,10 +35,14 @@ export const changePasswordWrite = defineWriteHandler({
|
|
|
39
35
|
|
|
40
36
|
const newHash = await hashPassword(event.payload.newPassword);
|
|
41
37
|
|
|
42
|
-
// The user aggregate is
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
38
|
+
// The user aggregate is r.systemScope(): its event stream can live in a
|
|
39
|
+
// tenant that isn't the session tenant (a platform operator's stream sits
|
|
40
|
+
// in the seed executor's tenant, not their membership). Write against the
|
|
41
|
+
// real stream tenant so optimistic locking targets the right stream instead
|
|
42
|
+
// of version_conflict-ing against an empty one. Falls back to the session
|
|
43
|
+
// tenant when the lookup finds nothing (fresh/unknown stream).
|
|
44
|
+
const streamTenant = await getAggregateStreamTenant(ctx.db.raw, event.user.id, USER_FEATURE);
|
|
45
|
+
const writer = createSystemUser(streamTenant ?? event.user.tenantId);
|
|
46
46
|
|
|
47
47
|
// Apply via user feature's update handler — writeAs(system) satisfies
|
|
48
48
|
// the privileged-only write rule on passwordHash. Pass the current version
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
// 1. Redis check + burn (single-use enforcement)
|
|
6
6
|
// 2. Load user + deleted/missing/no-version guard
|
|
7
7
|
// 3. Optional idempotent short-circuit (verify-email when already done)
|
|
8
|
-
// 4.
|
|
9
|
-
// 5.
|
|
8
|
+
// 4. Resolve memberships → tenant-order for stream-matching
|
|
9
|
+
// 5. Try each tenant's stream with the handler-specific `changes`
|
|
10
|
+
// 6. Release the burn on ANY non-success path so a legit retry isn't
|
|
10
11
|
// locked out by a stale marker
|
|
11
12
|
//
|
|
12
13
|
// The top-level `runConfirmTokenFlow` orchestrates and owns the
|
|
@@ -14,26 +15,26 @@
|
|
|
14
15
|
// burn (success, already-done) flips `committed = true`; everything
|
|
15
16
|
// else — including future branches a maintainer adds — releases
|
|
16
17
|
// automatically.
|
|
17
|
-
//
|
|
18
|
-
// The user aggregate is systemStream (#497): its event stream lives on
|
|
19
|
-
// SYSTEM_TENANT_ID deterministically, so the write targets exactly one
|
|
20
|
-
// stream. The former membership-probing (`tryWriteAcrossTenants`) existed
|
|
21
|
-
// for pre-#497 scattered streams; those need the one-time
|
|
22
|
-
// backfillUserStreamTenants migration (#762) — probing them stopped working
|
|
23
|
-
// the moment the executor's stream choke-point landed anyway.
|
|
24
18
|
|
|
25
19
|
import {
|
|
26
20
|
createSystemUser,
|
|
27
21
|
type HandlerContext,
|
|
28
22
|
type SessionUser,
|
|
29
23
|
SYSTEM_TENANT_ID,
|
|
24
|
+
type TenantId,
|
|
30
25
|
type WriteResult,
|
|
31
26
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
32
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
InternalError,
|
|
29
|
+
type WriteFailure,
|
|
30
|
+
writeFailure,
|
|
31
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
32
|
+
import { getAggregateStreamTenant } from "@cosmicdrift/kumiko-framework/event-store";
|
|
33
33
|
import type Redis from "ioredis";
|
|
34
|
-
import { UserHandlers, UserQueries } from "../../user";
|
|
34
|
+
import { USER_FEATURE, UserHandlers, UserQueries } from "../../user";
|
|
35
35
|
import type { AuthUserRow } from "../auth-user-row";
|
|
36
36
|
import { parseAuthUserRow } from "../auth-user-row";
|
|
37
|
+
import { orderTenantsByPreference } from "../stream-tenant";
|
|
37
38
|
import { burnToken, unburnToken } from "../token-burn-store";
|
|
38
39
|
|
|
39
40
|
export type ConfirmTokenFlowSpec<TSuccessData> = {
|
|
@@ -45,11 +46,13 @@ export type ConfirmTokenFlowSpec<TSuccessData> = {
|
|
|
45
46
|
// is misconfigured, not the caller's fault.
|
|
46
47
|
readonly redisRequiredMessage: string;
|
|
47
48
|
// Standard failure returned for every "token can't be consumed" path
|
|
48
|
-
// (bad state,
|
|
49
|
-
// 422 with a uniform code so the caller can't tell which
|
|
49
|
+
// (bad state, missing memberships, every tenant rejected). The route
|
|
50
|
+
// layer returns 422 with a uniform code so the caller can't tell which
|
|
51
|
+
// branch fired.
|
|
50
52
|
readonly invalidToken: () => ReturnType<typeof writeFailure>;
|
|
51
|
-
// Handler-specific payload for user:update. Runs once per token
|
|
52
|
-
//
|
|
53
|
+
// Handler-specific payload for user:update. Runs once per token — the
|
|
54
|
+
// result is shared across every tenant-stream attempt. Can be async
|
|
55
|
+
// (password-reset hashes here).
|
|
53
56
|
readonly buildChanges: (me: AuthUserRow) => Promise<Record<string, unknown>>;
|
|
54
57
|
// Returned verbatim on a successful write.
|
|
55
58
|
readonly successData: TSuccessData;
|
|
@@ -93,21 +96,20 @@ export async function runConfirmTokenFlow<TSuccessData>(
|
|
|
93
96
|
return { isSuccess: true, data: spec.alreadyDone.data };
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
const tenantOrder = await resolveStreamTenants(ctx, systemUser, me);
|
|
100
|
+
if (tenantOrder.length === 0) return spec.invalidToken();
|
|
101
|
+
|
|
96
102
|
const changes = await spec.buildChanges(me);
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
version: me.version,
|
|
100
|
-
changes,
|
|
101
|
-
});
|
|
102
|
-
if (writeRes.isSuccess) {
|
|
103
|
+
const writeResult = await tryWriteAcrossTenants(ctx, me, tenantOrder, changes);
|
|
104
|
+
if (writeResult.isSuccess) {
|
|
103
105
|
committed = true;
|
|
104
106
|
return { isSuccess: true, data: spec.successData };
|
|
105
107
|
}
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
if (
|
|
110
|
-
return
|
|
108
|
+
// `all_conflicts` = every tenant returned version_conflict → token-level
|
|
109
|
+
// failure. `hard_failure` = a real write error (DB down, access
|
|
110
|
+
// denied) that bubbles unchanged.
|
|
111
|
+
if (writeResult.reason === "all_conflicts") return spec.invalidToken();
|
|
112
|
+
return writeResult.failure;
|
|
111
113
|
} finally {
|
|
112
114
|
// committed===false covers EVERY failure path — including branches a
|
|
113
115
|
// future maintainer adds without reading this file. The original
|
|
@@ -119,13 +121,14 @@ export async function runConfirmTokenFlow<TSuccessData>(
|
|
|
119
121
|
}
|
|
120
122
|
}
|
|
121
123
|
|
|
124
|
+
// --- Private helpers ------------------------------------------------------
|
|
125
|
+
|
|
122
126
|
// Fetches the user row via the privileged findForAuth query and validates
|
|
123
|
-
// it's usable for a write: not deleted, has a row.version
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
// not at every callsite.
|
|
127
|
+
// it's usable for a write: not deleted, has a row.version (the version
|
|
128
|
+
// column is a findForAuth contract field — absence is a schema bug, but
|
|
129
|
+
// we still handle it gracefully rather than throwing past the burn).
|
|
130
|
+
// Return type narrows `version` to `number` so the write-callsite doesn't
|
|
131
|
+
// need a `?? 0` fallback — the guard lives here, not at every callsite.
|
|
129
132
|
async function loadValidatedUser(
|
|
130
133
|
ctx: HandlerContext,
|
|
131
134
|
systemUser: SessionUser,
|
|
@@ -134,6 +137,74 @@ async function loadValidatedUser(
|
|
|
134
137
|
const me = parseAuthUserRow(
|
|
135
138
|
await ctx.queryAs(systemUser, UserQueries.findForAuth, { id: userId }),
|
|
136
139
|
);
|
|
137
|
-
if (!me || me.isDeleted || me.version === undefined
|
|
140
|
+
if (!me || me.isDeleted || me.version === undefined) return null;
|
|
138
141
|
return { ...me, version: me.version };
|
|
139
142
|
}
|
|
143
|
+
|
|
144
|
+
// Loads the user's memberships and returns a prioritised tenant list, with the
|
|
145
|
+
// aggregate's real stream tenant recovered from the event log prepended.
|
|
146
|
+
//
|
|
147
|
+
// Empty only when the user has NO memberships AND no recoverable stream tenant
|
|
148
|
+
// — the caller treats that as invalid_token. A zero-membership systemScope user
|
|
149
|
+
// whose stream lives outside any membership (a platform operator seeded under a
|
|
150
|
+
// fixture/SYSTEM tenant) still resolves, because the stream-tenant lookup runs
|
|
151
|
+
// before the empty check rather than being short-circuited by it.
|
|
152
|
+
async function resolveStreamTenants(
|
|
153
|
+
ctx: HandlerContext,
|
|
154
|
+
systemUser: SessionUser,
|
|
155
|
+
me: AuthUserRow,
|
|
156
|
+
): Promise<readonly TenantId[]> {
|
|
157
|
+
const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
|
|
158
|
+
userId: me.id,
|
|
159
|
+
})) as Array<{ tenantId: TenantId }>; // @cast-boundary db-runner
|
|
160
|
+
const ordered = orderTenantsByPreference(memberships, me.lastActiveTenantId);
|
|
161
|
+
|
|
162
|
+
// The user aggregate is r.systemScope(): its event stream lives in whichever
|
|
163
|
+
// tenant the creating executor used, which need NOT be a membership tenant.
|
|
164
|
+
// A platform operator seeded under a fixture/platform tenant is the live case
|
|
165
|
+
// — its stream tenant is absent from `ordered`, so a membership-only search
|
|
166
|
+
// rejects every write and collapses to invalid_token. Recover the real stream
|
|
167
|
+
// tenant from the event log and try it first; memberships stay as fallback.
|
|
168
|
+
// Pulled BEFORE the empty-membership check so a zero-membership operator whose
|
|
169
|
+
// stream lives in SYSTEM_TENANT is recoverable instead of collapsing to
|
|
170
|
+
// invalid_token — mirrors change-password.write.ts's unconditional recovery.
|
|
171
|
+
const streamTenant = await getAggregateStreamTenant(ctx.db.raw, me.id, USER_FEATURE);
|
|
172
|
+
if (streamTenant && !ordered.includes(streamTenant)) {
|
|
173
|
+
return [streamTenant, ...ordered];
|
|
174
|
+
}
|
|
175
|
+
return ordered;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Discriminated result for the write-across-tenants loop.
|
|
179
|
+
// all_conflicts → every candidate rejected with version_conflict →
|
|
180
|
+
// token-level failure; caller returns invalidToken.
|
|
181
|
+
// hard_failure → a non-conflict error that should bubble unchanged
|
|
182
|
+
// (DB down, access denied, …); caller returns it as-is.
|
|
183
|
+
type TenantWriteResult =
|
|
184
|
+
| { isSuccess: true }
|
|
185
|
+
| { isSuccess: false; reason: "all_conflicts" }
|
|
186
|
+
| { isSuccess: false; reason: "hard_failure"; failure: WriteFailure };
|
|
187
|
+
|
|
188
|
+
// Attempts the update against each candidate stream. memberships-query
|
|
189
|
+
// has no deterministic ORDER BY, so the matching stream is discovered by
|
|
190
|
+
// attempt: version_conflict → try the next candidate, anything else →
|
|
191
|
+
// bubble immediately so ops sees the real failure class.
|
|
192
|
+
async function tryWriteAcrossTenants(
|
|
193
|
+
ctx: HandlerContext,
|
|
194
|
+
me: AuthUserRow & { version: number },
|
|
195
|
+
tenantOrder: readonly TenantId[],
|
|
196
|
+
changes: Record<string, unknown>,
|
|
197
|
+
): Promise<TenantWriteResult> {
|
|
198
|
+
for (const tenantId of tenantOrder) {
|
|
199
|
+
const writeRes = await ctx.writeAs(createSystemUser(tenantId), UserHandlers.update, {
|
|
200
|
+
id: me.id,
|
|
201
|
+
version: me.version,
|
|
202
|
+
changes,
|
|
203
|
+
});
|
|
204
|
+
if (writeRes.isSuccess) return { isSuccess: true };
|
|
205
|
+
if (writeRes.error.code !== "version_conflict") {
|
|
206
|
+
return { isSuccess: false, reason: "hard_failure", failure: writeRes };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return { isSuccess: false, reason: "all_conflicts" };
|
|
210
|
+
}
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
noMembership,
|
|
22
22
|
} from "../errors";
|
|
23
23
|
import { clearLockoutState, getLockoutState, recordFailedAttempt } from "../lockout-store";
|
|
24
|
-
import {
|
|
24
|
+
import { verifyPassword } from "../password-hashing";
|
|
25
25
|
|
|
26
26
|
export type LoginHandlerOptions = {
|
|
27
27
|
// When true, a valid (email + password) login fails with email_not_verified
|
|
@@ -74,9 +74,6 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
|
|
|
74
74
|
// Uniform response on any credential mismatch (no user, wrong password,
|
|
75
75
|
// soft-deleted user) — prevents email enumeration.
|
|
76
76
|
if (!found?.passwordHash || found.isDeleted) {
|
|
77
|
-
// Burn the same argon2 verify cost as the hit path so response
|
|
78
|
-
// latency doesn't reveal whether the email is registered (#774).
|
|
79
|
-
await verifyDummyPassword(event.payload.password);
|
|
80
77
|
return invalidCredentials();
|
|
81
78
|
}
|
|
82
79
|
|
|
@@ -42,14 +42,3 @@ export async function verifyPassword(hashString: string, password: string): Prom
|
|
|
42
42
|
return false;
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
-
|
|
46
|
-
// Anti-enumeration timing equaliser (#774). The login handler runs this on
|
|
47
|
-
// the no-user / no-hash path so a missing account costs the same argon2
|
|
48
|
-
// latency as a real verify — otherwise response timing leaks whether an
|
|
49
|
-
// email is registered. Derived from hashPassword once and cached, so it
|
|
50
|
-
// always tracks HASH_OPTIONS; the password never matches, result discarded.
|
|
51
|
-
let dummyHash: Promise<string> | undefined;
|
|
52
|
-
export async function verifyDummyPassword(password: string): Promise<void> {
|
|
53
|
-
dummyHash ??= hashPassword("anti-enumeration-dummy");
|
|
54
|
-
await verifyPassword(await dummyHash, password);
|
|
55
|
-
}
|