@cosmicdrift/kumiko-bundled-features 0.200.0 → 0.201.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.200.0",
3
+ "version": "0.201.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.0",
130
- "@cosmicdrift/kumiko-framework": "0.200.0",
131
- "@cosmicdrift/kumiko-headless": "0.200.0",
132
- "@cosmicdrift/kumiko-renderer": "0.200.0",
133
- "@cosmicdrift/kumiko-renderer-web": "0.200.0",
134
- "@cosmicdrift/kumiko-types": "0.200.0",
129
+ "@cosmicdrift/kumiko-dispatcher-live": "0.201.0",
130
+ "@cosmicdrift/kumiko-framework": "0.201.0",
131
+ "@cosmicdrift/kumiko-headless": "0.201.0",
132
+ "@cosmicdrift/kumiko-renderer": "0.201.0",
133
+ "@cosmicdrift/kumiko-renderer-web": "0.201.0",
134
+ "@cosmicdrift/kumiko-types": "0.201.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 });
@@ -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
+ }
@@ -37,11 +37,12 @@ import { userSessionEntity } from "../../sessions/schema/user-session";
37
37
  import { createTenantFeature } from "../../tenant";
38
38
  import { tenantMembershipsTable } from "../../tenant/membership-table";
39
39
  import { tenantEntity } from "../../tenant/schema/tenant";
40
- import { USER_STATUS } from "../../user";
40
+ import { USER_STATUS, UserHandlers, UserQueries } from "../../user";
41
41
  import { createUserFeature } from "../../user/feature";
42
42
  import { userEntity, userTable } from "../../user/schema/user";
43
- import { PatHandlers, PatQueries } from "../constants";
43
+ import { PAT_DEFAULT_EXPIRES_IN_DAYS, PatErrors, PatHandlers, PatQueries } from "../constants";
44
44
  import { createPersonalAccessTokensFeature } from "../feature";
45
+ import { revokeAllPatTokensForUser } from "../revoke-for-user";
45
46
  import { apiTokenEntity, apiTokenTable } from "../schema/api-token";
46
47
  import type { PatScopeConfig } from "../scopes";
47
48
 
@@ -66,14 +67,21 @@ const SCOPES: PatScopeConfig = {
66
67
 
67
68
  async function mintToken(
68
69
  actor: SessionUser,
69
- opts?: { scopes?: string[]; expiresInDays?: number },
70
+ opts?: {
71
+ scopes?: string[];
72
+ expiresInDays?: number;
73
+ currentPassword?: string;
74
+ mfaCode?: string;
75
+ },
70
76
  ): Promise<string> {
71
77
  const res = await stack.http.writeOk<{ id: string; token: string }>(
72
78
  PatHandlers.create,
73
79
  {
74
80
  name: "test",
75
81
  scopes: opts?.scopes ?? ["tokens:read"],
82
+ currentPassword: opts?.currentPassword ?? "pw",
76
83
  ...(opts?.expiresInDays ? { expiresInDays: opts.expiresInDays } : {}),
84
+ ...(opts?.mfaCode ? { mfaCode: opts.mfaCode } : {}),
77
85
  },
78
86
  actor,
79
87
  );
@@ -91,7 +99,12 @@ beforeAll(async () => {
91
99
  createTenantFeature(),
92
100
  createAuthEmailPasswordFeature(),
93
101
  createSessionsFeature(),
94
- createPersonalAccessTokensFeature({ scopes: SCOPES }),
102
+ createPersonalAccessTokensFeature({
103
+ scopes: SCOPES,
104
+ // Closure over `stack` is fine here: the hook only invokes this at
105
+ // fire-time, well after setupTestStack below has assigned it.
106
+ autoRevokeOnPasswordChange: (userId) => revokeAllPatTokensForUser(stack.db, userId),
107
+ }),
95
108
  authFoundationFeature,
96
109
  ],
97
110
  extraContext: { configResolver: resolver, configEncryption: encryption },
@@ -253,3 +266,157 @@ describe("PAT with active KMS (#820): token name is userOwned PII", () => {
253
266
  }
254
267
  });
255
268
  });
