@cosmicdrift/kumiko-bundled-features 0.148.0 → 0.149.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.148.0",
3
+ "version": "0.149.1",
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>",
@@ -114,11 +114,11 @@
114
114
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
115
115
  },
116
116
  "dependencies": {
117
- "@cosmicdrift/kumiko-dispatcher-live": "0.148.0",
118
- "@cosmicdrift/kumiko-framework": "0.148.0",
119
- "@cosmicdrift/kumiko-headless": "0.148.0",
120
- "@cosmicdrift/kumiko-renderer": "0.148.0",
121
- "@cosmicdrift/kumiko-renderer-web": "0.148.0",
117
+ "@cosmicdrift/kumiko-dispatcher-live": "0.149.1",
118
+ "@cosmicdrift/kumiko-framework": "0.149.1",
119
+ "@cosmicdrift/kumiko-headless": "0.149.1",
120
+ "@cosmicdrift/kumiko-renderer": "0.149.1",
121
+ "@cosmicdrift/kumiko-renderer-web": "0.149.1",
122
122
  "@mollie/api-client": "^4.5.0",
123
123
  "imapflow": "^1.3.3",
124
124
  "mailparser": "^3.9.8",
@@ -17,7 +17,7 @@ import { configValuesTable } from "../../config/table";
17
17
  import { createUserFeature } from "../../user/feature";
18
18
  import { userEntity } from "../../user/schema/user";
19
19
  import { base32Decode } from "../base32";
20
- import { AuthMfaHandlers } from "../constants";
20
+ import { AuthMfaHandlers, AuthMfaQueries } from "../constants";
21
21
  import { createAuthMfaFeature } from "../feature";
22
22
  import { userMfaEntity } from "../schema/user-mfa";
23
23
  import { currentTotpCode } from "../totp";
@@ -147,4 +147,27 @@ describe("enable-start + enable-confirm round trip", () => {
147
147
  );
148
148
  expectErrorIncludes(err, "invalid_setup_token");
149
149
  });
150
+
151
+ test("status query reflects enrollment before and after enable-confirm", async () => {
152
+ const user = createTestUser({ id: "status-query-1", roles: ["User"] });
153
+
154
+ const before = await stack.http.queryOk<{ enabled: boolean }>(AuthMfaQueries.status, {}, user);
155
+ expect(before.enabled).toBe(false);
156
+
157
+ const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
158
+ AuthMfaHandlers.enableStart,
159
+ { accountLabel: "status@example.com" },
160
+ user,
161
+ );
162
+ const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
163
+ const code = currentTotpCode(base32Decode(secretParam));
164
+ await stack.http.writeOk(
165
+ AuthMfaHandlers.enableConfirm,
166
+ { setupToken: start.setupToken, code },
167
+ user,
168
+ );
169
+
170
+ const after = await stack.http.queryOk<{ enabled: boolean }>(AuthMfaQueries.status, {}, user);
171
+ expect(after.enabled).toBe(true);
172
+ });
150
173
  });
