@cosmicdrift/kumiko-bundled-features 0.161.0 → 0.163.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 (35) hide show
  1. package/package.json +6 -6
  2. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +168 -0
  3. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +25 -0
  4. package/src/auth-email-password/constants.ts +6 -0
  5. package/src/auth-email-password/feature.ts +8 -1
  6. package/src/auth-email-password/handlers/self-registration-status.query.ts +20 -0
  7. package/src/auth-email-password/handlers/signup-confirm.write.ts +17 -10
  8. package/src/auth-email-password/handlers/signup-request.write.ts +11 -0
  9. package/src/auth-email-password/index.ts +5 -1
  10. package/src/auth-email-password/seeding.ts +16 -6
  11. package/src/auth-email-password/self-registration-toggle.ts +25 -0
  12. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +79 -0
  13. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +2 -2
  14. package/src/auth-email-password/web/auth-gate.tsx +44 -2
  15. package/src/auth-email-password/web/login-screen.tsx +8 -5
  16. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +253 -0
  17. package/src/auth-mfa/__tests__/enable-start-preauth.integration.test.ts +214 -0
  18. package/src/auth-mfa/constants.ts +2 -0
  19. package/src/auth-mfa/feature.ts +15 -0
  20. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +170 -0
  21. package/src/auth-mfa/handlers/enable-start-preauth.write.ts +87 -0
  22. package/src/auth-mfa/mfa-setup-token.ts +7 -1
  23. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +144 -1
  24. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +202 -0
  25. package/src/auth-mfa/web/i18n.ts +14 -0
  26. package/src/auth-mfa/web/index.ts +8 -2
  27. package/src/auth-mfa/web/mfa-client.ts +113 -0
  28. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +244 -0
  29. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +153 -0
  30. package/src/feature-toggles/compose-tier-resolver.ts +65 -0
  31. package/src/feature-toggles/feature.ts +5 -1
  32. package/src/feature-toggles/global-feature-state-table.ts +16 -0
  33. package/src/feature-toggles/index.ts +2 -0
  34. package/src/feature-toggles/toggle-runtime.ts +14 -0
  35. package/src/tenant/seeding.ts +40 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.161.0",
3
+ "version": "0.163.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>",
@@ -117,11 +117,11 @@
117
117
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
118
118
  },
