@cosmicdrift/kumiko-bundled-features 0.200.1 → 0.202.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 (29) hide show
  1. package/package.json +7 -7
  2. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +127 -0
  3. package/src/auth-email-password/handlers/invite-create.write.ts +9 -0
  4. package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +18 -6
  5. package/src/auth-mfa/feature.ts +69 -2
  6. package/src/auth-mfa/handlers/disable.write.ts +7 -0
  7. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +7 -0
  8. package/src/auth-mfa/handlers/enable-confirm.write.ts +7 -0
  9. package/src/auth-mfa/index.ts +4 -0
  10. package/src/auth-mfa/mfa-code-verifier.ts +33 -0
  11. package/src/jobs/web/__tests__/job-runs-screen.test.tsx +5 -3
  12. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +171 -4
  13. package/src/personal-access-tokens/constants.ts +8 -0
  14. package/src/personal-access-tokens/feature.ts +66 -2
  15. package/src/personal-access-tokens/handlers/create.write.ts +94 -36
  16. package/src/personal-access-tokens/index.ts +7 -2
  17. package/src/personal-access-tokens/revoke-for-user.ts +18 -0
  18. package/src/personal-access-tokens/web/i18n.ts +8 -0
  19. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +31 -0
  20. package/src/sessions/__tests__/role-rederivation.integration.test.ts +137 -0
  21. package/src/sessions/__tests__/sessions.integration.test.ts +3 -1
  22. package/src/sessions/session-callbacks.ts +44 -13
  23. package/src/sessions/session-checker-fail-open.test.ts +74 -0
  24. package/src/step-dispatcher/webhook-runner.ts +15 -0
  25. package/src/tenant/handlers/remove-member.write.ts +13 -12
  26. package/src/tenant/handlers/update-member-roles.write.ts +13 -12
  27. package/src/tenant/membership-roles.ts +7 -0
  28. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +23 -1
  29. package/src/user-data-rights/feature.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.200.1",
3
+ "version": "0.202.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>",
@@ -126,12 +126,12 @@
126
126
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
127
127
  },
128
128
  "dependencies": {
129
- "@cosmicdrift/kumiko-dispatcher-live": "0.200.1",
130
- "@cosmicdrift/kumiko-framework": "0.200.1",
131
- "@cosmicdrift/kumiko-headless": "0.200.1",
132
- "@cosmicdrift/kumiko-renderer": "0.200.1",
133
- "@cosmicdrift/kumiko-renderer-web": "0.200.1",
134
- "@cosmicdrift/kumiko-types": "0.200.1",
129
+ "@cosmicdrift/kumiko-dispatcher-live": "0.202.0",
130
+ "@cosmicdrift/kumiko-framework": "0.202.0",
131
+ "@cosmicdrift/kumiko-headless": "0.202.0",
132
+ "@cosmicdrift/kumiko-renderer": "0.202.0",
133
+ "@cosmicdrift/kumiko-renderer-web": "0.202.0",
134
+ "@cosmicdrift/kumiko-types": "0.202.0",
135
135
  "@mollie/api-client": "^4.5.0",
136
136
  "@node-rs/argon2": "^2.0.2",
137
137
  "@types/mailparser": "^3.4.6",
@@ -669,4 +669,131 @@ describe("privilege escalation via invite role", () => {
669
669
  )) as { role: string };
670
670
  expect(result.role).toBe("Editor");
671
671
  });