269
+
270
+ describe("PAT create: re-auth (#security)", () => {
271
+ test("missing currentPassword → rejected, no token minted", async () => {
272
+ const actor = await actorFor("reauth-missing@example.com");
273
+ const err = await stack.http.writeErr(
274
+ PatHandlers.create,
275
+ { name: "test", scopes: ["tokens:read"] },
276
+ actor,
277
+ );
278
+ expect(err.httpStatus).toBeGreaterThanOrEqual(400);
279
+ const rows = await stack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor);
280
+ expect(rows).toHaveLength(0);
281
+ });
282
+
283
+ test("wrong currentPassword → rejected, no token minted", async () => {
284
+ const actor = await actorFor("reauth-wrong@example.com");
285
+ const err = await stack.http.writeErr(
286
+ PatHandlers.create,
287
+ { name: "test", scopes: ["tokens:read"], currentPassword: "not-the-password" },
288
+ actor,
289
+ );
290
+ expect(err.details).toMatchObject({ reason: PatErrors.reauthRequired });
291
+ const rows = await stack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor);
292
+ expect(rows).toHaveLength(0);
293
+ });
294
+
295
+ test("expiresInDays omitted → defaults to ~90 days, not never-expiring", async () => {
296
+ const actor = await actorFor("reauth-expiry@example.com");
297
+ await mintToken(actor);
298
+ const rows = await stack.http.queryOk<Array<{ id: string; expiresAt: string | null }>>(
299
+ PatQueries.mine,
300
+ {},
301
+ actor,
302
+ );
303
+ expect(rows[0]?.expiresAt).not.toBeNull();
304
+ const expiresAt = Temporal.Instant.from(rows[0]?.expiresAt as string);
305
+ const expected = Temporal.Now.instant().add({ hours: 24 * PAT_DEFAULT_EXPIRES_IN_DAYS });
306
+ const driftHours = Math.abs(expiresAt.since(expected).total({ unit: "hours" }));
307
+ expect(driftHours).toBeLessThan(1);
308
+ });
309
+ });
310
+
311
+ describe("PAT create: MFA re-auth gate", () => {
312
+ let mfaStack: TestStack;
313
+ let mfaH: ReturnType<typeof makeSessionHelpers>;
314
+
315
+ beforeAll(async () => {
316
+ const encryption = createTestEnvelopeCipher(encryptionKey);
317
+ const resolver = createConfigResolver({ cipher: encryption });
318
+ mfaStack = await setupTestStack({
319
+ features: [
320
+ createConfigFeature(),
321
+ createUserFeature(),
322
+ createTenantFeature(),
323
+ createAuthEmailPasswordFeature(),
324
+ createSessionsFeature(),
325
+ createPersonalAccessTokensFeature({
326
+ scopes: SCOPES,
327
+ // Stub verifier: always enrolled, code never accepted — exercises
328
+ // the real gate over HTTP without depending on auth-mfa's TOTP
329
+ // machinery (same shape as sessions' massRevokeSpy tests).
330
+ mfaVerifier: async () => ({ enrolled: true, ok: false }),
331
+ }),
332
+ authFoundationFeature,
333
+ ],
334
+ extraContext: { configResolver: resolver, configEncryption: encryption },
335
+ authConfig: {
336
+ membershipQuery: "tenant:query:memberships",
337
+ loginHandler: AuthHandlers.login,
338
+ tokenVerifier: (raw: string) =>
339
+ resolveTokenVerifier({ db: mfaStack.db, registry: mfaStack.registry }, raw),
340
+ patRateLimiter: createInMemoryLoginRateLimiter(10, 60_000),
341
+ },
342
+ });
343
+ mfaH = makeSessionHelpers(mfaStack, TENANT);
344
+ await unsafeCreateEntityTable(mfaStack.db, userEntity);
345
+ await unsafeCreateEntityTable(mfaStack.db, tenantEntity);
346
+ await unsafeCreateEntityTable(mfaStack.db, userSessionEntity);
347
+ await unsafeCreateEntityTable(mfaStack.db, apiTokenEntity);
348
+ await unsafePushTables(mfaStack.db, { configValuesTable, tenantMembershipsTable });
349
+ });
350
+
351
+ afterAll(async () => {
352
+ await mfaStack.cleanup();
353
+ });
354
+
355
+ test("MFA-enrolled user without mfaCode → rejected, no token minted", async () => {
356
+ const { userId } = await mfaH.seedUser("mfa-missing-code@example.com", "pw");
357
+ const actor: SessionUser = { id: userId, tenantId: TENANT, roles: ["User"] };
358
+ const err = await mfaStack.http.writeErr(
359
+ PatHandlers.create,
360
+ { name: "test", scopes: ["tokens:read"], currentPassword: "pw" },
361
+ actor,
362
+ );
363
+ expect(err.details).toMatchObject({ reason: PatErrors.reauthRequired });
364
+ const rows = await mfaStack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor);
365
+ expect(rows).toHaveLength(0);
366
+ });
367
+
368
+ test("MFA-enrolled user with wrong mfaCode → rejected, no token minted", async () => {
369
+ const { userId } = await mfaH.seedUser("mfa-wrong-code@example.com", "pw");
370
+ const actor: SessionUser = { id: userId, tenantId: TENANT, roles: ["User"] };
371
+ const err = await mfaStack.http.writeErr(
372
+ PatHandlers.create,
373
+ { name: "test", scopes: ["tokens:read"], currentPassword: "pw", mfaCode: "000000" },
374
+ actor,
375
+ );
376
+ expect(err.details).toMatchObject({ reason: PatErrors.reauthRequired });
377
+ const rows = await mfaStack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor);
378
+ expect(rows).toHaveLength(0);
379
+ });
380
+ });
381
+
382
+ describe("PAT revoke on password change (#security)", () => {
383
+ test("changing password revokes all of the user's live PAT tokens", async () => {
384
+ const actor = await actorFor("pat-revoke-pwchange@example.com");
385
+ const token = await mintToken(actor);
386
+ const preCheck = await h.authedPost("/api/query", token, {
387
+ type: PatQueries.mine,
388
+ payload: {},
389
+ });
390
+ expect(preCheck.status).toBe(200);
391
+
392
+ await stack.http.writeOk(
393
+ AuthHandlers.changePassword,
394
+ { oldPassword: "pw", newPassword: "NewPassw0rd!42" },
395
+ actor,
396
+ );
397
+
398
+ const rows = await selectMany<{ revokedAt: string | null }>(stack.db, apiTokenTable, {
399
+ userId: actor.id,
400
+ });
401
+ expect(rows.length).toBeGreaterThan(0);
402
+ expect(rows.every((row) => row.revokedAt !== null)).toBe(true);
403
+
404
+ const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
405
+ expect(res.status).toBe(401);
406
+ });
407
+
408
+ test("editing a non-password field does NOT revoke PAT tokens", async () => {
409
+ const actor = await actorFor("pat-no-revoke-other-field@example.com");
410
+ const token = await mintToken(actor);
411
+
412
+ const me = await stack.http.queryOk<{ version: number }>(UserQueries.me, {}, actor);
413
+ await stack.http.writeOk(
414
+ UserHandlers.update,
415
+ { id: actor.id, version: me.version, changes: { displayName: "New Name" } },
416
+ actor,
417
+ );
418
+
419
+ const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
420
+ expect(res.status).toBe(200);
421
+ });
422
+ });
@@ -6,8 +6,16 @@ export const PAT_FEATURE = "personal-access-tokens";
6
6
  // Snake_case reason strings (Error-Reasons guard: no colons/dashes).
