@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.
- package/package.json +6 -6
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +168 -0
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +25 -0
- package/src/auth-email-password/constants.ts +6 -0
- package/src/auth-email-password/feature.ts +8 -1
- package/src/auth-email-password/handlers/self-registration-status.query.ts +20 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +17 -10
- package/src/auth-email-password/handlers/signup-request.write.ts +11 -0
- package/src/auth-email-password/index.ts +5 -1
- package/src/auth-email-password/seeding.ts +16 -6
- package/src/auth-email-password/self-registration-toggle.ts +25 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +79 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +2 -2
- package/src/auth-email-password/web/auth-gate.tsx +44 -2
- package/src/auth-email-password/web/login-screen.tsx +8 -5
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +253 -0
- package/src/auth-mfa/__tests__/enable-start-preauth.integration.test.ts +214 -0
- package/src/auth-mfa/constants.ts +2 -0
- package/src/auth-mfa/feature.ts +15 -0
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +170 -0
- package/src/auth-mfa/handlers/enable-start-preauth.write.ts +87 -0
- package/src/auth-mfa/mfa-setup-token.ts +7 -1
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +144 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +202 -0
- package/src/auth-mfa/web/i18n.ts +14 -0
- package/src/auth-mfa/web/index.ts +8 -2
- package/src/auth-mfa/web/mfa-client.ts +113 -0
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +244 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +153 -0
- package/src/feature-toggles/compose-tier-resolver.ts +65 -0
- package/src/feature-toggles/feature.ts +5 -1
- package/src/feature-toggles/global-feature-state-table.ts +16 -0
- package/src/feature-toggles/index.ts +2 -0
- package/src/feature-toggles/toggle-runtime.ts +14 -0
- 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.
|
|
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.
|
|
121
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
122
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
123
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
124
|
-
"@cosmicdrift/kumiko-renderer-web": "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(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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).
|
|
356
|
+
expect(onMfaSetupRequired).toHaveBeenCalledWith("setup-token-value", "a@b.c");
|
|
357
357
|
});
|
|
358
358
|
unmount();
|
|
359
359
|
|