672
+
673
+ // Regression guard for the opt-in canAssignRole hook (#security): this
674
+ // stack's invite feature was mounted WITHOUT canAssignRole, so any
675
+ // non-reserved app-defined role must still go through unchanged — the
676
+ // hierarchy gate below is a separate, opt-in stack, not a default.
677
+ test("without canAssignRole configured, any non-reserved app-defined role is still assignable", async () => {
678
+ const result = (await stack.http.writeOk(
679
+ AuthHandlers.inviteCreate,
680
+ { email: "dpo-candidate@example.com", role: "DPO" },
681
+ aliceSession(),
682
+ )) as { role: string };
683
+ expect(result.role).toBe("DPO");
684
+ });
685
+ });
686
+
687
+ // canAssignRole is a role-hierarchy gate that must live in the app (roles are
688
+ // app-defined strings, not a framework concept) — a separate stack mounts the
689
+ // invite feature WITH the hook to prove it's actually enforced, distinct from
690
+ // the framework-reserved-role check covered above.
691
+ describe("invite-create role-hierarchy gate (opt-in canAssignRole)", () => {
692
+ let gateStack: TestStack;
693
+ let gateAliceId: string;
694
+ let gateTenantId: TenantId;
695
+ const gateTransport = createInMemoryTransport();
696
+
697
+ function gateAliceSession(): SessionUser {
698
+ return { id: gateAliceId, tenantId: gateTenantId, roles: ["Admin"] };
699
+ }
700
+
701
+ beforeAll(async () => {
702
+ gateStack = await setupTestStack({
703
+ features: [
704
+ createConfigFeature(),
705
+ createUserFeature(),
706
+ createTenantFeature(),
707
+ createTemplateResolverFeature(),
708
+ createRendererFoundationFeature(),
709
+ createDeliveryFeature(),
710
+ createRendererSimpleFeature(),
711
+ createChannelEmailFeature({
712
+ transport: gateTransport,
713
+ renderer: simpleRenderer,
714
+ resolveEmail: async () => "unused@test.local",
715
+ }),
716
+ createAuthEmailPasswordFeature({
717
+ invite: {
718
+ tokenTtlMinutes: 60,
719
+ appUrl: APP_ACCEPT_URL,
720
+ // Only "Admin" may grant "DPO" — everything else may only grant
721
+ // non-DPO roles. A minimal, deliberately app-shaped hierarchy;
722
+ // the framework itself knows nothing about "DPO".
723
+ canAssignRole: (inviterRoles, targetRole) =>
724
+ targetRole !== "DPO" || inviterRoles.includes("Admin"),
725
+ },
726
+ }),
727
+ ],
728
+ extraContext: (deps) => ({
729
+ ...createDeliveryTestContext(deps),
730
+ configResolver: createConfigResolver(),
731
+ }),
732
+ authConfig: {
733
+ membershipQuery: "tenant:query:memberships",
734
+ loginHandler: AuthHandlers.login,
735
+ invite: {
736
+ acceptHandler: AuthHandlers.inviteAccept,
737
+ acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
738
+ signupCompleteHandler: AuthHandlers.inviteSignupComplete,
739
+ },
740
+ },
741
+ });
742
+
743
+ await unsafeCreateEntityTable(gateStack.db, userEntity);
744
+ await unsafeCreateEntityTable(gateStack.db, tenantEntity);
745
+ await unsafeCreateEntityTable(gateStack.db, tenantInvitationEntity);
746
+ await unsafePushTables(gateStack.db, {
747
+ configValuesTable,
748
+ tenantMembershipsTable,
749
+ notificationPreferencesTable,
750
+ });
751
+
752
+ gateTenantId = newTenantId("gate");
753
+ await seedTenant(gateStack.db, {
754
+ id: gateTenantId,
755
+ key: `tenant-gate-${gateTenantId.slice(0, 8)}`,
756
+ name: "Tenant Gate",
757
+ });
758
+ ({ id: gateAliceId } = await seedUser(gateStack.db, {
759
+ email: "gate-alice@example.com",
760
+ displayName: "Gate Alice",
761
+ passwordHash: await hashPassword("gate-alice-pw-1234"),
762
+ emailVerified: true,
763
+ }));
764
+ await seedTenantMembership(gateStack.db, {
765
+ userId: gateAliceId,
766
+ tenantId: gateTenantId,
767
+ roles: ["Admin"],
768
+ });
769
+ });
770
+
771
+ afterAll(async () => {
772
+ await gateStack.cleanup();
773
+ });
774
+
775
+ test("canAssignRole → false blocks invite-create with a role-hierarchy error, no invitation persisted", async () => {
776
+ // TenantAdmin clears the handler's own access.admin gate but canAssignRole
777
+ // above only lets literal "Admin" grant "DPO" — the case this hook exists for.
778
+ const err = await gateStack.http.writeErr(
779
+ AuthHandlers.inviteCreate,
780
+ { email: "blocked-target@example.com", role: "DPO" },
781
+ { id: gateAliceId, tenantId: gateTenantId, roles: ["TenantAdmin"] },
782
+ );
783
+ expect(err.details).toMatchObject({ reason: "unassignable_membership_role", role: "DPO" });
784
+ const rows = await selectMany(gateStack.db, tenantInvitationsTable, {
785
+ email: "blocked-target@example.com",
786
+ });
787
+ expect(rows).toHaveLength(0);
788
+ expect(gateTransport.sent).toHaveLength(0);
789
+ });
790
+
791
+ test("canAssignRole → true allows invite-create through", async () => {
792
+ const result = (await gateStack.http.writeOk(
793
+ AuthHandlers.inviteCreate,
794
+ { email: "allowed-target@example.com", role: "DPO" },
795
+ gateAliceSession(),
796
+ )) as { role: string };
797
+ expect(result.role).toBe("DPO");
798
+ });
672
799
  });