7
7
  export const PatErrors = {
8
8
  ownershipDenied: "ownership_denied",
9
+ // Uniform for wrong-password AND missing/wrong MFA code — a distinct
10
+ // reason per case would leak whether the account has MFA enrolled.
11
+ reauthRequired: "reauth_required",
9
12
  } as const;
10
13
 
14
+ // expiresInDays default when omitted at mint time. A silent "never expires"
15
+ // default is a standing-credential risk; the existing 3650-day cap still
16
+ // lets callers who genuinely want a long-lived token pass it explicitly.
17
+ export const PAT_DEFAULT_EXPIRES_IN_DAYS = 90;
18
+
11
19
  // Dormant custom-screen id (r.screen) — the app places it via r.nav. The client
12
20
  // maps it to the PatTokensScreen component.
13
21
  export const PAT_SCREEN_ID = "api-tokens";
@@ -7,7 +7,7 @@ import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
7
7
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE, PAT_SCREEN_ID, type PatRateLimit } from "./constants";
9
9
  import { buildAvailableScopesQuery } from "./handlers/available-scopes.query";
10
- import { createPatWrite } from "./handlers/create.write";
10
+ import { type CreatePatOptions, createPatCreateHandler } from "./handlers/create.write";
11
11
  import { listPatQuery } from "./handlers/list.query";