119
119
  "dependencies": {
120
- "@cosmicdrift/kumiko-dispatcher-live": "0.161.0",
121
- "@cosmicdrift/kumiko-framework": "0.161.0",
122
- "@cosmicdrift/kumiko-headless": "0.161.0",
123
- "@cosmicdrift/kumiko-renderer": "0.161.0",
124
- "@cosmicdrift/kumiko-renderer-web": "0.161.0",
120
+ "@cosmicdrift/kumiko-dispatcher-live": "0.163.0",
121
+ "@cosmicdrift/kumiko-framework": "0.163.0",
122
+ "@cosmicdrift/kumiko-headless": "0.163.0",
123
+ "@cosmicdrift/kumiko-renderer": "0.163.0",
124
+ "@cosmicdrift/kumiko-renderer-web": "0.163.0",
125
125
  "@mollie/api-client": "^4.5.0",
126
126
  "imapflow": "^1.3.3",
127
127
  "mailparser": "^3.9.8",
@@ -0,0 +1,168 @@
1
+ // Runtime on/off switch for self-signup (#self-registration-toggle). Pins:
2
+ // 1. Default (no override row) → signup-request works.
3
+ // 2. runtime.apply(off) → signup-request 403 feature_disabled, no mail sent.
4
+ // 3. runtime.apply(on) → works again.
5
+ // Mirrors samples/recipes/feature-toggles' runtime.apply() pattern — flips the
6
+ // in-memory snapshot directly instead of going through the set-handler/HTTP,
7
+ // since that wiring is already covered by feature-toggles' own tests.
8
+
9
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
+ import {
11
+ createFeatureTogglesFeature,
12
+ GlobalFeatureToggleRuntime,
13
+ globalFeatureStateTable,
14
+ } from "@cosmicdrift/kumiko-bundled-features/feature-toggles";
15
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
16
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
17
+ import {
18
+ setupTestStack,
19
+ type TestStack,
20
+ unsafeCreateEntityTable,
21
+ unsafePushTables,
22
+ } from "@cosmicdrift/kumiko-framework/stack";
23
+ import { createLateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
24
+ import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
25
+ import { createConfigFeature } from "../../config";
26
+ import { createConfigResolver } from "../../config/resolver";
27
+ import { configValuesTable } from "../../config/table";
28
+ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery";
29
+ import { notificationPreferencesTable } from "../../delivery/tables";
30
+ import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
31
+ import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
32
+ import { createTemplateResolverFeature } from "../../template-resolver/feature";
33
+ import { createTenantFeature } from "../../tenant";
34
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
35
+ import { tenantEntity } from "../../tenant/schema/tenant";
36
+ import { createUserFeature } from "../../user/feature";
37
+ import { userEntity, userTable } from "../../user/schema/user";
38
+ import { AuthHandlers } from "../constants";
39
+ import { createAuthEmailPasswordFeature } from "../feature";
40
+ import {
41
+ AUTH_SELF_REGISTRATION_FEATURE,
42
+ createAuthSelfRegistrationToggleFeature,
43
+ } from "../self-registration-toggle";
44
+
45
+ const APP_ACTIVATION_URL = "https://app.example.com/signup/complete";
46
+ const emailTransport = createInMemoryTransport();
47
+
48
+ let stack: TestStack;
49
+ let runtime: GlobalFeatureToggleRuntime;
50
+
51
+ beforeAll(async () => {
52
+ let effective: () => ReadonlySet<string> = () => new Set();
53
+ const runtimeHolder = createLateBoundHolder<GlobalFeatureToggleRuntime>("runtime");
54
+
55
+ stack = await setupTestStack({
56
+ features: [
57
+ createConfigFeature(),
58
+ createUserFeature(),
59
+ createTenantFeature(),
60
+ createTemplateResolverFeature(),
61
+ createRendererFoundationFeature(),
62
+ createDeliveryFeature(),
63
+ createRendererSimpleFeature(),
64
+ createChannelEmailFeature({
65
+ transport: emailTransport,
66
+ renderer: simpleRenderer,
67
+ resolveEmail: async () => "unused@test.local",
68
+ }),
69
+ createAuthEmailPasswordFeature({
70
+ signup: { tokenTtlMinutes: 60, appUrl: APP_ACTIVATION_URL },
71
+ }),
72
+ createAuthSelfRegistrationToggleFeature(),
73
+ createFeatureTogglesFeature({ getRuntime: () => runtimeHolder.get() }),
74
+ ],
75
+ effectiveFeatures: () => effective(),
76
+ extraContext: (deps) => ({
77
+ ...createDeliveryTestContext(deps),
78
+ configResolver: createConfigResolver(),
79
+ }),
80
+ systemHooks: [],
81
+ anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
82
+ authConfig: {
83
+ membershipQuery: "tenant:query:memberships",
84
+ loginHandler: AuthHandlers.login,
85
+ signup: {
86
+ requestHandler: AuthHandlers.signupRequest,
87
+ confirmHandler: AuthHandlers.signupConfirm,
88
+ },
89
+ },
90
+ });
91
+
92
+ await unsafeCreateEntityTable(stack.db, userEntity);
93
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
94
+ await unsafePushTables(stack.db, {
95
+ configValuesTable,
96
+ tenantMembershipsTable,
97
+ notificationPreferencesTable,
98
+ globalFeatureStateTable,
99
+ });
100
+
101
+ runtime = new GlobalFeatureToggleRuntime(stack.db, stack.registry);
102
+ await runtime.initialize();
103
+ effective = runtime.effectiveFeatures;
104
+ runtimeHolder.set(runtime);
105
+ });
106
+
107
+ afterAll(async () => {
108
+ await stack.cleanup();
109
+ });
110
+
111
+ beforeEach(async () => {
112
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
113
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${globalFeatureStateTable.tableName}"`);
114
+ await runtime.refresh();
115
+ emailTransport.sent.length = 0;
116
+ const allKeys = await stack.redis.redis.keys("signup:*");
117
+ if (allKeys.length > 0) await stack.redis.redis.del(...allKeys);
118
+ });
119
+
120
+ async function postSignupRequest(email: string): Promise<Response> {
121
+ return stack.http.raw("POST", "/api/auth/signup-request", { email });
122
+ }
123
+
124
+ describe("auth-self-registration toggle", () => {
125
+ test("default (no override) → signup-request works", async () => {
126
+ const res = await postSignupRequest("alice@example.com");
127
+ expect(res.status).toBe(200);
128
+ expect(emailTransport.sent).toHaveLength(1);
129
+ });
130
+
131
+ test("runtime off → signup-request still 200 (anti-enumeration silent-success) but no mail sent", async () => {
132
+ runtime.apply(AUTH_SELF_REGISTRATION_FEATURE, false);
133
+
134
+ const res = await postSignupRequest("blocked@example.com");
135
+ expect(res.status).toBe(200);
136
+ expect(emailTransport.sent).toHaveLength(0);
137
+ });
138
+
139
+ test("runtime back on → mail sends again", async () => {
140
+ runtime.apply(AUTH_SELF_REGISTRATION_FEATURE, false);
141
+ await postSignupRequest("still-blocked@example.com");
142
+ expect(emailTransport.sent).toHaveLength(0);
143
+
144
+ runtime.apply(AUTH_SELF_REGISTRATION_FEATURE, true);
145
+ const res = await postSignupRequest("reenabled@example.com");
146
+ expect(res.status).toBe(200);
147
+ expect(emailTransport.sent).toHaveLength(1);
148
+ });
149
+
150
+ test("status query reflects the runtime flip", async () => {
151
+ const onRes = await stack.http.raw("POST", "/api/query", {
152
+ type: "auth-email-password:query:signup-registration-status",
153
+ payload: {},
154
+ });
155
+ expect((await onRes.json()) as { data?: { enabled: boolean } }).toMatchObject({
156
+ data: { enabled: true },
157
+ });
158
+
159
+ runtime.apply(AUTH_SELF_REGISTRATION_FEATURE, false);
160
+ const offRes = await stack.http.raw("POST", "/api/query", {
161
+ type: "auth-email-password:query:signup-registration-status",
162
+ payload: {},
163
+ });
164
+ expect((await offRes.json()) as { data?: { enabled: boolean } }).toMatchObject({
165
+ data: { enabled: false },
166
+ });
167
+ });
168
+ });
@@ -26,6 +26,7 @@
26
26
 
27
27
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
28
28
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
29
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
29
30
  import {
30
31
  setupTestStack,
31
32
  type TestStack,
@@ -44,11 +45,18 @@ import { createTemplateResolverFeature } from "../../template-resolver/feature";
44
45
  import { createTenantFeature } from "../../tenant";
45
46
  import { tenantMembershipsTable } from "../../tenant/membership-table";
46
47
  import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
48
+ // kumiko-lint-ignore cross-feature-import regression-proof for #1463: seedTenant
49
+ // must fire tier-engine's entity postSave hook on self-signup, not just on the
50
+ // TenantHandlers.create HTTP path.
51
+ import { tierAssignmentEntity } from "../../tier-engine/entity";
52
+ import { createTierEngineFeature } from "../../tier-engine/feature";
47
53
  import { createUserFeature } from "../../user/feature";
48
54
  import { userEntity, userTable } from "../../user/schema/user";
49
55
  import { AuthErrors, AuthHandlers } from "../constants";
50
56
  import { createAuthEmailPasswordFeature } from "../feature";
51
57
 
58
+ const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
59
+
52
60
  const APP_ACTIVATION_URL = "https://app.example.com/signup/complete";
53
61
 
54
62
  // Activation mails now go through delivery (ctx.notify → channel-email). The
@@ -78,6 +86,12 @@ beforeAll(async () => {
78
86
  createAuthEmailPasswordFeature({
79
87
  signup: { tokenTtlMinutes: 60, appUrl: APP_ACTIVATION_URL },
80
88
  }),
89
+ // Regression-proof for #1463: seedTenant must fire the entity postSave
90
+ // hook on self-signup too, not just on the TenantHandlers.create path.
91
+ createTierEngineFeature({
92
+ defaultTier: "free",
93
+ tierMap: { free: { features: [], caps: {} } },
94
+ }),
81
95
  ],
82
96
  extraContext: (deps) => ({
83
97
  ...createDeliveryTestContext(deps),
@@ -102,6 +116,7 @@ beforeAll(async () => {
102
116
  configValuesTable,
103
117
  tenantMembershipsTable,
104
118
  notificationPreferencesTable,
119
+ tierAssignmentTable,
105
120
  });
106
121
  });
107
122
 
@@ -112,6 +127,7 @@ afterAll(async () => {
112
127
  beforeEach(async () => {
113
128
  await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
114
129
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
130
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tierAssignmentTable.tableName}"`);
115
131
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
116
132
  emailTransport.sent.length = 0;
117
133
  // Redis-cleanup damit Resend-Tests keine state-leaks haben.
@@ -220,6 +236,15 @@ describe("POST /api/auth/signup-confirm", () => {
220
236
  expect(JSON.parse(rolesRaw) as string[]).toContain("Admin");
221
237
  }
222
238
 
239
+ // #1463 regression: seedTenant fires the tenant entity's postSave
240
+ // hooks — tier-engine's auto-default-tier hook must run on self-signup
241
+ // exactly like it does on the regular TenantHandlers.create HTTP path.
242
+ const tierRows = await selectMany(stack.db, tierAssignmentTable, {
243
+ tenantId: body.user?.tenantId ?? "",
244
+ });
245
+ expect(tierRows).toHaveLength(1);
246
+ expect(tierRows[0]?.["tier"]).toBe("free");
247
+
223
248
  // Authority-Beweis: Login mit dem gesetzten Password funktioniert.
224
249
  const loginRes = await postLogin(email, password);
225
250
  expect(loginRes.status).toBe(200);
@@ -46,6 +46,12 @@ export const AuthHandlers = {
46
46
  inviteCancel: "auth-email-password:write:invite-cancel",
47
47
  } as const;
48
48
 
49
+ // Qualified query names. Anonymous-readable status so the (unauthenticated)
50
+ // signup page can decide whether to show its own link/form.
51
+ export const AuthQueries = {
52
+ signupRegistrationStatus: "auth-email-password:query:signup-registration-status",
53
+ } as const;
54
+
49
55
  // Error codes — kept intentionally generic so clients can't distinguish
50
56
  // "email doesn't exist" from "password wrong". Both surface as invalid_credentials.
51
57
  // Soft-deleted users also collapse into invalid_credentials to avoid enumeration.
@@ -20,6 +20,7 @@ import {
20
20
  import { createRequestEmailVerificationHandler } from "./handlers/request-email-verification.write";
21
21
  import { createRequestPasswordResetHandler } from "./handlers/request-password-reset.write";
22
22
  import { createResetPasswordHandler } from "./handlers/reset-password.write";
23
+ import { selfRegistrationStatusQuery } from "./handlers/self-registration-status.query";
23
24
  import { createSignupConfirmHandler } from "./handlers/signup-confirm.write";
24
25
  import {
25
26
  createSignupRequestHandler,
@@ -223,6 +224,12 @@ export function createAuthEmailPasswordFeature(
223
224
  r.writeHandler(createVerifyEmailHandler(opts.emailVerification));
224
225
  }
225
226
 
227
+ const queries = {
228
+ ...(opts.signup && {
229
+ signupRegistrationStatus: r.queryHandler(selfRegistrationStatusQuery),
230
+ }),
231
+ };
232
+
226
233
  if (opts.signup) {
227
234
  r.writeHandler(createSignupRequestHandler(opts.signup));
228
235
  r.writeHandler(createSignupConfirmHandler());
@@ -240,6 +247,6 @@ export function createAuthEmailPasswordFeature(
240
247
  r.writeHandler(createConfirmAccountUnlockHandler(opts.accountUnlock));
241
248
  }
242
249
 
243
- return { handlers };
250
+ return { handlers, queries };
244
251
  });
245
252
  }
@@ -0,0 +1,20 @@
1
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { z } from "zod";
3
+ import { AUTH_SELF_REGISTRATION_FEATURE } from "../self-registration-toggle";
4
+
5
+ // Anonymous-readable status for the (unauthenticated) signup page: lets it
6
+ // hide its own link/form when an operator has flipped self-registration
7
+ // off, instead of collecting an email that signup-request will silently
8
+ // no-op on. Lives in auth-email-password itself (never toggleable) rather
9
+ // than on the auth-self-registration companion feature — the dispatcher's
10
+ // per-feature gate would otherwise make this query unreachable exactly when
11
+ // the toggle is off (same split as managed-pages' branding query vs.
12
+ // css-gate.ts).
13
+ export const selfRegistrationStatusQuery = defineQueryHandler({
14
+ name: "signup-registration-status",
15
+ schema: z.object({}),
16
+ access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
17
+ handler: async (_query, ctx) => ({
18
+ enabled: await ctx.hasFeature(AUTH_SELF_REGISTRATION_FEATURE),
19
+ }),
20
+ });
@@ -109,16 +109,23 @@ export function createSignupConfirmHandler() {
109
109
 
110
110
  let provisioned: { readonly userId: string; readonly tenantId: TenantId };
111
111
  try {
112
- provisioned = await provisionSignupAccount(dbConn, {
113
- email,
114
- password: event.payload.password,
115
- displayName,
116
- tenantId,
117
- tenantKey,
118
- // Tenant-Display-Name als Default = Email. User wechselt das im
119
- // Settings-Screen. Konzept "Tenant" leakt nicht in die Signup-UI.
120
- tenantName: email,
121
- });
112
+ provisioned = await provisionSignupAccount(
113
+ dbConn,
114
+ {
115
+ email,
116
+ password: event.payload.password,
117
+ displayName,
118
+ tenantId,
119
+ tenantKey,
120
+ // Tenant-Display-Name als Default = Email. User wechselt das im
121
+ // Settings-Screen. Konzept "Tenant" leakt nicht in die Signup-UI.
122
+ tenantName: email,
123
+ },
124
+ // #1463: seedTenant's postSave hooks (tier-engine's auto-default-
125
+ // tier, an app's auto-default-compliance) must fire on self-signup
126
+ // exactly like they do on the regular TenantHandlers.create path.
127
+ { registry: ctx.registry, context: ctx },
128
+ );
122
129
  } catch (err) {
123
130
  // Email hat bereits ein Konto — provisionSignupAccount ist create-only
124
131
  // (#365): sauberer User-Fehler, KEINE Session für den fremden Account.
@@ -31,6 +31,7 @@ import { AUTH_SIGNUP_DEFAULT_TTL_MINUTES } from "../constants";
31
31
  import type { AuthMailLocale } from "../email-templates";
32
32
  import { renderActivationEmail } from "../email-templates";
33
33
  import { dispatchMagicLinkMail } from "../magic-link-mail";
34
+ import { AUTH_SELF_REGISTRATION_FEATURE } from "../self-registration-toggle";
34
35
  import { getTokenForSignupEmail, normalizeEmail, storeSignupToken } from "../signup-token-store";
35
36
 
36
37
  const SIGNUP_NOTIFICATION_TYPE = "auth-email-password:signup-activation";
@@ -68,6 +69,16 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
68
69
  schema: SignupRequestSchema,
69
70
  access: { roles: ["all"] },
70
71
  handler: async (event, ctx) => {
72
+ // Silent no-op when off, matching the route's own always-200
73
+ // anti-enumeration contract (registerTokenRequestRoute swallows every
74
+ // handler failure into `{isSuccess:true}` regardless) — no mail goes
75
+ // out, but the caller can't distinguish "disabled" from "unknown
76
+ // email" either way. The client-visible signal is the `status` query
77
+ // on auth-self-registration, which the signup page uses to hide its
78
+ // own link/form instead of collecting input that silently no-ops.
79
+ if (!(await ctx.hasFeature(AUTH_SELF_REGISTRATION_FEATURE))) {
80
+ return { isSuccess: true, data: { kind: "no-op" } };
81
+ }
71
82
  if (!ctx.redis) {
72
83
  return writeFailure(
73
84
  new InternalError({
@@ -7,7 +7,7 @@
7
7
  // its own changeset + deliberate deprecation window, not a silent drop.
8
8
  export { hashPassword, verifyPassword } from "../shared/password-hashing";
9
9
  export { type AuthPaths, DEFAULT_AUTH_PATHS, makeAuthPaths } from "./auth-paths";
10
- export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers } from "./constants";
10
+ export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers, AuthQueries } from "./constants";
11
11
  // Renderers for the auth mails. All four magic-link flows (reset, verify,
12
12
  // signup-activation, invite) emit structured AuthMailContent through delivery
13
13
  // (ctx.notify).
@@ -35,6 +35,10 @@ export type {
35
35
  SignupOptions,
36
36
  } from "./feature";
37
37
  export { authEmailPasswordEnvSchema, createAuthEmailPasswordFeature } from "./feature";
38
+ export {
39
+ AUTH_SELF_REGISTRATION_FEATURE,
40
+ createAuthSelfRegistrationToggleFeature,
41
+ } from "./self-registration-toggle";
38
42
  // Generic HMAC-signed single-purpose token helpers. Re-exported damit
39
43
  // app-spezifische out-of-band-Flows (subscriber-confirm, magic-links,
40
44
  // invite-tokens) denselben battle-tested signer/verifier nutzen können
@@ -19,7 +19,7 @@ import { ConflictError } from "@cosmicdrift/kumiko-framework/errors";
19
19
  import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
20
20
  import { hashPassword } from "../shared";
21
21
  // kumiko-lint-ignore cross-feature-import auth-tests need user+tenant seed-helpers
22
- import { seedTenant, seedTenantMembership } from "../tenant/seeding";
22
+ import { type SeedTenantHooks, seedTenant, seedTenantMembership } from "../tenant/seeding";
23
23
  // kumiko-lint-ignore cross-feature-import signup create-only guard reads the user projection by email
24
24
  import { userTable } from "../user/schema/user";
25
25
  // kumiko-lint-ignore cross-feature-import auth-tests need user+tenant seed-helpers
@@ -115,6 +115,12 @@ export type ProvisionSignupAccountOptions = {
115
115
  export async function provisionSignupAccount(
116
116
  db: DbConnection,
117
117
  options: ProvisionSignupAccountOptions,
118
+ // Optional, append-only — existing callers (tests, seed scripts) that
119
+ // don't pass hooks keep today's behavior. The real signup-confirm
120
+ // handler DOES pass it (#1463) so seedTenant's postSave hooks
121
+ // (tier-engine's auto-default-tier, app auto-default-compliance) fire
122
+ // on self-signup, same as they do on the regular tenant-create path.
123
+ hooks?: SeedTenantHooks,
118
124
  ): Promise<{ readonly userId: string; readonly tenantId: TenantId }> {
119
125
  // Create-only-Guard VOR seedTenant: bei bereits registrierter Email hart
120
126
  // abbrechen, sonst entstünde ein verwaister Tenant und seedUser (idempotent
@@ -124,11 +130,15 @@ export async function provisionSignupAccount(
124
130
  if (existingUser) {
125
131
  throw new ConflictError({ message: "signup: email already registered" });
126
132
  }
127
- await seedTenant(db, {
128
- id: options.tenantId,
129
- key: options.tenantKey,
130
- name: options.tenantName,
131
- });
133
+ await seedTenant(
134
+ db,
135
+ {
136
+ id: options.tenantId,
137
+ key: options.tenantKey,
138
+ name: options.tenantName,
139
+ },
140
+ hooks,
141
+ );
132
142
  const { id: userId } = await seedUserWithPassword(db, {
133
143
  email: options.email,
134
144
  password: options.password,
@@ -0,0 +1,25 @@
1
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ // Companion toggle for the auth-email-password self-signup flow. Handler-less
4
+ // (like managed-pages-css/css-gate.ts): composing it just registers
5
+ // "auth-self-registration" as toggleable (default ON) so an operator can
6
+ // flip it at runtime via feature-toggles, without redeploying and without
7
+ // making the rest of auth-email-password (login/reset/verify) toggleable.
8
+ //
9
+ // Deliberately owns NO handlers — the dispatcher's per-feature gate blocks
10
+ // every handler belonging to a disabled feature, and a `status` query living
11
+ // here would become unreachable exactly when it matters most (disabled).
12
+ // The signup-request handler and the `signupRegistrationStatus` query both
13
+ // live in auth-email-password itself and read `ctx.hasFeature(...)` against
14
+ // this feature name instead — same split as managed-pages/css-gate.ts vs.
15
+ // managed-pages' branding query.
16
+ export const AUTH_SELF_REGISTRATION_FEATURE = "auth-self-registration";
17
+
18
+ export function createAuthSelfRegistrationToggleFeature(): FeatureDefinition {
19
+ return defineFeature(AUTH_SELF_REGISTRATION_FEATURE, (r) => {
20
+ r.describe(
21
+ 'Runtime on/off switch for the auth-email-password self-signup flow. Handler-less: composing it registers "auth-self-registration" as a toggleable feature (default ON). auth-email-password\'s signup-request handler and its `signupRegistrationStatus` query both read `ctx.hasFeature("auth-self-registration")` — the query stays reachable when the toggle is off (deliberately not gated itself) so the public signup page can hide its own link/form. Only meaningful when `signup` is configured on `createAuthEmailPasswordFeature` — compose alongside it, then flip via the feature-toggles admin screen.',
22
+ );
23
+ r.toggleable({ default: true });
24
+ });
25
+ }
@@ -70,6 +70,43 @@ describe("createLoginRoute", () => {
70
70
  return <div data-testid="mfa-verify">{challengeToken}</div>;
71
71
  }
72
72
 
73
+ function LoginWithMfaSetupTrigger({
74
+ onMfaSetupRequired,
75
+ }: {
76
+ readonly onMfaSetupRequired?: (preauthSetupToken: string, accountLabel: string) => void;
77
+ }): ReactNode {
78
+ return (
79
+ <button
80
+ type="button"
81
+ data-testid="trigger-mfa-setup"
82
+ onClick={() => onMfaSetupRequired?.("setup-token-123", "user@example.com")}
83
+ >
84
+ trigger
85
+ </button>
86
+ );
87
+ }
88
+
89
+ function CustomMfaSetup({
90
+ preauthSetupToken,
91
+ accountLabel,
92
+ onSuccess,
93
+ }: {
94
+ readonly preauthSetupToken: string;
95
+ readonly accountLabel: string;
96
+ readonly onSuccess?: () => void;
97
+ }): ReactNode {
98
+ return (
99
+ <div data-testid="mfa-setup">
100
+ <span data-testid="mfa-setup-info">
101
+ {preauthSetupToken}:{accountLabel}
102
+ </span>
103
+ <button type="button" data-testid="complete-mfa-setup" onClick={() => onSuccess?.()}>
104
+ complete
105
+ </button>
106
+ </div>
107
+ );
108
+ }
109
+
73
110
  test("authenticated + onAuthenticated → renders nothing, fires onAuthenticated exactly once", () => {
74
111
  const onAuthenticated = mock(() => {});
75
112
  const LoginRoute = createLoginRoute({ loginScreen: CustomLogin, onAuthenticated });
@@ -109,4 +146,46 @@ describe("createLoginRoute", () => {
109
146
  fireEvent.click(screen.getByTestId("trigger-mfa"));
110
147
  expect(screen.getByTestId("mfa-verify").textContent).toBe("token-123");
111
148
  });
149
+
150
+ test("onMfaSetupRequired → renders MfaSetupComponent with token and accountLabel", () => {
151
+ const LoginRoute = createLoginRoute({
152
+ loginScreen: LoginWithMfaSetupTrigger,
153
+ mfaSetupScreen: CustomMfaSetup,
154
+ });
155
+ const session = makeSessionApi({ status: "unauthenticated" });
156
+ renderWithProviders(<LoginRoute />, { session });
157
+ fireEvent.click(screen.getByTestId("trigger-mfa-setup"));
158
+ expect(screen.getByTestId("mfa-setup-info").textContent).toBe(
159
+ "setup-token-123:user@example.com",
160
+ );
161
+ });
162
+
163
+ test("MfaSetupComponent onSuccess → gate clears the request and refreshes the session", () => {
164
+ const LoginRoute = createLoginRoute({
165
+ loginScreen: LoginWithMfaSetupTrigger,
166
+ mfaSetupScreen: CustomMfaSetup,
167
+ });
168
+ const session = makeSessionApi({ status: "unauthenticated" });
169
+ renderWithProviders(<LoginRoute />, { session });
170
+ fireEvent.click(screen.getByTestId("trigger-mfa-setup"));
171
+ expect(screen.getByTestId("mfa-setup")).toBeTruthy();
172
+ fireEvent.click(screen.getByTestId("complete-mfa-setup"));
173
+ expect(session.refresh).toHaveBeenCalledTimes(1);
174
+ expect(screen.queryByTestId("mfa-setup")).toBeNull();
175
+ });
176
+
177
+ test("makeAuthGate delegates mfaSetupScreen wiring to createLoginRoute", () => {
178
+ const Gate = makeAuthGate(LoginWithMfaSetupTrigger, undefined, undefined, CustomMfaSetup);
179
+ const session = makeSessionApi({ status: "unauthenticated" });
180
+ renderWithProviders(
181
+ <Gate>
182
+ <div data-testid="protected">secret</div>
183
+ </Gate>,
184
+ { session },
185
+ );
186
+ fireEvent.click(screen.getByTestId("trigger-mfa-setup"));
187
+ expect(screen.getByTestId("mfa-setup-info").textContent).toBe(
188
+ "setup-token-123:user@example.com",
189
+ );
190
+ });
112
191
  });
@@ -336,7 +336,7 @@ describe("LoginScreen", () => {
336
336
  });
337
337
 
338
338
  test("mfa-setup-required with and without onMfaSetupRequired", async () => {
339
- const onMfaSetupRequired = mock<() => void>();
339
+ const onMfaSetupRequired = mock<(preauthSetupToken: string, accountLabel: string) => void>();
340
340
  const sessionOk = makeSessionApi({
341
341
  status: "unauthenticated",
342
342
  user: null,
@@ -353,7 +353,7 @@ describe("LoginScreen", () => {
353
353
  fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "x" } });
354
354
  fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
355
355
  await waitFor(() => {
356
- expect(onMfaSetupRequired).toHaveBeenCalled();
356
+ expect(onMfaSetupRequired).toHaveBeenCalledWith("setup-token-value", "a@b.c");
357
357
  });
358
358
  unmount();
359
359