@@ -0,0 +1,137 @@
1
+ // Regression test for a real crash found via manual browser testing in a
2
+ // consumer app (kumiko-studio) that mounts the full GDPR/user-data-rights
3
+ // stack: totpSecret and recoveryCodes are both `encrypted: true` +
4
+ // `userOwned` (schema/user-mfa.ts) — the SECOND layer (per-subject
5
+ // crypto-shredding, entity-field-encryption.ts's sibling pipeline) only
6
+ // activates when `configurePiiSubjectKms` has been called, which none of
7
+ // the other auth-mfa integration tests do. That gap let a real bug ship:
8
+ // recoveryCodes used to be a `createJsonbField` (an object, not a string),
9
+ // and `encryptPiiFieldValues` throws "PII field must be a string" for any
10
+ // non-string userOwned field. enable-confirm failed for EVERY real deployment
11
+ // with crypto-shredding configured, while every test here stayed green.
12
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import {
14
+ configurePiiSubjectKms,
15
+ InMemoryKmsAdapter,
16
+ resetPiiSubjectKmsForTests,
17
+ } from "@cosmicdrift/kumiko-framework/crypto";
18
+ import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
19
+ import {
20
+ createTestUser,
21
+ setupTestStack,
22
+ type TestStack,
23
+ unsafeCreateEntityTable,
24
+ unsafePushTables,
25
+ } from "@cosmicdrift/kumiko-framework/stack";
26
+ import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
27
+ import { createConfigFeature } from "../../config";
28
+ import { createConfigResolver } from "../../config/resolver";
29
+ import { configValuesTable } from "../../config/table";
30
+ import { createUserFeature } from "../../user/feature";
31
+ import { userEntity } from "../../user/schema/user";
32
+ import { base32Decode } from "../base32";
33
+ import { AuthMfaHandlers } from "../constants";
34
+ import { createAuthMfaFeature } from "../feature";
35
+ import { userMfaEntity } from "../schema/user-mfa";
36
+ import { currentTotpCode } from "../totp";
37
+
38
+ let stack: TestStack;
39
+
40
+ const SETUP_TOKEN_SECRET = "test-setup-token-secret-do-not-use-in-prod";
41
+
42
+ beforeAll(async () => {
43
+ const encryption = createTestEnvelopeCipher();
44
+ configureEntityFieldEncryption(encryption);
45
+ const resolver = createConfigResolver({ cipher: encryption });
46
+ stack = await setupTestStack({
47
+ features: [
48
+ createConfigFeature(),
49
+ createUserFeature(),
50
+ createAuthMfaFeature({
51
+ setupTokenSecret: SETUP_TOKEN_SECRET,
52
+ issuer: "Kumiko Test",
53
+ challengeTokenSecret: "test-mfa-challenge-secret-at-least-32-bytes!!",
54
+ }),
55
+ ],
56
+ extraContext: { configResolver: resolver, configEncryption: encryption },
57
+ });
58
+ await unsafeCreateEntityTable(stack.db, userEntity);
59
+ await unsafeCreateEntityTable(stack.db, userMfaEntity);
60
+ await unsafePushTables(stack.db, { configValuesTable });
61
+ });
62
+
63
+ afterAll(async () => {
64
+ await stack.cleanup();
65
+ });
66
+
67
+ // The second, independent encryption layer — same activation recipe as
68
+ // crypto-shredding/__tests__/forget-subject.integration.test.ts.
69
+ beforeEach(() => {
70
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
71
+ });
72
+
73
+ afterEach(() => {
74
+ resetPiiSubjectKmsForTests();
75
+ });
76
+
77
+ describe("with per-subject PII crypto-shredding active", () => {
78
+ test("enable-confirm succeeds and persists both totpSecret and recoveryCodes", async () => {
79
+ const user = createTestUser({ id: "pii-enable-1", roles: ["User"] });
80
+
81
+ const start = await stack.http.writeOk<{
82
+ setupToken: string;
83
+ otpauthUri: string;
84
+ recoveryCodes: string[];
85
+ }>(AuthMfaHandlers.enableStart, { accountLabel: "pii-enable@example.com" }, user);
86
+
87
+ const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
88
+ const secret = base32Decode(secretParam);
89
+
90
+ const confirmed = await stack.http.writeOk<{ enabled: boolean }>(
91
+ AuthMfaHandlers.enableConfirm,
92
+ { setupToken: start.setupToken, code: currentTotpCode(secret) },
93
+ user,
94
+ );
95
+ expect(confirmed.enabled).toBe(true);
96
+
97
+ // recoveryCodes round-trips through both encryption layers correctly —
98
+ // a recovery code (not just the TOTP code) still authenticates.
99
+ const disableRes = await stack.http.writeOk<{ disabled: boolean }>(
100
+ AuthMfaHandlers.disable,
101
+ { code: start.recoveryCodes[0] },
102
+ user,
103
+ );
104
+ expect(disableRes.disabled).toBe(true);
105
+ });
106
+
107
+ test("regenerate-recovery re-encrypts the new codes without crashing", async () => {
108
+ const user = createTestUser({ id: "pii-regen-1", roles: ["User"] });
109
+
110
+ const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
111
+ AuthMfaHandlers.enableStart,
112
+ { accountLabel: "pii-regen@example.com" },
113
+ user,
114
+ );
115
+ const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
116
+ const secret = base32Decode(secretParam);
117
+ await stack.http.writeOk(
118
+ AuthMfaHandlers.enableConfirm,
119
+ { setupToken: start.setupToken, code: currentTotpCode(secret) },
120
+ user,
121
+ );
122
+
123
+ const regenerated = await stack.http.writeOk<{ recoveryCodes: string[] }>(
124
+ AuthMfaHandlers.regenerateRecovery,
125
+ { code: currentTotpCode(secret) },
126
+ user,
127
+ );
128
+ expect(regenerated.recoveryCodes).toHaveLength(8);
129
+
130
+ const disableRes = await stack.http.writeOk<{ disabled: boolean }>(
131
+ AuthMfaHandlers.disable,
132
+ { code: regenerated.recoveryCodes[0] },
133
+ user,
134
+ );
135
+ expect(disableRes.disabled).toBe(true);
136
+ });
137
+ });
@@ -15,6 +15,10 @@ export const AuthMfaHandlers = {
15
15
  verify: "auth-mfa:write:verify",
16
16
  } as const;
17
17
 
18
+ export const AuthMfaQueries = {
19
+ status: "auth-mfa:query:user-mfa:status",
20
+ } as const;
21
+
18
22
  export const MFA_SETUP_TOKEN_TTL_MINUTES = 10;
19
23
  export const MFA_CHALLENGE_TOKEN_TTL_MINUTES = 10;
20
24
  export const MFA_VERIFY_MAX_ATTEMPTS = 5;
@@ -1,6 +1,7 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
4
5
  import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
5
6
 
6
7
  export type UserMfaRow = {
@@ -17,10 +18,14 @@ const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
17
18
  entityName: "user-mfa",
18
19
  });
19
20
 
20
- // `totpSecret` is `encrypted: true` — a raw fetchOne would return ciphertext,
21
- // not the plaintext base32 secret. fetchOne only locates the row's id (no
21
+ // `totpSecret`/`recoveryCodes` are `encrypted: true` — a raw fetchOne would
22
+ // return ciphertext, not plaintext. fetchOne only locates the row's id (no
22
23
  // decryption needed for that); executor.detail() does the real, decrypting