12
12
  import { revokePatWrite } from "./handlers/revoke.write";
13
13
  import { PAT_FEATURE_I18N } from "./i18n";
@@ -15,6 +15,36 @@ import { createPatResolver } from "./resolver";
15
15
  import { apiTokenEntity } from "./schema/api-token";
16
16
  import type { PatScopeConfig } from "./scopes";
17
17
 
18
+ // Password-change is the only field-level trigger — see the postSave hook
19
+ // below. MFA-enable/disable is wired separately (auth-mfa's
20
+ // revokeAllPatTokens callback, late-bound at app-composition time) since
21
+ // that's a different entity ("user-mfa") this feature doesn't own or
22
+ // require.
23
+ const PAT_REVOKE_TRIGGERING_FIELDS = ["passwordHash"] as const;
24
+
25
+ export type BindPatAutoRevokeOnPasswordChange = (
26
+ revoker: (userId: string) => Promise<number>,
27
+ ) => void;
28
+
29
+ // Reads the late-bind setter off a mounted personal-access-tokens feature's
30
+ // exports — run{Prod,Dev}App call this once a concrete db is available,
31
+ // mirrors sessions' own bindAutoRevokeFromFeature/bindAutoRevokeOnPasswordChange.
32
+ export function bindPatAutoRevokeOnPasswordChangeFromFeature(
33
+ feature: FeatureDefinition,
34
+ ): BindPatAutoRevokeOnPasswordChange | undefined {
35
+ const exports = feature.exports;
36
+ if (exports && typeof exports === "object" && "bindAutoRevokeOnPasswordChange" in exports) {
37
+ const { bindAutoRevokeOnPasswordChange } = exports as {
38
+ bindAutoRevokeOnPasswordChange: unknown;
39
+ };
40
+ if (typeof bindAutoRevokeOnPasswordChange === "function") {
41
+ // @cast-boundary exports-walk — feature.exports is untyped by design
42
+ return bindAutoRevokeOnPasswordChange as BindPatAutoRevokeOnPasswordChange;
43
+ }
44
+ }
45
+ return undefined;
46
+ }
47
+
18
48
  export type PersonalAccessTokensOptions = {
19
49
  // The scopes this deployment offers. Each is a named bundle of QN globs a PAT
20
50
  // may be granted (a scope can span features). Closed over by available-scopes
@@ -28,10 +58,20 @@ export type PersonalAccessTokensOptions = {
28
58
  * { default: false } for fail-closed gating (feature off until a tier grants
29
59
  * it). Omit to keep PAT always-on (default). */
30
60
  readonly toggleable?: { readonly default: boolean };
61
+ // Opt-in MFA re-auth gate for minting a token — wired via
62
+ // mfaVerifierFromFeature (auth-mfa/feature.ts) at app-composition time. No
63
+ // hard dependency on the optional auth-mfa feature.
64
+ readonly mfaVerifier?: CreatePatOptions["mfaVerifier"];
65
+ // Password-change revoker — same constructor-option-or-late-bind duality as
66
+ // sessions' own autoRevokeOnPasswordChange (bindAutoRevokeOnPasswordChange
67
+ // below wins only if this is unset). Lets tests pass a revoker directly
68
+ // instead of needing a post-setupTestStack bind call.
69
+ readonly autoRevokeOnPasswordChange?: (userId: string) => Promise<number>;
31
70
  };
32
71
 
33
72
  export type PatFeatureExports = {
34
73
  readonly rateLimit: PatRateLimit;
74
+ readonly bindAutoRevokeOnPasswordChange: BindPatAutoRevokeOnPasswordChange;
35
75
  };
36
76
 
37
77
  // Personal Access Tokens — long-lived, revocable bearer credentials for the
@@ -73,8 +113,31 @@ export function createPersonalAccessTokensFeature(
73
113
  piiEncryptedOnWrite: true,
74
114
  });
75
115
 