@@ -33,6 +33,7 @@ import {
33
33
  import {
34
34
  findForbiddenMembershipRole,
35
35
  reservedMembershipRoleError,
36
+ unassignableMembershipRoleError,
36
37
  } from "../../tenant/membership-roles";
37
38
  import { AUTH_INVITE_DEFAULT_TTL_MINUTES } from "../constants";
38
39
  import type { AuthMailLocale } from "../email-templates";
@@ -64,6 +65,10 @@ export type InviteCreateOptions = {
64
65
  readonly appUrl: string;
65
66
  readonly appName?: string;
66
67
  readonly locale?: AuthMailLocale;
68
+ // Opt-in role-hierarchy gate. Roles are app-defined strings, not a framework
69
+ // concept, so the hierarchy itself must live in the app — this hook lets it
70
+ // plug in without the framework hardcoding any role names.
71
+ readonly canAssignRole?: (inviterRoles: readonly string[], targetRole: string) => boolean;
67
72
  };
68
73
 
69
74
  const executor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEntity, {
@@ -90,6 +95,10 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
90
95
  return writeFailure(reservedMembershipRoleError(forbiddenRole));
91
96
  }
92
97
 
98
+ if (opts.canAssignRole && !opts.canAssignRole(event.user.roles, event.payload.role)) {
99
+ return writeFailure(unassignableMembershipRoleError(event.payload.role));
100
+ }
101
+
93
102
  const email = event.payload.email.toLowerCase();
94
103
  const tenantId = event.user.tenantId;
95
104
  const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
@@ -30,6 +30,18 @@ import { currentTotpCode } from "../totp";
30
30
  // account — every OTHER live session must be signed out (stolen-session
31
31
  // defense), but the session that performed the change must survive.
32
32
 
33
+ // sessionChecker's "live" outcome can now carry re-derived roles as
34
+ // `{status: "live", roles: [...]}` instead of the bare string — these tests
35
+ // only care about liveness, not the exact role set, so unwrap the status.
36
+ async function checkerStatus(
37
+ callbacks: ReturnType<typeof createSessionCallbacks>,
38
+ sid: string,
39
+ userId: string,
40
+ ): Promise<string> {
41
+ const result = await callbacks.sessionChecker(sid, userId);
42
+ return typeof result === "string" ? result : result.status;
43
+ }
44
+
33
45
  let stack: TestStack;
34
46
  let sessionCallbacks: ReturnType<typeof createSessionCallbacks>;
35
47
 
@@ -94,8 +106,8 @@ describe("session auto-revoke on MFA state changes", () => {
94
106
  user,
95
107
  );
96
108
 
97
- expect(await sessionCallbacks.sessionChecker(currentSid, userId)).toBe("live");
98
- expect(await sessionCallbacks.sessionChecker(otherSid, userId)).toBe("revoked");
109
+ expect(await checkerStatus(sessionCallbacks, currentSid, userId)).toBe("live");
110
+ expect(await checkerStatus(sessionCallbacks, otherSid, userId)).toBe("revoked");
99
111
  });
100
112
 