23
- // read the same way every other entity handler does.
24
+ // read the same way every other entity handler does. `recoveryCodes` is
25
+ // stored as a JSON string (schema/user-mfa.ts — both encryption layers need
26
+ // a string value) — parsed back into `{ hashes }` here, once, for every
27
+ // caller instead of each handler re-parsing it. A parse failure here means
28
+ // stored data is corrupt (post-decrypt, not user input) — fail loud.
24
29
  export async function findUserMfaRow(db: TenantDb, user: SessionUser): Promise<UserMfaRow | null> {
25
30
  const idLookup = await fetchOne<{ id: string }>(db, userMfaTable, {
26
31
  userId: user.id,
@@ -28,7 +33,13 @@ export async function findUserMfaRow(db: TenantDb, user: SessionUser): Promise<U
28
33
  });
29
34
  if (!idLookup) return null;
30
35
  const detail = await executor.detail({ id: idLookup.id }, user, db);
36
+ if (!detail) return null;
31
37
  // @cast-boundary engine-payload — executor.detail returns a decrypted,
32
38
  // untyped Record; the userMfa entity's own field shape narrows it.
33
- return detail as UserMfaRow | null;
39
+ const row = detail as Omit<UserMfaRow, "recoveryCodes"> & { recoveryCodes: string };
40
+ const recoveryCodes = parseJsonOrThrow<{ hashes: readonly string[] }>(
41
+ row.recoveryCodes,
42
+ "user-mfa.recoveryCodes",
43
+ );
44
+ return { ...row, recoveryCodes };
34
45
  }
@@ -6,6 +6,7 @@ import { createEnableConfirmHandler } from "./handlers/enable-confirm.write";
6
6
  import { createEnableStartHandler } from "./handlers/enable-start.write";
7
7
  import { mfaReencryptJob } from "./handlers/reencrypt.job";
8
8
  import { createRegenerateRecoveryHandler } from "./handlers/regenerate-recovery.write";
9
+ import { mfaStatusQuery } from "./handlers/status.query";
9
10
  import { createMfaVerifyHandler } from "./handlers/verify.write";
10
11
  import { AUTH_MFA_FEATURE_I18N } from "./i18n";
11
12
  import { createMfaStatusChecker, type MfaStatusChecker } from "./mfa-status-checker";
@@ -129,6 +130,10 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
129
130
  ),
130
131
  };
131
132
 
133
+ const queries = {
134
+ status: r.queryHandler(mfaStatusQuery),
135
+ };
136
+
132
137
  // No late-bind needed (unlike sharedRevoker) — this checker only needs
133
138
  // the HandlerContext the CALLER already has (login.write.ts runs it
134
139
  // from inside its own dispatcher call), not a raw db handle assembled
@@ -141,6 +146,6 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
141
146
  revokeAllOtherSessions = revoker;
142
147
  };
143
148
 
144
- return { handlers, bindRevokeAllOtherSessions, checkMfaStatus };
149
+ return { handlers, queries, bindRevokeAllOtherSessions, checkMfaStatus };
145
150
  });
146
151
  }
@@ -46,7 +46,9 @@ export function createEnableConfirmHandler(opts: EnableConfirmOptions) {
46
46
  {
47
47
  userId: event.user.id,
48
48
  totpSecret: verify.payload.totpSecretBase32,
49
- recoveryCodes: { hashes: verify.payload.recoveryCodeHashes },
49
+ // Stored as a JSON string — see schema/user-mfa.ts (recoveryCodes
50
+ // is an encrypted+userOwned text field, both layers need a string).
51
+ recoveryCodes: JSON.stringify({ hashes: verify.payload.recoveryCodeHashes }),
50
52
  enabledAt: Temporal.Now.instant(),
51
53
  lastUsedAt: null,
52
54
  },
@@ -1,12 +1,20 @@
1
- // KEK-rotation job for userMfa.totpSecret (entity-field encryption, same
2
- // MasterKeyProvider as secrets/config — see schema/user-mfa.ts). Modeled
3
- // directly on config/handlers/reencrypt.job.ts: entity-field encryption
4
- // stores a StoredEnvelope JSON string per value (no separate kekVersion
5
- // column, unlike secrets' DEK-wrap model), so rotation means decrypt under
6
- // the OLD key, then let the executor's own write-path re-encrypt under
7
- // provider.currentVersion() — the same auto-encrypt every enable-confirm
8
- // write already goes through (see enable-confirm.write.ts: it passes
9
- // PLAINTEXT to executor.create, never a manually-built envelope).
1
+ // KEK-rotation job for userMfa.totpSecret + recoveryCodes (entity-field
2
+ // encryption, same MasterKeyProvider as secrets/config — see
3
+ // schema/user-mfa.ts). Modeled directly on config/handlers/reencrypt.job.ts:
4
+ // entity-field encryption stores a StoredEnvelope JSON string per value (no
5
+ // separate kekVersion column, unlike secrets' DEK-wrap model), so rotation
6
+ // means decrypt under the OLD key, then let the executor's own write-path
7
+ // re-encrypt under provider.currentVersion() — the same auto-encrypt every
8
+ // enable-confirm write already goes through (see enable-confirm.write.ts: it
9
+ // passes PLAINTEXT to executor.create, never a manually-built envelope).
10
+ //
11
+ // Both fields also carry `userOwned` — the executor's own encryptForStorage
12
+ // wraps them PII(envelope(plaintext)) (see event-store-executor-context.ts).
13
+ // This job reads raw rows via selectMany (bypassing the executor's
14
+ // decryptForRead), so it must peel that PII layer itself before the envelope
15
+ // cipher ever sees the value, mirroring decryptForRead's PII-then-envelope
16
+ // order — an envelope decrypt on a still-PII-wrapped string throws
17
+ // "legacy single-key format" instead of rotating anything.
10
18
  //
11
19
  // Idempotent: re-running skips rows already on the current kekVersion.
12
20
  // Every write is a normal executor.update — after a full run even a
@@ -16,6 +24,11 @@
16
24
  // projection instead of appending a real `.updated` event).
17
25
 
18
26
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
27
+ import {
28
+ collectPiiSubjectFields,
29
+ configuredPiiSubjectKms,
30
+ decryptPiiFieldValues,
31
+ } from "@cosmicdrift/kumiko-framework/crypto";
19
32
  import {
20
33
  configuredEntityFieldEncryption,
21
34
  createEventStoreExecutor,
@@ -37,6 +50,8 @@ const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
37
50
  entityName: "user-mfa",
38
51
  });