116
+ // Password-change auto-revoke — mirrors sessions' own
117
+ // autoRevokeOnPasswordChange postSave hook, including WHY it's a
118
+ // late-bind callback rather than direct ctx.db use: user:write:user:update
119
+ // is r.systemScope()'d (the user aggregate is a systemStream, framework
120
+ // #497), so a postSave hook on "user" gets a poisoned ctx.db here. The
121
+ // concrete revoker is bound once run{Prod,Dev}App has a real db handle.
122
+ let autoRevokeOnPasswordChange = options.autoRevokeOnPasswordChange;
123
+ r.hook("postSave", { allOf: "user" }, async (result) => {
124
+ // skip: nothing bound — same late-bind pattern as sessions/feature.ts
125
+ if (!autoRevokeOnPasswordChange) return;
126
+ // skip: brand-new user, no PATs can exist yet
127
+ if (result.isNew) return;
128
+ // skip: handler didn't touch any revoke-triggering field
129
+ if (!PAT_REVOKE_TRIGGERING_FIELDS.some((field) => result.changes[field] !== undefined)) {
130
+ return;
131
+ }
132
+ await autoRevokeOnPasswordChange(String(result.id));
133
+ });
134
+ const bindAutoRevokeOnPasswordChange: BindPatAutoRevokeOnPasswordChange = (revoker) => {
135
+ // explicit constructor option wins over the runtime binding
136
+ autoRevokeOnPasswordChange ??= revoker;
137
+ };
138
+
76
139
  const handlers = {
77
- create: r.writeHandler(createPatWrite),
140
+ create: r.writeHandler(createPatCreateHandler({ mfaVerifier: options.mfaVerifier })),
78
141
  revoke: r.writeHandler(revokePatWrite),
79
142
  };
80
143
  const queries = {
@@ -102,6 +165,7 @@ export function createPersonalAccessTokensFeature(
102
165
  return {
103
166
  handlers,
104
167
  queries,
168
+ bindAutoRevokeOnPasswordChange,
105
169
  rateLimit: options.rateLimit ?? PAT_DEFAULT_RATE_LIMIT,
106
170
  } satisfies { handlers: unknown; queries: unknown } & PatFeatureExports;
107
171
  });