101
113
  test("disable revokes every OTHER session but keeps the caller's", async () => {
@@ -130,8 +142,8 @@ describe("session auto-revoke on MFA state changes", () => {
130
142
  caller,
131
143
  );
132
144
 
133
- expect(await sessionCallbacks.sessionChecker(currentSid, userId)).toBe("live");
134
- expect(await sessionCallbacks.sessionChecker(otherSid, userId)).toBe("revoked");
145
+ expect(await checkerStatus(sessionCallbacks, currentSid, userId)).toBe("live");
146
+ expect(await checkerStatus(sessionCallbacks, otherSid, userId)).toBe("revoked");
135
147
  });
136
148
 
137
149
  test("a failed disable attempt does NOT revoke any session", async () => {
@@ -163,7 +175,7 @@ describe("session auto-revoke on MFA state changes", () => {
163
175
  const err = await stack.http.writeErr(AuthMfaHandlers.disable, { code: "000000" }, caller);
164
176
  expectErrorIncludes(err, "invalid_totp_code");
165
177
 
166
- expect(await sessionCallbacks.sessionChecker(currentSid, userId)).toBe("live");
167
- expect(await sessionCallbacks.sessionChecker(otherSid, userId)).toBe("live");
178
+ expect(await checkerStatus(sessionCallbacks, currentSid, userId)).toBe("live");
179
+ expect(await checkerStatus(sessionCallbacks, otherSid, userId)).toBe("live");
168
180
  });
169
181
  });
@@ -11,6 +11,7 @@ import { createRegenerateRecoveryHandler } from "./handlers/regenerate-recovery.
11
11
  import { mfaStatusQuery } from "./handlers/status.query";
12
12
  import { createMfaVerifyHandler } from "./handlers/verify.write";
13
13
  import { AUTH_MFA_FEATURE_I18N } from "./i18n";
14
+ import { createMfaCodeVerifier, type MfaCodeVerifier } from "./mfa-code-verifier";
14
15
  import { createMfaStatusChecker, type MfaStatusChecker } from "./mfa-status-checker";
15
16
  import { userMfaEntity } from "./schema/user-mfa";
16
17
 
@@ -70,6 +71,42 @@ export function mfaStatusCheckerFromFeature(
70
71
  return undefined;
71
72
  }
72
73
 
74
+ // Reads the eagerly-built `verifyMfaCode` off a mounted auth-mfa feature's
75
+ // exports — personal-access-tokens wires this in as its optional
76
+ // mfaVerifier (re-auth gate for minting a token). Same no-bind-setter
77
+ // reasoning as checkMfaStatus above.
78
+ export function mfaVerifierFromFeature(feature: FeatureDefinition): MfaCodeVerifier | undefined {
79
+ const exports = feature.exports;
80
+ if (exports && typeof exports === "object" && "verifyMfaCode" in exports) {
81
+ const { verifyMfaCode } = exports as { verifyMfaCode: unknown };
82
+ if (typeof verifyMfaCode === "function") {
83
+ // @cast-boundary exports-walk — feature.exports is untyped by design
84
+ return verifyMfaCode as MfaCodeVerifier;
85
+ }
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ export type BindRevokeAllPatTokens = (revoker: (userId: string) => Promise<number>) => void;
91
+
92
+ // Reads the late-bind setter off a mounted auth-mfa feature's exports —
93
+ // run{Prod,Dev}App call this once the personal-access-tokens feature (if
94
+ // mounted) has produced a concrete revoker, same wiring shape as
95
+ // bindMfaRevokeAllOtherSessionsFromFeature.
96
+ export function bindRevokeAllPatTokensFromFeature(
97
+ feature: FeatureDefinition,
98
+ ): BindRevokeAllPatTokens | undefined {
99
+ const exports = feature.exports;
100
+ if (exports && typeof exports === "object" && "bindRevokeAllPatTokens" in exports) {
101
+ const { bindRevokeAllPatTokens } = exports as { bindRevokeAllPatTokens: unknown };
102
+ if (typeof bindRevokeAllPatTokens === "function") {
103
+ // @cast-boundary exports-walk — feature.exports is untyped by design
104
+ return bindRevokeAllPatTokens as BindRevokeAllPatTokens;
105
+ }
106
+ }
107
+ return undefined;
108
+ }
109
+
73
110
  export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefinition {
74
111
  return defineFeature("auth-mfa", (r) => {
75
112
  r.describe(
@@ -120,6 +157,14 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
120
157
  const sharedRevoker = (userId: string, currentSid: string | undefined): Promise<number> =>
121
158
  revokeAllOtherSessions?.(userId, currentSid) ?? Promise.resolve(0);
122
159
 
160
+ // Same late-bind shape as sharedRevoker above, for the personal-access-
161
+ // tokens feature (if mounted). Wired to enable-confirm(-preauth)/disable
162
+ // only — regenerate-recovery doesn't change enrollment state, so it's
163
+ // out of scope for the "MFA-enable/disable" finding this mirrors.
164
+ let revokeAllPatTokens: ((userId: string) => Promise<number>) | undefined;
165
+ const sharedPatRevoker = (userId: string): Promise<number> =>
166
+ revokeAllPatTokens?.(userId) ?? Promise.resolve(0);
167
+
123
168
  const handlers = {
124
169
  enableStart: r.writeHandler(
125
170
  createEnableStartHandler({ setupTokenSecret: opts.setupTokenSecret, issuer: opts.issuer }),
@@ -135,15 +180,22 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
135
180
  createEnableConfirmHandler({
136
181
  setupTokenSecret: opts.setupTokenSecret,
137
182
  revokeAllOtherSessions: sharedRevoker,
183
+ revokeAllPatTokens: sharedPatRevoker,
138
184
  }),
139
185
  ),
140
186
  enableConfirmPreauth: r.writeHandler(
141
187
  createEnableConfirmPreauthHandler({
142
188
  setupTokenSecret: opts.setupTokenSecret,
143
189
  revokeAllOtherSessions: sharedRevoker,
190
+ revokeAllPatTokens: sharedPatRevoker,
191
+ }),
192
+ ),
193
+ disable: r.writeHandler(
194
+ createDisableHandler({
195
+ revokeAllOtherSessions: sharedRevoker,
196
+ revokeAllPatTokens: sharedPatRevoker,
144
197
  }),
145
198
  ),
146
- disable: r.writeHandler(createDisableHandler({ revokeAllOtherSessions: sharedRevoker })),
147
199
  regenerateRecovery: r.writeHandler(
148
200
  createRegenerateRecoveryHandler({ revokeAllOtherSessions: sharedRevoker }),
149
201
  ),
@@ -164,10 +216,25 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
164
216
  challengeTokenSecret: opts.challengeTokenSecret,
165
217
  });
166
218
 
219
+ // No late-bind needed — same reasoning as checkMfaStatus: only needs the
220
+ // HandlerContext the caller already has.
221
+ const verifyMfaCode = createMfaCodeVerifier();
222
+
167
223
  const bindRevokeAllOtherSessions: BindMfaRevokeAllOtherSessions = (revoker) => {
168
224
  revokeAllOtherSessions = revoker;
169
225
  };
170
226
 
171
- return { handlers, queries, bindRevokeAllOtherSessions, checkMfaStatus };
227
+ const bindRevokeAllPatTokens: BindRevokeAllPatTokens = (revoker) => {
228
+ revokeAllPatTokens = revoker;
229
+ };
230
+
231
+ return {
232
+ handlers,
233
+ queries,
234
+ bindRevokeAllOtherSessions,
235
+ checkMfaStatus,
236
+ verifyMfaCode,
237
+ bindRevokeAllPatTokens,
238
+ };
172
239
  });
173
240
  }
@@ -12,6 +12,10 @@ export type DisableOptions = {
12
12
  userId: string,
13
13
  currentSid: string | undefined,
14
14
  ) => Promise<number>;
15
+ // Wired late by run-prod-app once the personal-access-tokens feature (if
16
+ // mounted) is concrete. Absent when PAT isn't mounted: disabling MFA just
17
+ // doesn't revoke PAT tokens.
18
+ readonly revokeAllPatTokens?: (userId: string) => Promise<number>;
15
19
  };
16
20
 
17
21
  const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
@@ -60,6 +64,9 @@ export function createDisableHandler(opts: DisableOptions) {
60
64
  if (opts.revokeAllOtherSessions) {
61
65
  await opts.revokeAllOtherSessions(event.user.id, event.user.sid);
62
66
  }
67
+ if (opts.revokeAllPatTokens) {
68
+ await opts.revokeAllPatTokens(event.user.id);
69
+ }
63
70
 
64
71
  return { isSuccess: true, data: { disabled: true } };
65
72
  },
@@ -36,6 +36,10 @@ export type EnableConfirmPreauthOptions = {
36
36
  userId: string,
37
37
  currentSid: string | undefined,
38
38
  ) => Promise<number>;
39
+ // Wired late by run-prod-app once the personal-access-tokens feature (if
40
+ // mounted) is concrete. Absent when PAT isn't mounted: enabling MFA just
41
+ // doesn't revoke PAT tokens.
42
+ readonly revokeAllPatTokens?: (userId: string) => Promise<number>;
39
43
  };
40
44
 
41
45
  const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
@@ -202,6 +206,9 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
202
206
  if (opts.revokeAllOtherSessions) {
203
207
  await opts.revokeAllOtherSessions(userId, undefined);
204
208
  }
209
+ if (opts.revokeAllPatTokens) {
210
+ await opts.revokeAllPatTokens(userId);
211
+ }
205
212
 
206
213
  return { isSuccess: true, data: { kind: "mfa-preauth-confirm-success", session } };
207
214
  },