39
52
 
53
+ const piiFieldNames = collectPiiSubjectFields(userMfaEntity);
54
+
40
55
  export type MfaReencryptJobPayload = {
41
56
  readonly batchSize?: number;
42
57
  readonly maxDurationMs?: number;
@@ -91,6 +106,9 @@ export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<vo
91
106
  });
92
107
  }
93
108
  const db = ctx.db as DbConnection; // @cast-boundary db-operator
109
+ // Undefined = per-subject crypto-shredding isn't configured for this app —
110
+ // both fields are then plain envelope strings, no PII layer to peel.
111
+ const piiKms = configuredPiiSubjectKms();
94
112
 
95
113
  const batchSize = payload.batchSize ?? DEFAULT_BATCH_SIZE;
96
114
  const maxFailures = payload.maxFailures ?? DEFAULT_MAX_FAILURES;
@@ -113,6 +131,7 @@ export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<vo
113
131
  tenantId: string;
114
132
  version: number;
115
133
  totpSecret: string;
134
+ recoveryCodes: string;
116
135
  };
117
136
 
118
137
  let alreadyCurrent = 0;
@@ -131,22 +150,43 @@ export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<vo
131
150
  }
132
151
 
133
152
  async function migrateRow(row: UserMfaRow): Promise<"migrated" | "skipped" | "failed"> {
134
- if (!needsReencrypt(row.totpSecret, targetVersion)) {
153
+ const tenantId = row.tenantId as TenantId; // @cast-boundary db-row
154
+
155
+ // Peel the PII layer first (if active) so needsReencrypt/cipher.decrypt
156
+ // below only ever see envelope strings, same order as decryptForRead.
157
+ let envelopeValues: { totpSecret: string; recoveryCodes: string } = row;
158
+ if (piiKms) {
159
+ const unwrapped = await decryptPiiFieldValues(row, piiFieldNames, piiKms, {
160
+ requestId: "auth-mfa-reencrypt-job",
161
+ tenantId,
162
+ });
163
+ envelopeValues = unwrapped as { totpSecret: string; recoveryCodes: string }; // @cast-boundary engine-payload
164
+ }
165
+
166
+ const totpNeedsRotation = needsReencrypt(envelopeValues.totpSecret, targetVersion);
167
+ const recoveryNeedsRotation = needsReencrypt(envelopeValues.recoveryCodes, targetVersion);
168
+ if (!totpNeedsRotation && !recoveryNeedsRotation) {
135
169
  alreadyCurrent++;
136
170
  return "skipped";
137
171
  }
138
172
 
139
- const tenantId = row.tenantId as TenantId; // @cast-boundary db-row
140
173
  // decrypt failure (missing legacy key, unknown kekVersion, tamper)
141
174
  // throws → counted as failed via onRowError; the row stays untouched.
142
- const plaintext = await cipher.decrypt(row.totpSecret, { tenantId });
175
+ const changes: Record<string, string> = {};
176
+ if (totpNeedsRotation) {
177
+ changes["totpSecret"] = await cipher.decrypt(envelopeValues.totpSecret, { tenantId });
178
+ }
179
+ if (recoveryNeedsRotation) {
180
+ changes["recoveryCodes"] = await cipher.decrypt(envelopeValues.recoveryCodes, { tenantId });
181
+ }
143
182
 
144
183
  const actor: SessionUser = { id: "system", tenantId, roles: SYSTEM_ROLES };
145
184
  // PLAINTEXT here, not a manually-built envelope — the executor's own
146
185
  // write-path (encryptForStorage) re-encrypts under the CURRENT
147
- // injected cipher/KEK, exactly like every other write to this field.
186
+ // injected cipher/KEK (and re-wraps the PII layer under the same,
187
+ // unrotated subject DEK), exactly like every other write to this field.
148
188
  const result = await executor.update(
149
- { id: row.id, version: row.version, changes: { totpSecret: plaintext } },
189
+ { id: row.id, version: row.version, changes },
150
190
  actor,
151
191
  tdbFor(tenantId),
152
192
  );
@@ -44,7 +44,7 @@ export function createRegenerateRecoveryHandler(opts: RegenerateRecoveryOptions)
44
44
  {
45
45
  id: row.id,
46
46
  version: row.version,
47
- changes: { recoveryCodes: { hashes: newHashes } },
47
+ changes: { recoveryCodes: JSON.stringify({ hashes: newHashes }) },
48
48
  },
49
49
  event.user,
50
50
  ctx.db,
@@ -0,0 +1,23 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { z } from "zod";
4
+ import { userMfaTable } from "../schema/user-mfa";
5
+
6
+ // "Is MFA enabled for me" — the one thing a settings screen needs before it
7
+ // can decide whether to show the enrollment flow or the disable/regenerate
8
+ // actions. No client-side signal (session/JWT) carries this today, so
9
+ // without this query every app builds its own row-existence check via a
10
+ // side channel. Plain fetchOne — no need for the entity's decrypting
11
+ // executor.detail() (see db/queries.ts's findUserMfaRow), existence is all
12
+ // the caller wants.
13
+ export const mfaStatusQuery = defineQueryHandler({
14
+ name: "user-mfa:status",
15
+ schema: z.object({}),
16
+ access: { openToAll: true },
17
+ handler: async (query, ctx) => {
18
+ const row = await fetchOne<{ id: string }>(ctx.db, userMfaTable, {
19
+ userId: query.user.id,
20
+ });
21
+ return { enabled: row !== undefined };
22
+ },
23
+ });
@@ -94,7 +94,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
94
94
  {
95
95
  id: row.id,
96
96
  version: row.version,
97
- changes: { recoveryCodes: { hashes: verify.remainingHashes } },
97
+ changes: { recoveryCodes: JSON.stringify({ hashes: verify.remainingHashes }) },
98
98
  },
99
99
  scopedUser,
100
100
  scopedDb,
@@ -1,6 +1,11 @@
1
1
  export { base32Decode } from "./base32";
2
2
  export { type MfaRequiredPolicy, mfaRequiredConfigHandle } from "./config";
3
- export { AUTH_MFA_FEATURE, AuthMfaHandlers, MFA_ENABLE_SCREEN_ID } from "./constants";
3
+ export {
4
+ AUTH_MFA_FEATURE,
5
+ AuthMfaHandlers,
6
+ AuthMfaQueries,
7
+ MFA_ENABLE_SCREEN_ID,
8
+ } from "./constants";
4
9
  export type {
5
10
  AuthMfaFeatureOptions,
6
11
  BindMfaRevokeAllOtherSessions,
@@ -1,7 +1,6 @@
1
1
  import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
2
2
  import {
3
3
  createEntity,
4
- createJsonbField,
5
4
  createTextField,
6
5
  createTimestampField,
7
6
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -27,10 +26,18 @@ export const userMfaEntity = createEntity({
27
26
  encrypted: true,
28
27
  userOwned: { ownerField: "userId" },
29
28
  }),
30
- // { hashes: string[] } argon2id hashes of unredeemed recovery codes.
29
+ // JSON-stringified `{ hashes: string[] }` (argon2id hashes of unredeemed
30
+ // recovery codes) — both encryption layers (entity-field envelope +
31
+ // per-subject crypto-shred, same as totpSecret above) require a string
32
+ // value, so callers JSON.stringify on write and JSON.parse on read
33
+ // (see db/queries.ts's findUserMfaRow) rather than storing jsonb here.
31
34
  // A redeemed code's hash is removed from the array (see disable/regen
32
35
  // handlers), so array length also IS the remaining-codes count.
33
- recoveryCodes: createJsonbField({ userOwned: { ownerField: "userId" } }),
36
+ recoveryCodes: createTextField({
37
+ required: true,
38
+ encrypted: true,
39
+ userOwned: { ownerField: "userId" },
40
+ }),
34
41
  enabledAt: createTimestampField({ required: true }),
35
42
  lastUsedAt: createTimestampField(),
36
43
  },
@@ -35,6 +35,25 @@ export const defaultTranslations: TranslationsByLocale = {
35
35
  "auth.mfa.enable.cancel": "Abbrechen",
36
36
  "auth.mfa.enable.confirm": "Aktivieren",
37
37
  "auth.mfa.enable.success": "Zwei-Faktor-Authentifizierung ist jetzt aktiv.",
38
+ "auth.mfa.disable.title": "Zwei-Faktor-Authentifizierung deaktivieren",
39
+ "auth.mfa.disable.description":
40
+ "Bestätige mit einem Code aus deiner Authenticator-App oder einem Recovery-Code. Dein Konto ist danach nur noch durch dein Passwort geschützt.",
41
+ "auth.mfa.disable.code": "Code aus der Authenticator-App oder Recovery-Code",
42
+ "auth.mfa.disable.confirm": "Deaktivieren",
43
+ "auth.mfa.disable.cancel": "Abbrechen",
44
+ "auth.mfa.disable.trigger": "Zwei-Faktor-Authentifizierung deaktivieren",
45
+ "auth.mfa.regenerate.title": "Neue Recovery-Codes erzeugen",
46
+ "auth.mfa.regenerate.description":
47
+ "Bestätige mit einem Code aus deiner Authenticator-App. Alle bisherigen Recovery-Codes werden sofort ungültig.",
48
+ "auth.mfa.regenerate.code": "Code aus der Authenticator-App",
49
+ "auth.mfa.regenerate.confirm": "Neu erzeugen",
50
+ "auth.mfa.regenerate.cancel": "Abbrechen",
51
+ "auth.mfa.regenerate.trigger": "Neue Recovery-Codes erzeugen",
52
+ "auth.mfa.regenerate.newCodesTitle": "Deine neuen Recovery-Codes",
53
+ "auth.mfa.regenerate.newCodesHint":
54
+ "Speichere diese Codes an einem sicheren Ort. Die alten Codes funktionieren ab sofort nicht mehr.",
55
+ "auth.mfa.regenerate.acknowledge": "Ich habe die neuen Recovery-Codes gespeichert.",
56
+ "auth.mfa.regenerate.done": "Fertig",
38
57
  },
39
58
  en: {
40
59
  "screen:auth-mfa-enable.title": "Two-factor authentication",
@@ -65,6 +84,25 @@ export const defaultTranslations: TranslationsByLocale = {
65
84
  "auth.mfa.enable.cancel": "Cancel",
66
85
  "auth.mfa.enable.confirm": "Enable",
67
86
  "auth.mfa.enable.success": "Two-factor authentication is now enabled.",
87
+ "auth.mfa.disable.title": "Disable two-factor authentication",
88
+ "auth.mfa.disable.description":
89
+ "Confirm with a code from your authenticator app or a recovery code. Your account will then be protected by your password alone.",
90
+ "auth.mfa.disable.code": "Code from your authenticator app or a recovery code",
91
+ "auth.mfa.disable.confirm": "Disable",
92
+ "auth.mfa.disable.cancel": "Cancel",
93
+ "auth.mfa.disable.trigger": "Disable two-factor authentication",
94
+ "auth.mfa.regenerate.title": "Generate new recovery codes",
95
+ "auth.mfa.regenerate.description":
96
+ "Confirm with a code from your authenticator app. All existing recovery codes stop working immediately.",
97
+ "auth.mfa.regenerate.code": "Code from your authenticator app",
98
+ "auth.mfa.regenerate.confirm": "Generate new codes",
99
+ "auth.mfa.regenerate.cancel": "Cancel",
100
+ "auth.mfa.regenerate.trigger": "Generate new recovery codes",
101
+ "auth.mfa.regenerate.newCodesTitle": "Your new recovery codes",
102
+ "auth.mfa.regenerate.newCodesHint":
103
+ "Save these codes somewhere safe. The old codes stop working immediately.",
104
+ "auth.mfa.regenerate.acknowledge": "I've saved my new recovery codes.",
105
+ "auth.mfa.regenerate.done": "Done",
68
106
  },
69
107
  };
70
108
 
@@ -9,7 +9,14 @@ export { authMfaClient } from "./client-plugin";
9
9
  export { defaultTranslations, mergeTranslations } from "./i18n";
10
10
  export type { MfaVerifyResult } from "./mfa-client";
11
11
  export { verifyMfaChallenge } from "./mfa-client";
12
+ export type { MfaDisableDialogProps } from "./mfa-disable-dialog";
13
+ export { MfaDisableDialog } from "./mfa-disable-dialog";
12
14
  export type { MfaEnableScreenProps } from "./mfa-enable-screen";
13
15
  export { MfaEnableScreen } from "./mfa-enable-screen";
16
+ export { mfaManageErrorKey } from "./mfa-error-keys";
17
+ export type { MfaRecoveryCodesRevealProps } from "./mfa-recovery-codes-reveal";
18
+ export { MfaRecoveryCodesReveal } from "./mfa-recovery-codes-reveal";
19
+ export type { MfaRegenerateRecoveryDialogProps } from "./mfa-regenerate-recovery-dialog";
20
+ export { MfaRegenerateRecoveryDialog } from "./mfa-regenerate-recovery-dialog";
14
21
  export type { MfaVerifyScreenProps } from "./mfa-verify-screen";
15
22
  export { MfaVerifyScreen } from "./mfa-verify-screen";
@@ -0,0 +1,70 @@
1
+ // @runtime client
2
+ // MfaDisableDialog — confirm-with-code before turning MFA off. Uses the
3
+ // generic Dialog primitive (unlike MfaEnableScreen/MfaRegenerateRecovery-
4
+ // Dialog): disable is single-step, no state to preserve after confirm, so
5
+ // it doesn't hit the "Dialog always closes after onConfirm" problem that
6
+ // ruled Dialog out for the multi-step enable/regenerate flows. Error
7
+ // reporting still has to go through the parent (onError) rather than an
8
+ // inline Banner, because Dialog closes in a `finally` regardless of
9
+ // whether onConfirm's write succeeded — see profile-screen.tsx's
10
+ // delete-account dialog for the same convention (StatusBanner rendered
11
+ // by the caller, not the dialog).
12
+
13
+ import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
14
+ import { type ReactNode, useState } from "react";
15
+ import { AuthMfaHandlers } from "../constants";
16
+ import { mfaManageErrorKey } from "./mfa-error-keys";
17
+
18
+ export type MfaDisableDialogProps = {
19
+ readonly open: boolean;
20
+ readonly onOpenChange: (open: boolean) => void;
21
+ readonly onDisabled: () => void;
22
+ readonly onError: (i18nKey: string) => void;
23
+ };
24
+
25
+ export function MfaDisableDialog({
26
+ open,
27
+ onOpenChange,
28
+ onDisabled,
29
+ onError,
30
+ }: MfaDisableDialogProps): ReactNode {
31
+ const t = useTranslation();
32
+ const { Dialog, Field, Input } = usePrimitives();
33
+ const dispatcher = useDispatcher();
34
+ const [code, setCode] = useState("");
35
+
36
+ const confirm = async (): Promise<void> => {
37
+ const res = await dispatcher.write<{ disabled: boolean }>(AuthMfaHandlers.disable, { code });
38
+ setCode("");
39
+ if (!res.isSuccess) {
40
+ onError(mfaManageErrorKey(res.error.code));
41
+ return;
42
+ }
43
+ onDisabled();
44
+ };
45
+
46
+ return (
47
+ <Dialog
48
+ open={open}
49
+ onOpenChange={onOpenChange}
50
+ variant="danger"
51
+ title={t("auth.mfa.disable.title")}
52
+ description={t("auth.mfa.disable.description")}
53
+ confirmLabel={t("auth.mfa.disable.confirm")}
54
+ cancelLabel={t("auth.mfa.disable.cancel")}
55
+ onConfirm={confirm}
56
+ testId="mfa-disable-dialog"
57
+ >
58
+ <Field id="mfa-disable-code" label={t("auth.mfa.disable.code")} required>
59
+ <Input
60
+ kind="text"
61
+ id="mfa-disable-code"
62
+ name="mfa-disable-code"
63
+ value={code}
64
+ onChange={setCode}
65
+ autoComplete="one-time-code"
66
+ />
67
+ </Field>
68
+ </Dialog>
69
+ );
70
+ }
@@ -14,6 +14,7 @@ import { type ReactNode, useState } from "react";
14
14
  // kumiko-lint-ignore cross-feature-import client-only hook, the feature's server barrel has no web/ re-export
15
15
  import { useSession } from "../../auth-email-password/web";
16
16
  import { AuthMfaHandlers } from "../constants";
17
+ import { mfaManageErrorKey } from "./mfa-error-keys";
17
18
 
18
19
  type EnableStartResponse = {
19
20
  readonly setupToken: string;
@@ -35,9 +36,20 @@ function extractSecret(otpauthUri: string): string {
35
36
  return new URLSearchParams(query).get("secret") ?? "";
36
37
  }
37
38
 
38
- export type MfaEnableScreenProps = { readonly embedded?: boolean };
39
+ export type MfaEnableScreenProps = {
40
+ readonly embedded?: boolean;
41
+ // Fired once enable-confirm succeeds — MfaEnableScreen has no query of its
42
+ // own to invalidate, so a host screen composing it alongside other MFA
43
+ // state (e.g. a status query gating which section renders) needs this to
44
+ // know when to refetch/swap views instead of leaving the success banner
45
+ // as a dead end.
46
+ readonly onEnabled?: () => void;
47
+ };
39
48
 
40
- export function MfaEnableScreen({ embedded = false }: MfaEnableScreenProps = {}): ReactNode {
49
+ export function MfaEnableScreen({
50
+ embedded = false,
51
+ onEnabled,
52
+ }: MfaEnableScreenProps = {}): ReactNode {
41
53
  const t = useTranslation();
42
54
  const { Button, Banner, Field, Input, Section, Heading } = usePrimitives();
43
55
  const dispatcher = useDispatcher();
@@ -88,6 +100,7 @@ export function MfaEnableScreen({ embedded = false }: MfaEnableScreenProps = {})
88
100
  }
89
101
  setEnabled(true);
90
102
  setSetup(null);
103
+ onEnabled?.();
91
104
  };
92
105
 
93
106
  const content = (
@@ -95,7 +108,7 @@ export function MfaEnableScreen({ embedded = false }: MfaEnableScreenProps = {})
95
108
  <Heading>{t("auth.mfa.enable.title")}</Heading>
96
109
 
97
110
  {enabled && <Banner variant="info">{t("auth.mfa.enable.success")}</Banner>}
98
- {error !== null && <Banner variant="error">{t(`auth.mfa.errors.${error}`)}</Banner>}
111
+ {error !== null && <Banner variant="error">{t(mfaManageErrorKey(error))}</Banner>}
99
112
 
100
113
  {!setup && !enabled && (
101
114
  <Section
@@ -0,0 +1,25 @@
1
+ // @runtime client
2
+ // Shared error-code → i18n-key mapping for the account-management write
3
+ // handlers (enable-confirm/disable/regenerate-recovery) — distinct from
4
+ // mfa-verify-screen.tsx's own mapper, which covers the login-challenge
5
+ // error surface (challenge_expired/rate_limited) that these handlers never
6
+ // return. Centralized so enable/disable/regenerate stay in sync instead of
7
+ // each re-deriving the key ad hoc (enable-screen used to template-string
8
+ // the raw snake_case code straight into the key, which never matched the
9
+ // camelCase keys actually registered below).
10
+ export function mfaManageErrorKey(code: string): string {
11
+ switch (code) {
12
+ case "invalid_totp_code":
13
+ return "auth.mfa.errors.invalidCode";
14
+ case "invalid_recovery_code":
15
+ return "auth.mfa.errors.invalidRecoveryCode";
16
+ case "mfa_already_enabled":
17
+ return "auth.mfa.errors.mfaAlreadyEnabled";
18
+ case "mfa_not_enabled":
19
+ return "auth.mfa.errors.mfaNotEnabled";
20
+ case "invalid_setup_token":
21
+ return "auth.mfa.errors.invalidSetupToken";
22
+ default:
23
+ return "auth.mfa.errors.verifyFailed";
24
+ }
25
+ }
@@ -0,0 +1,53 @@
1
+ // @runtime client
2
+ // MfaRecoveryCodesReveal — one-time display of a fresh recovery-code set,
3
+ // with an acknowledge-then-dismiss gate (same UX as the codes block in
4
+ // MfaEnableScreen). Standalone so MfaRegenerateRecoveryDialog's caller can
5
+ // render it after the dialog closes — the codes only exist in memory for
6
+ // this one render, never persisted anywhere the app could show them again.
7
+
8
+ import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
9
+ import { type ReactNode, useState } from "react";
10
+
11
+ export type MfaRecoveryCodesRevealProps = {
12
+ readonly codes: readonly string[];
13
+ readonly onDismiss: () => void;
14
+ };
15
+
16
+ export function MfaRecoveryCodesReveal({
17
+ codes,
18
+ onDismiss,
19
+ }: MfaRecoveryCodesRevealProps): ReactNode {
20
+ const t = useTranslation();
21
+ const { Button, Field, Input, Section } = usePrimitives();
22
+ const [acknowledged, setAcknowledged] = useState(false);
23
+
24
+ return (
25
+ <Section
26
+ testId="mfa-regenerate-reveal"
27
+ actions={
28
+ <Button variant="primary" onClick={onDismiss} disabled={!acknowledged}>
29
+ {t("auth.mfa.regenerate.done")}
30
+ </Button>
31
+ }
32
+ >
33
+ <div className="flex flex-col items-center gap-2 text-center">
34
+ <span className="text-sm font-semibold">{t("auth.mfa.regenerate.newCodesTitle")}</span>
35
+ <span className="text-xs text-muted-foreground">
36
+ {t("auth.mfa.regenerate.newCodesHint")}
37
+ </span>
38
+ <code className="inline-block whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm">
39
+ {codes.join("\n")}
40
+ </code>
41
+ </div>
42
+ <Field id="mfa-regenerate-ack" label={t("auth.mfa.regenerate.acknowledge")}>
43
+ <Input
44
+ kind="boolean"
45
+ id="mfa-regenerate-ack"
46
+ name="mfa-regenerate-ack"
47
+ value={acknowledged}
48
+ onChange={setAcknowledged}
49
+ />
50
+ </Field>
51
+ </Section>
52
+ );
53
+ }
@@ -0,0 +1,69 @@
1
+ // @runtime client
2
+ // MfaRegenerateRecoveryDialog — confirm-with-code, then the caller reveals
3
+ // the new codes. The reveal itself can't live inside this Dialog: Dialog
4
+ // closes in a `finally` right after onConfirm resolves (see profile-screen.
5
+ // tsx's delete-account dialog for the same constraint), so there's no way
6
+ // to keep it open to show the result. onRegenerated hands the new codes to
7
+ // the caller, which is expected to render them via MfaRecoveryCodesReveal
8
+ // (or equivalent) once the dialog has closed.
9
+
10
+ import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
11
+ import { type ReactNode, useState } from "react";
12
+ import { AuthMfaHandlers } from "../constants";
13
+ import { mfaManageErrorKey } from "./mfa-error-keys";
14
+
15
+ export type MfaRegenerateRecoveryDialogProps = {
16
+ readonly open: boolean;
17
+ readonly onOpenChange: (open: boolean) => void;
18
+ readonly onRegenerated: (codes: readonly string[]) => void;
19
+ readonly onError: (i18nKey: string) => void;
20
+ };
21
+
22
+ export function MfaRegenerateRecoveryDialog({
23
+ open,
24
+ onOpenChange,
25
+ onRegenerated,
26
+ onError,
27
+ }: MfaRegenerateRecoveryDialogProps): ReactNode {
28
+ const t = useTranslation();
29
+ const { Dialog, Field, Input } = usePrimitives();
30
+ const dispatcher = useDispatcher();
31
+ const [code, setCode] = useState("");
32
+
33
+ const confirm = async (): Promise<void> => {
34
+ const res = await dispatcher.write<{ recoveryCodes: readonly string[] }>(
35
+ AuthMfaHandlers.regenerateRecovery,
36
+ { code },
37
+ );
38
+ setCode("");
39
+ if (!res.isSuccess) {
40
+ onError(mfaManageErrorKey(res.error.code));
41
+ return;
42
+ }
43
+ onRegenerated(res.data.recoveryCodes);
44
+ };
45
+
46
+ return (
47
+ <Dialog
48
+ open={open}
49
+ onOpenChange={onOpenChange}
50
+ title={t("auth.mfa.regenerate.title")}
51
+ description={t("auth.mfa.regenerate.description")}
52
+ confirmLabel={t("auth.mfa.regenerate.confirm")}
53
+ cancelLabel={t("auth.mfa.regenerate.cancel")}
54
+ onConfirm={confirm}
55
+ testId="mfa-regenerate-recovery-dialog"
56
+ >
57
+ <Field id="mfa-regenerate-code" label={t("auth.mfa.regenerate.code")} required>
58
+ <Input
59
+ kind="text"
60
+ id="mfa-regenerate-code"
61
+ name="mfa-regenerate-code"
62
+ value={code}
63
+ onChange={setCode}
64
+ autoComplete="one-time-code"
65
+ />
66
+ </Field>
67
+ </Dialog>
68
+ );
69
+ }