@@ -1,47 +1,105 @@
1
1
  import { insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import {
3
+ createSystemUser,
4
+ defineWriteHandler,
5
+ type HandlerContext,
6
+ type TenantId,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
9
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
4
10
  import { Temporal } from "temporal-polyfill";
5
11
  import { z } from "zod";
6
- import { encryptForDirectWrite } from "../../shared";
12
+ import { encryptForDirectWrite, verifyPassword } from "../../shared";
13
+ import { UserQueries } from "../../user";
14
+ import { PAT_DEFAULT_EXPIRES_IN_DAYS, PatErrors } from "../constants";
7
15
  import { mintPatToken } from "../hash";
8
16
  import { apiTokenEntity, apiTokenTable } from "../schema/api-token";
9
17
 
18
+ export type PatMfaVerifyResult = { readonly enrolled: boolean; readonly ok: boolean };
19
+
20
+ export type CreatePatOptions = {
21
+ // Opt-in MFA re-auth gate — auth-mfa (if mounted) wires this in at
22
+ // app-composition time via mfaVerifierFromFeature. Deliberately generic
23
+ // here, same decoupling as login.write.ts's mfaStatusChecker: this
24
+ // feature must not import auth-mfa's types, only this shape.
25
+ readonly mfaVerifier?: (
26
+ ctx: HandlerContext,
27
+ userId: string,
28
+ tenantId: TenantId,
29
+ code: string | undefined,
30
+ ) => Promise<PatMfaVerifyResult>;
31
+ };
32
+
33
+ function reauthFailed() {
34
+ return writeFailure(
35
+ new UnprocessableError(PatErrors.reauthRequired, {
36
+ i18nKey: "errors.reauthRequired",
37
+ }),
38
+ );
39
+ }
40
+
10
41
  // Mint a PAT for the calling user in their active tenant. The plaintext token
11
42
  // is returned ONCE (data.token) and never again — only the hash is stored.
12
43
  // `scopes` are granted scope names; unknown names simply grant nothing at
13
44
  // resolve time (fail-closed), so no cross-check against the app config here.
14
- export const createPatWrite = defineWriteHandler({
15
- name: "create",
16
- schema: z.object({
17
- name: z.string().min(1).max(120),
18
- scopes: z.array(z.string().min(1)).min(1),
19
- expiresInDays: z.number().int().positive().max(3650).optional(),
20
- }),
21
- access: { openToAll: true },
22
- handler: async (event, ctx) => {
23
- const { raw, hash, prefix } = mintPatToken();
24
- const now = Temporal.Now.instant();
25
- const id = generateId();
26
- const row = await encryptForDirectWrite(
27
- apiTokenEntity,
28
- {
29
- id,
30
- userId: event.user.id,
31
- tenantId: event.user.tenantId,
32
- name: event.payload.name,
33
- tokenHash: hash,
34
- prefix,
35
- scopes: JSON.stringify(event.payload.scopes),
36
- createdAt: now,
37
- expiresAt: event.payload.expiresInDays
38
- ? now.add({ hours: 24 * event.payload.expiresInDays })
39
- : null,
40
- revokedAt: null,
41
- },
42
- "pat:create",
43
- );
44
- await insertOne(ctx.db, apiTokenTable, row);
45
- return { isSuccess: true, data: { id, token: raw, prefix } };
46
- },
47
- });
45
+ //
46
+ // Minting a long-lived bearer credential re-verifies the caller's password
47
+ // (and MFA code, if enrolled) even though the request is already
48
+ // session-authed — a stolen/leaked session cookie must not be enough to
49
+ // stand up a durable API credential.
50
+ export function createPatCreateHandler(opts: CreatePatOptions = {}) {
51
+ return defineWriteHandler({
52
+ name: "create",
53
+ schema: z.object({
54
+ name: z.string().min(1).max(120),
55
+ scopes: z.array(z.string().min(1)).min(1),
56
+ expiresInDays: z.number().int().positive().max(3650).optional(),
57
+ currentPassword: z.string().min(1),
58
+ mfaCode: z.string().optional(),
59
+ }),
60
+ access: { openToAll: true },
61
+ handler: async (event, ctx) => {
62
+ const systemUser = createSystemUser(event.user.tenantId);
63
+ const me = (await ctx.queryAs(systemUser, UserQueries.findForAuth, {
64
+ id: event.user.id,
65
+ })) as { passwordHash: string | null } | null; // @cast-boundary db-runner
66
+ if (!me?.passwordHash) return reauthFailed();
67
+ const passwordOk = await verifyPassword(me.passwordHash, event.payload.currentPassword);
68
+ if (!passwordOk) return reauthFailed();
69
+
70
+ if (opts.mfaVerifier) {
71
+ const mfa = await opts.mfaVerifier(
72
+ ctx,
73
+ event.user.id,
74
+ event.user.tenantId,
75
+ event.payload.mfaCode,
76
+ );
77
+ if (mfa.enrolled && !mfa.ok) return reauthFailed();
78
+ }
79
+
80
+ const { raw, hash, prefix } = mintPatToken();
81
+ const now = Temporal.Now.instant();
82
+ const id = generateId();
83
+ const row = await encryptForDirectWrite(
84
+ apiTokenEntity,
85
+ {
86
+ id,
87
+ userId: event.user.id,
88
+ tenantId: event.user.tenantId,
89
+ name: event.payload.name,
90
+ tokenHash: hash,
91
+ prefix,
92
+ scopes: JSON.stringify(event.payload.scopes),
93
+ createdAt: now,
94
+ expiresAt: now.add({
95
+ hours: 24 * (event.payload.expiresInDays ?? PAT_DEFAULT_EXPIRES_IN_DAYS),
96
+ }),
97
+ revokedAt: null,
98
+ },
99
+ "pat:create",
100
+ );
101
+ await insertOne(ctx.db, apiTokenTable, row);
102
+ return { isSuccess: true, data: { id, token: raw, prefix } };
103
+ },
104
+ });
105
+ }
@@ -3,10 +3,15 @@ import { PAT_DEFAULT_RATE_LIMIT, type PatRateLimit } from "./constants";
3
3
 
4
4
  export type { PatRateLimit } from "./constants";
5
5
  export { PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE, PatHandlers, PatQueries } from "./constants";
6
- export type { PersonalAccessTokensOptions } from "./feature";
7
- export { createPersonalAccessTokensFeature } from "./feature";
6
+ export type { BindPatAutoRevokeOnPasswordChange, PersonalAccessTokensOptions } from "./feature";
7
+ export {
8
+ bindPatAutoRevokeOnPasswordChangeFromFeature,
9
+ createPersonalAccessTokensFeature,
10
+ } from "./feature";
11
+ export type { CreatePatOptions, PatMfaVerifyResult } from "./handlers/create.write";
8
12
  export { hashPatToken, mintPatToken } from "./hash";
9
13
  export { createPatResolver } from "./resolver";
14
+ export { revokeAllPatTokensForUser } from "./revoke-for-user";
10
15
  export { apiTokenEntity, apiTokenTable } from "./schema/api-token";
11
16
  export type { PatScopeConfig, PatScopeDef } from "./scopes";
12
17
  export { expandScopes } from "./scopes";
@@ -0,0 +1,18 @@
1
+ import { updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+ import { Temporal } from "temporal-polyfill";
4
+ import { apiTokenTable } from "./schema/api-token";
5
+
6
+ // Cross-tenant revoke: password-change and MFA-enable/disable are account-
7
+ // level security events, not scoped to one tenant. Mirrors sessions'
8
+ // sessionMassRevoker (session-callbacks.ts) which passes the boot-time
9
+ // DbConnection directly — not ctx.db, which would be tenant-scoped in a hook.
10
+ export async function revokeAllPatTokensForUser(db: DbConnection, userId: string): Promise<number> {
11
+ const updated = await updateMany<{ id: string }>(
12
+ db,
13
+ apiTokenTable,
14
+ { revokedAt: Temporal.Now.instant() },
15
+ { userId, revokedAt: null },
16
+ );
17
+ return updated.length;
18
+ }
@@ -17,6 +17,10 @@ export const defaultTranslations: TranslationsByLocale = {
17
17
  "pat.create.submit": "Token erstellen",
18
18
  "pat.create.needName": "Bitte einen Namen vergeben.",
19
19
  "pat.create.needScope": "Bitte mindestens eine Berechtigung wählen.",
20
+ "pat.create.needPassword": "Bitte dein Passwort zur Bestätigung eingeben.",
21
+ "pat.create.currentPassword": "Passwort (zur Bestätigung)",
22
+ "pat.create.mfaCode": "2FA-Code (falls aktiviert)",
23
+ "pat.create.mfaCodePlaceholder": "6-stelliger Code",
20
24
  "pat.expiry.30d": "30 Tage",
21
25
  "pat.expiry.90d": "90 Tage",
22
26
  "pat.expiry.1y": "1 Jahr",
@@ -49,6 +53,10 @@ export const defaultTranslations: TranslationsByLocale = {
49
53
  "pat.create.submit": "Create token",
50
54
  "pat.create.needName": "Please enter a name.",
51
55
  "pat.create.needScope": "Please select at least one permission.",
56
+ "pat.create.needPassword": "Please enter your password to confirm.",
57
+ "pat.create.currentPassword": "Password (to confirm)",
58
+ "pat.create.mfaCode": "2FA code (if enabled)",
59
+ "pat.create.mfaCodePlaceholder": "6-digit code",
52
60
  "pat.expiry.30d": "30 days",
53
61
  "pat.expiry.90d": "90 days",
54
62
  "pat.expiry.1y": "1 year",
@@ -57,6 +57,8 @@ export function PatTokensScreen({
57
57
  const [name, setName] = useState("");
58
58
  const [levels, setLevels] = useState<Readonly<Record<string, Level>>>({});
59
59
  const [expiry, setExpiry] = useState<ExpiryKey>("90d");
60
+ const [currentPassword, setCurrentPassword] = useState("");
61
+ const [mfaCode, setMfaCode] = useState("");
60
62
  const [minted, setMinted] = useState<{ token: string } | null>(null);
61
63
  const [copied, setCopied] = useState(false);
62
64
  const [error, setError] = useState<string | null>(null);
@@ -80,15 +82,21 @@ export function PatTokensScreen({
80
82
  if (name.trim() === "") return setError(t("pat.create.needName"));
81
83
  const scopes = grants();
82
84
  if (scopes.length === 0) return setError(t("pat.create.needScope"));
85
+ if (currentPassword === "") return setError(t("pat.create.needPassword"));
83
86
  setBusy(true);
84
87
  setError(null);
85
88
  const days = EXPIRY_DAYS[expiry];
86
89
  const res = await dispatcher.write(PatHandlers.create, {
87
90
  name: name.trim(),
88
91
  scopes,
92
+ currentPassword,
89
93
  ...(days !== undefined ? { expiresInDays: days } : {}),
94
+ ...(mfaCode.trim() !== "" ? { mfaCode: mfaCode.trim() } : {}),
90
95
  });
91
96
  setBusy(false);
97
+ // Never leave a submitted password/MFA code sitting in state, success or not.
98
+ setCurrentPassword("");
99
+ setMfaCode("");
92
100
  if (!res.isSuccess) return setError(t("pat.error.generic"));
93
101
  setMinted({ token: (res.data as { token: string }).token });
94
102
  setCopied(false);
@@ -219,6 +227,29 @@ export function PatTokensScreen({
219
227
  disabled={busy}
220
228
  />
221
229
  </Field>
230
+
231
+ <Field id="pat-current-password" label={t("pat.create.currentPassword")} required>
232
+ <Input
233
+ kind="password"
234
+ id="pat-current-password"
235
+ name="currentPassword"
236
+ value={currentPassword}
237
+ onChange={setCurrentPassword}
238
+ disabled={busy}
239
+ />
240
+ </Field>
241
+
242
+ <Field id="pat-mfa-code" label={t("pat.create.mfaCode")}>
243
+ <Input
244
+ kind="text"
245
+ id="pat-mfa-code"
246
+ name="mfaCode"
247
+ value={mfaCode}
248
+ onChange={setMfaCode}
249
+ placeholder={t("pat.create.mfaCodePlaceholder")}
250
+ disabled={busy}
251
+ />
252
+ </Field>
222
253
  </Form>
223
254
 
224
255
  <div className="flex flex-col gap-3">
@@ -18,6 +18,13 @@ export function reservedMembershipRoleError(role: string): AccessDeniedError {
18
18
  });
19
19
  }
20
20
 
21
+ export function unassignableMembershipRoleError(role: string): AccessDeniedError {
22
+ return new AccessDeniedError({
23
+ message: `role "${role}" cannot be assigned by this inviter`,
24
+ details: { reason: "unassignable_membership_role", role },
25
+ });
26
+ }
27
+
21
28
  export function assertAssignableMembershipRoles(roles: readonly string[]): void {
22
29
  const forbidden = findForbiddenMembershipRole(roles);
23
30
  if (forbidden !== undefined) throw reservedMembershipRoleError(forbidden);
@@ -34,7 +34,7 @@ import {
34
34
  import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing";
35
35
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
36
36
  import { createConfigFeature } from "../../config";
37
- import { createConfigResolver } from "../../config/resolver";
37
+ import { buildEnvConfigOverrides, createConfigResolver } from "../../config/resolver";
38
38
  import { configValueEntity } from "../../config/table";
39
39
  import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
40
40
  import { createSessionsFeature } from "../../sessions";
@@ -147,6 +147,28 @@ describe("tenant-model config resolution (seam)", () => {
147
147
  });
148
148
  expect(model).toBe("multi-user");
149
149
  });
150
+
151
+ test("TENANT_MODEL env var bridges through the real registry to resolveAppTenantModel", async () => {
152
+ // Mirrors the cascade.integration.test.ts env seam test, but pins it at
153
+ // the real tenantModel key: registry → buildEnvConfigOverrides →
154
+ // resolveAppTenantModel, so a key-qualification mismatch between the
155
+ // feature's `env` declaration and the bridge would fail here, not just
156
+ // on a stub registry.
157
+ const keyDef = stack.registry.getConfigKey(TENANT_MODEL_CONFIG_KEY);
158
+ expect(keyDef).toBeDefined();
159
+ expect(keyDef?.env).toBe("TENANT_MODEL");
160
+
161
+ const overrides = buildEnvConfigOverrides(stack.registry, { TENANT_MODEL: "single-user" });
162
+ expect(overrides.get(TENANT_MODEL_CONFIG_KEY)).toBe("single-user");
163
+
164
+ const model = await resolveAppTenantModel({
165
+ registry: stack.registry,
166
+ configResolver: createConfigResolver({ appOverrides: overrides }),
167
+ db: stack.db,
168
+ userId: SYSTEM_USER_ID,
169
+ });
170
+ expect(model).toBe("single-user");
171
+ });
150
172
  });
151
173
 
152
174
  describe("forget pipeline honours the effective tenant model", () => {
@@ -179,6 +179,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
179
179
  createSystemConfig("select", {
180
180
  default: "multi-user",
181
181
  options: ["single-user", "multi-user"],
182
+ env: "TENANT_MODEL",
182
183
  }),
183
184
  );
184
185