@@ -20,6 +20,10 @@ export type EnableConfirmOptions = {
20
20
  userId: string,
21
21
  currentSid: string | undefined,
22
22
  ) => Promise<number>;
23
+ // Wired late by run-prod-app once the personal-access-tokens feature (if
24
+ // mounted) is concrete. Absent when PAT isn't mounted: enabling MFA just
25
+ // doesn't revoke PAT tokens.
26
+ readonly revokeAllPatTokens?: (userId: string) => Promise<number>;
23
27
  };
24
28
 
25
29
  const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
@@ -99,6 +103,9 @@ export function createEnableConfirmHandler(opts: EnableConfirmOptions) {
99
103
  if (opts.revokeAllOtherSessions) {
100
104
  await opts.revokeAllOtherSessions(event.user.id, event.user.sid);
101
105
  }
106
+ if (opts.revokeAllPatTokens) {
107
+ await opts.revokeAllPatTokens(event.user.id);
108
+ }
102
109
 
103
110
  return { isSuccess: true, data: { enabled: true } };
104
111
  },
@@ -9,12 +9,16 @@ export {
9
9
  export type {
10
10
  AuthMfaFeatureOptions,
11
11
  BindMfaRevokeAllOtherSessions,
12
+ BindRevokeAllPatTokens,
12
13
  } from "./feature";
13
14
  export {
14
15
  bindMfaRevokeAllOtherSessionsFromFeature,
16
+ bindRevokeAllPatTokensFromFeature,
15
17
  createAuthMfaFeature,
16
18
  mfaStatusCheckerFromFeature,
19
+ mfaVerifierFromFeature,
17
20
  } from "./feature";
21
+ export type { MfaCodeVerifier, MfaCodeVerifyResult } from "./mfa-code-verifier";
18
22
  export type { MfaStatusChecker, MfaStatusCheckResult } from "./mfa-status-checker";
19
23
  export { userMfaEntity, userMfaTable } from "./schema/user-mfa";
20
24
  export {
@@ -0,0 +1,33 @@
1
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { HandlerContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { findUserMfaRow } from "./db/queries";
4
+ import { verifyMfaFactor } from "./verify-factor";
5
+
6
+ export type MfaCodeVerifyResult = { readonly enrolled: boolean; readonly ok: boolean };
7
+
8
+ export type MfaCodeVerifier = (
9
+ ctx: HandlerContext,
10
+ userId: string,
11
+ tenantId: TenantId,
12
+ code: string | undefined,
13
+ ) => Promise<MfaCodeVerifyResult>;
14
+
15
+ // Re-auth-only primitive (personal-access-tokens' currentPassword+mfaCode
16
+ // gate), distinct from login's mfaStatusChecker (enrollment/policy only, no
17
+ // code) and from verify.write.ts (full login-completion flow). TOTP-only —
18
+ // a recovery code accepted here without persisting single-use consumption
19
+ // (verify.write.ts's remainingHashes update) would become infinitely
20
+ // reusable, and a re-auth gate is not the account-recovery flow recovery
21
+ // codes exist for.
22
+ export function createMfaCodeVerifier(): MfaCodeVerifier {
23
+ return async (ctx, userId, tenantId, code) => {
24
+ const scopedDb = createTenantDb(ctx.db.raw, tenantId, "system");
25
+ const row = await findUserMfaRow(scopedDb, { id: userId, tenantId, roles: [] });
26
+ if (!row) return { enrolled: false, ok: false };
27
+ // Fail closed without ctx.redis, matching disable.write.ts/verify.write.ts —
28
+ // skipping the TOTP-replay burn would let an observed code be replayed.
29
+ if (!code || !ctx.redis) return { enrolled: true, ok: false };
30
+ const verify = await verifyMfaFactor(row, code, { redis: ctx.redis, userId });
31
+ return { enrolled: true, ok: verify.ok && verify.method === "totp" };
32
+ };
33
+ }
@@ -49,10 +49,12 @@ const stubNav: NavApi = {
49
49
  route: undefined,
50
50
  navigate: () => {},
51
51
  replace: () => {},
52
- hrefFor: (target) =>
53
- target.entityId !== undefined
52
+ hrefFor: (target) => {
53
+ if (!("screenId" in target)) return "";
54
+ return target.entityId !== undefined
54
55
  ? `/${target.screenId}/${target.entityId}`
55
- : `/${target.screenId}`,
56
+ : `/${target.screenId}`;
57
+ },
56
58
  searchParams: {},
57
59
  setSearchParams: () => {},
58
60
  };