@cosmicdrift/kumiko-bundled-features 0.48.1 → 0.51.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 +9 -6
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +51 -0
- package/src/auth-email-password/constants.ts +6 -0
- package/src/auth-email-password/errors.ts +19 -0
- package/src/auth-email-password/handlers/request-email-verification.write.ts +1 -0
- package/src/auth-email-password/handlers/request-password-reset.write.ts +1 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +22 -12
- package/src/auth-email-password/handlers/signup-request.write.ts +12 -10
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/auth-email-password/password-hashing.ts +1 -0
- package/src/auth-email-password/reset-token.ts +2 -0
- package/src/auth-email-password/seeding.ts +19 -4
- package/src/auth-email-password/signup-token-store.ts +1 -0
- package/src/auth-email-password/verification-token.ts +2 -0
- package/src/billing-foundation/aggregate-id.ts +1 -0
- package/src/cap-counter/aggregate-id.ts +2 -0
- package/src/config/__tests__/app-override-visibility.integration.test.ts +143 -0
- package/src/config/__tests__/backing-secrets.integration.test.ts +188 -0
- package/src/config/__tests__/cascade.integration.test.ts +111 -1
- package/src/config/__tests__/config.integration.test.ts +60 -0
- package/src/config/__tests__/env-overrides.test.ts +134 -0
- package/src/config/__tests__/inherited-redaction.integration.test.ts +180 -0
- package/src/config/__tests__/read-redaction.test.ts +112 -0
- package/src/config/__tests__/settings-hub-feature-name.test.ts +14 -0
- package/src/config/constants.ts +3 -1
- package/src/config/feature.ts +5 -2
- package/src/config/handlers/cascade.query.ts +13 -2
- package/src/config/handlers/readiness.query.ts +1 -0
- package/src/config/handlers/reset.write.ts +23 -2
- package/src/config/handlers/set.write.ts +36 -2
- package/src/config/handlers/values.query.ts +39 -13
- package/src/config/index.ts +1 -1
- package/src/config/read-redaction.ts +54 -0
- package/src/config/resolver.ts +163 -4
- package/src/config/web/client-plugin.ts +24 -0
- package/src/config/web/i18n.ts +25 -0
- package/src/config/web/index.ts +3 -0
- package/src/config/write-helpers.ts +37 -0
- package/src/custom-fields/aggregate-id.ts +1 -0
- package/src/custom-fields/wire-for-entity.ts +1 -0
- package/src/delivery/upsert-preference.ts +1 -0
- package/src/file-provider-inmemory/feature.ts +1 -1
- package/src/file-provider-s3/feature.ts +1 -1
- package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +162 -0
- package/src/jobs/feature.ts +13 -0
- package/src/jobs/handlers/projection-rebuild.job.ts +36 -0
- package/src/legal-pages/README.md +16 -13
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +15 -8
- package/src/legal-pages/feature.ts +9 -4
- package/src/legal-pages/markdown.ts +6 -56
- package/src/legal-pages/security-headers.ts +1 -0
- package/src/mail-transport-inmemory/feature.ts +1 -1
- package/src/mail-transport-smtp/feature.ts +1 -1
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +536 -0
- package/src/managed-pages/branding.ts +142 -0
- package/src/managed-pages/css-gate.ts +24 -0
- package/src/managed-pages/feature.ts +246 -0
- package/src/managed-pages/handlers/branding.query.ts +30 -0
- package/src/managed-pages/handlers/by-slug.query.ts +35 -0
- package/src/managed-pages/handlers/set.write.ts +113 -0
- package/src/managed-pages/index.ts +30 -0
- package/src/managed-pages/screens/branding-screen.ts +85 -0
- package/src/managed-pages/screens/page-screens.ts +82 -0
- package/src/managed-pages/seeding.ts +99 -0
- package/src/managed-pages/table.ts +58 -0
- package/src/page-render/__tests__/branding.test.ts +57 -0
- package/src/page-render/__tests__/css-sanitize.test.ts +215 -0
- package/src/page-render/__tests__/markdown.test.ts +41 -0
- package/src/page-render/branding.ts +99 -0
- package/src/page-render/css-sanitize.ts +344 -0
- package/src/page-render/index.ts +13 -0
- package/src/page-render/layout.ts +100 -0
- package/src/page-render/markdown.ts +39 -0
- package/src/page-render/security-headers.ts +16 -0
- package/src/step-dispatcher/mail-runner.ts +1 -0
- package/src/subscription-stripe/runtime.ts +1 -0
- package/src/subscription-stripe/verify-webhook.ts +1 -0
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +48 -0
- package/src/tenant/handlers/list.query.ts +1 -1
- package/src/tenant/handlers/memberships.query.ts +16 -15
- package/src/tenant/handlers/toggle-enabled.write.ts +1 -1
- package/src/tenant/handlers/update.write.ts +1 -1
- package/src/text-content/api.ts +1 -0
- package/src/tier-engine/aggregate-id.ts +1 -0
- package/src/user/handlers/me.query.ts +1 -1
- package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
- package/src/user-data-rights/deletion-token.ts +2 -0
- package/src/user-data-rights/feature.ts +1 -1
- package/src/user-data-rights/run-export-jobs.ts +2 -0
- package/src/user-profile/handlers/change-email.write.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.51.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>",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"./audit": "./src/audit/index.ts",
|
|
22
22
|
"./compliance-profiles": "./src/compliance-profiles/index.ts",
|
|
23
23
|
"./config": "./src/config/index.ts",
|
|
24
|
+
"./config/web": "./src/config/web/index.ts",
|
|
24
25
|
"./data-retention": "./src/data-retention/index.ts",
|
|
25
26
|
"./readiness": "./src/readiness/index.ts",
|
|
26
27
|
"./jobs": "./src/jobs/index.ts",
|
|
@@ -74,14 +75,16 @@
|
|
|
74
75
|
"./renderer-foundation": "./src/renderer-foundation/index.ts",
|
|
75
76
|
"./legal-pages": "./src/legal-pages/index.ts",
|
|
76
77
|
"./legal-pages/web": "./src/legal-pages/web/index.ts",
|
|
78
|
+
"./managed-pages": "./src/managed-pages/index.ts",
|
|
79
|
+
"./managed-pages/seeding": "./src/managed-pages/seeding.ts",
|
|
77
80
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
78
81
|
},
|
|
79
82
|
"dependencies": {
|
|
80
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
81
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
82
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
83
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
84
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
83
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.50.0",
|
|
84
|
+
"@cosmicdrift/kumiko-framework": "0.50.0",
|
|
85
|
+
"@cosmicdrift/kumiko-headless": "0.50.0",
|
|
86
|
+
"@cosmicdrift/kumiko-renderer": "0.50.0",
|
|
87
|
+
"@cosmicdrift/kumiko-renderer-web": "0.50.0",
|
|
85
88
|
"@mollie/api-client": "^4.5.0",
|
|
86
89
|
"@node-rs/argon2": "^2.0.2",
|
|
87
90
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -252,4 +252,55 @@ describe("POST /api/auth/signup-confirm", () => {
|
|
|
252
252
|
}
|
|
253
253
|
expect(new Set(keys).size).toBe(3);
|
|
254
254
|
});
|
|
255
|
+
|
|
256
|
+
test("bereits registrierte Email → 422 signup_email_already_registered, keine Session, kein neuer Tenant/Membership, kein Account-Takeover (#365)", async () => {
|
|
257
|
+
const email = "victim@example.com";
|
|
258
|
+
const victimPassword = "victim-original-pw-1234";
|
|
259
|
+
|
|
260
|
+
// 1. Legitimer Erst-Signup: User + Tenant + Admin-Membership entstehen.
|
|
261
|
+
const firstToken = await requestSignup(email);
|
|
262
|
+
const firstRes = await postSignupConfirm(firstToken, victimPassword);
|
|
263
|
+
expect(firstRes.status).toBe(200);
|
|
264
|
+
const firstBody = (await firstRes.json()) as { user?: { id: string } };
|
|
265
|
+
const victimUserId = firstBody.user?.id ?? "";
|
|
266
|
+
expect(victimUserId).toBeTruthy();
|
|
267
|
+
|
|
268
|
+
const userBefore = await selectMany(stack.db, userTable, { email });
|
|
269
|
+
expect(userBefore).toHaveLength(1);
|
|
270
|
+
const passwordHashBefore = userBefore[0]?.["passwordHash"];
|
|
271
|
+
|
|
272
|
+
// 2. Zweiter Signup-Versuch für DIESELBE Email mit Angreifer-Passwort.
|
|
273
|
+
// Request bleibt always-200 (Anti-Enumeration); nach dem Burn des
|
|
274
|
+
// ersten Tokens mintet er einen frischen.
|
|
275
|
+
const attackerToken = await requestSignup(email);
|
|
276
|
+
const attackerPassword = "attacker-chosen-pw-9999";
|
|
277
|
+
const confirmRes = await postSignupConfirm(attackerToken, attackerPassword);
|
|
278
|
+
|
|
279
|
+
// 3. Sauberer Fehler, KEINE Session (kein auth-Cookie).
|
|
280
|
+
expect(confirmRes.status).toBe(422);
|
|
281
|
+
const body = (await confirmRes.json()) as { error?: { details?: { reason?: string } } };
|
|
282
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.signupEmailAlreadyRegistered);
|
|
283
|
+
expect(confirmRes.headers.get("set-cookie") ?? "").not.toContain("kumiko_auth=");
|
|
284
|
+
|
|
285
|
+
// 4. Kein neuer Tenant (auch kein verwaister), keine neue Membership.
|
|
286
|
+
const allTenants = await selectMany(stack.db, tenantTable);
|
|
287
|
+
expect(allTenants).toHaveLength(1);
|
|
288
|
+
const memberships = await selectMany(stack.db, tenantMembershipsTable, {
|
|
289
|
+
userId: victimUserId,
|
|
290
|
+
});
|
|
291
|
+
expect(memberships).toHaveLength(1);
|
|
292
|
+
|
|
293
|
+
// 5. Account nicht übernommen: ein User, Passwort-Hash unverändert (NICHT
|
|
294
|
+
// auf das Angreifer-Passwort überschrieben).
|
|
295
|
+
const userAfter = await selectMany(stack.db, userTable, { email });
|
|
296
|
+
expect(userAfter).toHaveLength(1);
|
|
297
|
+
expect(userAfter[0]?.["passwordHash"]).toBe(passwordHashBefore);
|
|
298
|
+
|
|
299
|
+
// 6. Authority-Beweis: der bestehende Account hängt weiter am Original-
|
|
300
|
+
// Passwort, das Angreifer-Passwort loggt nicht ein.
|
|
301
|
+
const loginVictim = await postLogin(email, victimPassword);
|
|
302
|
+
expect(loginVictim.status).toBe(200);
|
|
303
|
+
const loginAttacker = await postLogin(email, attackerPassword);
|
|
304
|
+
expect(loginAttacker.status).not.toBe(200);
|
|
305
|
+
});
|
|
255
306
|
});
|
|
@@ -60,6 +60,12 @@ export const AuthErrors = {
|
|
|
60
60
|
// anti-enumeration-Trade-off wie reset/verify.
|
|
61
61
|
invalidSignupToken: "invalid_signup_token",
|
|
62
62
|
signupNotConfigured: "signup_not_configured",
|
|
63
|
+
// Self-Signup: confirm lehnt eine bereits registrierte Email ab statt den
|
|
64
|
+
// bestehenden User wiederzuverwenden (Account-Takeover, #365). KEIN
|
|
65
|
+
// anti-enumeration-collapse wie invalidSignupToken: wer hier ankommt,
|
|
66
|
+
// kontrolliert die Inbox (hat den Magic-Link), das Reveal "Email existiert"
|
|
67
|
+
// ist also keine neue Info.
|
|
68
|
+
signupEmailAlreadyRegistered: "signup_email_already_registered",
|
|
63
69
|
// Invite-Flow: alle Token-Failures collapsen auf invalidInviteToken
|
|
64
70
|
// (anti-enumeration). emailMismatch wenn der invitee versucht den
|
|
65
71
|
// Link mit einer anderen Email zu accepten als die eingeladene.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
2
2
|
import { AuthErrors } from "./constants";
|
|
3
3
|
|
|
4
|
+
// @wrapper-known error-helper
|
|
4
5
|
export function invalidCredentials() {
|
|
5
6
|
return writeFailure(
|
|
6
7
|
new UnprocessableError(AuthErrors.invalidCredentials, {
|
|
@@ -9,6 +10,7 @@ export function invalidCredentials() {
|
|
|
9
10
|
);
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
// @wrapper-known error-helper
|
|
12
14
|
export function invalidInviteToken() {
|
|
13
15
|
return writeFailure(
|
|
14
16
|
new UnprocessableError(AuthErrors.invalidInviteToken, {
|
|
@@ -17,6 +19,7 @@ export function invalidInviteToken() {
|
|
|
17
19
|
);
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
// @wrapper-known error-helper
|
|
20
23
|
export function inviteEmailMismatch() {
|
|
21
24
|
return writeFailure(
|
|
22
25
|
new UnprocessableError(AuthErrors.inviteEmailMismatch, {
|
|
@@ -25,6 +28,7 @@ export function inviteEmailMismatch() {
|
|
|
25
28
|
);
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
// @wrapper-known error-helper
|
|
28
32
|
export function invalidResetToken() {
|
|
29
33
|
return writeFailure(
|
|
30
34
|
new UnprocessableError(AuthErrors.invalidResetToken, {
|
|
@@ -33,6 +37,7 @@ export function invalidResetToken() {
|
|
|
33
37
|
);
|
|
34
38
|
}
|
|
35
39
|
|
|
40
|
+
// @wrapper-known error-helper
|
|
36
41
|
export function invalidVerificationToken() {
|
|
37
42
|
return writeFailure(
|
|
38
43
|
new UnprocessableError(AuthErrors.invalidVerificationToken, {
|
|
@@ -41,6 +46,7 @@ export function invalidVerificationToken() {
|
|
|
41
46
|
);
|
|
42
47
|
}
|
|
43
48
|
|
|
49
|
+
// @wrapper-known error-helper
|
|
44
50
|
export function invalidSignupToken() {
|
|
45
51
|
return writeFailure(
|
|
46
52
|
new UnprocessableError(AuthErrors.invalidSignupToken, {
|
|
@@ -49,6 +55,16 @@ export function invalidSignupToken() {
|
|
|
49
55
|
);
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
// @wrapper-known error-helper
|
|
59
|
+
export function signupEmailAlreadyRegistered() {
|
|
60
|
+
return writeFailure(
|
|
61
|
+
new UnprocessableError(AuthErrors.signupEmailAlreadyRegistered, {
|
|
62
|
+
i18nKey: "auth.errors.signupEmailAlreadyRegistered",
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// @wrapper-known error-helper
|
|
52
68
|
export function noMembership() {
|
|
53
69
|
return writeFailure(
|
|
54
70
|
new UnprocessableError(AuthErrors.noMembership, {
|
|
@@ -57,6 +73,7 @@ export function noMembership() {
|
|
|
57
73
|
);
|
|
58
74
|
}
|
|
59
75
|
|
|
76
|
+
// @wrapper-known error-helper
|
|
60
77
|
export function emailNotVerified() {
|
|
61
78
|
return writeFailure(
|
|
62
79
|
new UnprocessableError(AuthErrors.emailNotVerified, {
|
|
@@ -66,6 +83,7 @@ export function emailNotVerified() {
|
|
|
66
83
|
}
|
|
67
84
|
|
|
68
85
|
// retryAfterSeconds drives the login/signup UI countdown — must stay > 0.
|
|
86
|
+
// @wrapper-known error-helper
|
|
69
87
|
export function accountLocked(retryAfterSeconds: number) {
|
|
70
88
|
return writeFailure(
|
|
71
89
|
new UnprocessableError(AuthErrors.accountLocked, {
|
|
@@ -75,6 +93,7 @@ export function accountLocked(retryAfterSeconds: number) {
|
|
|
75
93
|
);
|
|
76
94
|
}
|
|
77
95
|
|
|
96
|
+
// @wrapper-known error-helper
|
|
78
97
|
export function accountRestricted() {
|
|
79
98
|
return writeFailure(
|
|
80
99
|
new UnprocessableError(AuthErrors.accountRestricted, {
|
|
@@ -10,6 +10,7 @@ export type RequestEmailVerificationOptions = TokenRequestOptions;
|
|
|
10
10
|
|
|
11
11
|
export type RequestVerificationData = TokenRequestData<"verification-requested">;
|
|
12
12
|
|
|
13
|
+
// @wrapper-known semantic-alias
|
|
13
14
|
export function createRequestEmailVerificationHandler(opts: RequestEmailVerificationOptions) {
|
|
14
15
|
return createTokenRequestHandler(
|
|
15
16
|
{
|
|
@@ -12,6 +12,7 @@ export type RequestPasswordResetOptions = TokenRequestOptions;
|
|
|
12
12
|
// the dispatcher (bypassing the framework's auth-routes).
|
|
13
13
|
export type RequestResetData = TokenRequestData<"reset-requested">;
|
|
14
14
|
|
|
15
|
+
// @wrapper-known semantic-alias
|
|
15
16
|
export function createRequestPasswordResetHandler(opts: RequestPasswordResetOptions) {
|
|
16
17
|
return createTokenRequestHandler(
|
|
17
18
|
{
|
|
@@ -26,13 +26,13 @@ import {
|
|
|
26
26
|
type SessionUser,
|
|
27
27
|
type TenantId,
|
|
28
28
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
29
|
-
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
29
|
+
import { ConflictError, InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
30
30
|
import { generateUniqueName } from "@cosmicdrift/kumiko-framework/random";
|
|
31
31
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
32
32
|
import { z } from "zod";
|
|
33
33
|
// kumiko-lint-ignore cross-feature-import signup-confirm reads tenants.key for slug-uniqueness check (TOCTOU + DB-unique-index zusammen)
|
|
34
34
|
import { tenantTable } from "../../tenant/schema/tenant";
|
|
35
|
-
import { invalidSignupToken } from "../errors";
|
|
35
|
+
import { invalidSignupToken, signupEmailAlreadyRegistered } from "../errors";
|
|
36
36
|
// kumiko-lint-ignore cross-feature-import provisioning needs cross-feature seeding helpers
|
|
37
37
|
import { INITIAL_SIGNUP_ROLES, provisionSignupAccount } from "../seeding";
|
|
38
38
|
import {
|
|
@@ -106,16 +106,26 @@ export function createSignupConfirmHandler() {
|
|
|
106
106
|
// den Tenant-Namen + sein eigenes displayName später ändern.
|
|
107
107
|
const displayName = email.split("@")[0] ?? email;
|
|
108
108
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
109
|
+
let provisioned: { readonly userId: string; readonly tenantId: TenantId };
|
|
110
|
+
try {
|
|
111
|
+
provisioned = await provisionSignupAccount(dbConn, {
|
|
112
|
+
email,
|
|
113
|
+
password: event.payload.password,
|
|
114
|
+
displayName,
|
|
115
|
+
tenantId,
|
|
116
|
+
tenantKey,
|
|
117
|
+
// Tenant-Display-Name als Default = Email. User wechselt das im
|
|
118
|
+
// Settings-Screen. Konzept "Tenant" leakt nicht in die Signup-UI.
|
|
119
|
+
tenantName: email,
|
|
120
|
+
});
|
|
121
|
+
} catch (err) {
|
|
122
|
+
// Email hat bereits ein Konto — provisionSignupAccount ist create-only
|
|
123
|
+
// (#365): sauberer User-Fehler, KEINE Session für den fremden Account.
|
|
124
|
+
// committed bleibt false → der burn wird im finally released (ein
|
|
125
|
+
// Retry scheitert wieder gleich, kein stale-Marker-Block).
|
|
126
|
+
if (err instanceof ConflictError) return signupEmailAlreadyRegistered();
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
119
129
|
|
|
120
130
|
// Cleanup beider Token-Lookup-Keys. Burn-Key bleibt für die
|
|
121
131
|
// restliche Burn-TTL als Replay-Schutz.
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
//
|
|
3
3
|
// User gibt Email ein → wir minten einen opaken Random-Token, speichern
|
|
4
4
|
// ihn bidirektional in Redis (token↔email), und der Route-Layer schickt
|
|
5
|
-
// die Activation-Mail. Anders als reset/verify-Flows
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// die Activation-Mail. Anders als reset/verify-Flows machen wir HIER keinen
|
|
6
|
+
// userId-Lookup und kein HMAC-signing (es gäbe kein Subject — im Normalfall
|
|
7
|
+
// existiert der User noch nicht). Ob die Email bereits ein Konto hat,
|
|
8
|
+
// entscheidet bewusst der Confirm-Schritt, nicht dieser.
|
|
8
9
|
//
|
|
9
10
|
// Resend-Idempotenz: wenn für die Email bereits ein lebender Token in
|
|
10
11
|
// Redis liegt, geben wir denselben Token zurück (und refreshen TTL auf
|
|
@@ -12,13 +13,14 @@
|
|
|
12
13
|
// Activation-Link. Erste Mail bleibt gültig — kein "old link broken"-
|
|
13
14
|
// annoyance.
|
|
14
15
|
//
|
|
15
|
-
// Always-200 (enumeration-safe): das Response sieht für jede Email
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
16
|
+
// Always-200 (enumeration-safe): das Response sieht für jede Email gleich
|
|
17
|
+
// aus, egal ob sie schon registriert ist oder nicht. Eine Email KANN bereits
|
|
18
|
+
// ein Konto haben (Seeding oder früherer Signup) — die Sperre dagegen sitzt
|
|
19
|
+
// bewusst im Confirm-Schritt (#365): signup-confirm lehnt eine bereits
|
|
20
|
+
// registrierte Email ab statt den bestehenden User wiederzuverwenden. Hier
|
|
21
|
+
// bleibt's always-200 + Resend-idempotent, damit der Request-Pfad nichts
|
|
22
|
+
// leakt; ein request-seitiges Unterdrücken des Links wäre Defense-in-depth,
|
|
23
|
+
// aber mit Enumeration-Risiko (separat).
|
|
22
24
|
|
|
23
25
|
import { generateToken } from "@cosmicdrift/kumiko-framework/api";
|
|
24
26
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -37,6 +37,8 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
37
37
|
"auth.errors.invalidVerificationToken": "Der Bestätigungs-Link ist ungültig oder abgelaufen.",
|
|
38
38
|
"auth.errors.invalidSignupToken":
|
|
39
39
|
"Der Aktivierungs-Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.",
|
|
40
|
+
"auth.errors.signupEmailAlreadyRegistered":
|
|
41
|
+
"Für diese E-Mail-Adresse existiert bereits ein Konto. Bitte logge dich ein oder setze dein Passwort zurück.",
|
|
40
42
|
"auth.errors.unknownError": "Etwas ist schief gegangen. Bitte erneut versuchen.",
|
|
41
43
|
"auth.forgotPassword.title": "Passwort zurücksetzen",
|
|
42
44
|
"auth.forgotPassword.intro":
|
|
@@ -135,6 +137,8 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
135
137
|
"auth.errors.invalidVerificationToken": "Verification link is invalid or expired.",
|
|
136
138
|
"auth.errors.invalidSignupToken":
|
|
137
139
|
"Activation link is invalid or expired. Please request a new one.",
|
|
140
|
+
"auth.errors.signupEmailAlreadyRegistered":
|
|
141
|
+
"An account already exists for this email. Please sign in or reset your password.",
|
|
138
142
|
"auth.errors.unknownError": "Something went wrong. Please try again.",
|
|
139
143
|
"auth.forgotPassword.title": "Reset password",
|
|
140
144
|
"auth.forgotPassword.intro":
|
|
@@ -10,6 +10,7 @@ export type VerifyResult =
|
|
|
10
10
|
| { readonly ok: true; readonly userId: string; readonly expiresAtMs: number }
|
|
11
11
|
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
12
12
|
|
|
13
|
+
// @wrapper-known semantic-alias
|
|
13
14
|
export function signResetToken(
|
|
14
15
|
userId: string,
|
|
15
16
|
ttlMinutes: number,
|
|
@@ -19,6 +20,7 @@ export function signResetToken(
|
|
|
19
20
|
return signToken(userId, TokenPurpose.passwordReset, ttlMinutes, secret, now);
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
// @wrapper-known semantic-alias
|
|
22
24
|
export function verifyResetToken(
|
|
23
25
|
token: string,
|
|
24
26
|
secret: string,
|
|
@@ -12,11 +12,15 @@
|
|
|
12
12
|
// Damit Sample-Server und Tests keine drei sub-paths zusammensammeln
|
|
13
13
|
// müssen.
|
|
14
14
|
|
|
15
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
16
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
16
17
|
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
+
import { ConflictError } from "@cosmicdrift/kumiko-framework/errors";
|
|
17
19
|
import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
18
20
|
// kumiko-lint-ignore cross-feature-import auth-tests need user+tenant seed-helpers
|
|
19
21
|
import { seedTenant, seedTenantMembership } from "../tenant/seeding";
|
|
22
|
+
// kumiko-lint-ignore cross-feature-import signup create-only guard reads the user projection by email
|
|
23
|
+
import { userTable } from "../user/schema/user";
|
|
20
24
|
// kumiko-lint-ignore cross-feature-import auth-tests need user+tenant seed-helpers
|
|
21
25
|
import { seedUser } from "../user/seeding";
|
|
22
26
|
import { hashPassword } from "./password-hashing";
|
|
@@ -84,10 +88,13 @@ export async function seedUserWithPassword(
|
|
|
84
88
|
* ein orphan-Tenant zurückbleiben (Tenant ohne User → unused row;
|
|
85
89
|
* User ohne Membership → "no_membership" beim ersten Login).
|
|
86
90
|
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
+
* Create-only: existiert bereits ein User mit dieser Email (Seeding oder
|
|
92
|
+
* früherer Signup), wirft die Funktion einen ConflictError BEVOR ein Tenant
|
|
93
|
+
* entsteht. Der Self-Signup-Pfad darf einen bestehenden User nicht still
|
|
94
|
+
* wiederverwenden — sonst würde der Confirm-Schritt eine Session für ihn
|
|
95
|
+
* minten (Account-Takeover, #365). `seedUser` selbst bleibt idempotent
|
|
96
|
+
* add-only (für Bootstrap/Tests); die create-only-Garantie sitzt hier.
|
|
97
|
+
* tenantKey-Kollisionen löst der Caller via generateUniqueName. */
|
|
91
98
|
/** Default-Roles für den Self-Signup-Admin. Geteilt zwischen
|
|
92
99
|
* provisionSignupAccount (DB-write) und signup-confirm-handler
|
|
93
100
|
* (SessionUser-Konstruktion für JWT-Mint) — sonst hätten zwei
|
|
@@ -109,6 +116,14 @@ export async function provisionSignupAccount(
|
|
|
109
116
|
db: DbConnection,
|
|
110
117
|
options: ProvisionSignupAccountOptions,
|
|
111
118
|
): Promise<{ readonly userId: string; readonly tenantId: TenantId }> {
|
|
119
|
+
// Create-only-Guard VOR seedTenant: bei bereits registrierter Email hart
|
|
120
|
+
// abbrechen, sonst entstünde ein verwaister Tenant und seedUser (idempotent
|
|
121
|
+
// add-only) gäbe still die bestehende userId zurück → Confirm mintet eine
|
|
122
|
+
// Session für den fremden Account (#365).
|
|
123
|
+
const existingUser = await fetchOne(db, userTable, { email: options.email });
|
|
124
|
+
if (existingUser) {
|
|
125
|
+
throw new ConflictError({ message: "signup: email already registered" });
|
|
126
|
+
}
|
|
112
127
|
await seedTenant(db, {
|
|
113
128
|
id: options.tenantId,
|
|
114
129
|
key: options.tenantKey,
|
|
@@ -43,6 +43,7 @@ export function normalizeEmail(email: string): string {
|
|
|
43
43
|
function tokenKey(token: string): string {
|
|
44
44
|
return `${TOKEN_KEY_PREFIX}${token}`;
|
|
45
45
|
}
|
|
46
|
+
// @wrapper-known semantic-alias
|
|
46
47
|
function emailKey(email: string): string {
|
|
47
48
|
return `${EMAIL_KEY_PREFIX}${normalizeEmail(email)}`;
|
|
48
49
|
}
|
|
@@ -9,6 +9,7 @@ export type VerifyResult =
|
|
|
9
9
|
| { readonly ok: true; readonly userId: string; readonly expiresAtMs: number }
|
|
10
10
|
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
11
11
|
|
|
12
|
+
// @wrapper-known semantic-alias
|
|
12
13
|
export function signVerificationToken(
|
|
13
14
|
userId: string,
|
|
14
15
|
ttlMinutes: number,
|
|
@@ -18,6 +19,7 @@ export function signVerificationToken(
|
|
|
18
19
|
return signToken(userId, TokenPurpose.emailVerification, ttlMinutes, secret, now);
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
// @wrapper-known semantic-alias
|
|
21
23
|
export function verifyVerificationToken(
|
|
22
24
|
token: string,
|
|
23
25
|
secret: string,
|
|
@@ -16,6 +16,7 @@ const SUBSCRIPTION_NAMESPACE = "5c3b2d1e-9a4f-4e8c-b7a3-1f8d6c2e9a4b";
|
|
|
16
16
|
* Stripe→Mollie-Migration) appended einen neuen event auf denselben
|
|
17
17
|
* Stream — selber Tenant, selber Aggregate-Stream.
|
|
18
18
|
*/
|
|
19
|
+
// @wrapper-known uuid-domain
|
|
19
20
|
export function subscriptionAggregateId(tenantId: string): string {
|
|
20
21
|
return uuidv5(tenantId, SUBSCRIPTION_NAMESPACE);
|
|
21
22
|
}
|
|
@@ -32,6 +32,7 @@ const CAP_COUNTER_ROLLING_NAMESPACE = "8b2ad0c6-1f3e-4f7c-9b8a-3c4d5e6f7a8b";
|
|
|
32
32
|
* vom event-store optimistic-lock serialisiert (version_conflict bei
|
|
33
33
|
* Race → Caller-side Retry).
|
|
34
34
|
*/
|
|
35
|
+
// @wrapper-known uuid-domain
|
|
35
36
|
export function capCounterAggregateId(
|
|
36
37
|
tenantId: string,
|
|
37
38
|
capName: string,
|
|
@@ -56,6 +57,7 @@ export function capCounterAggregateId(
|
|
|
56
57
|
* tenantId + capName auf, erzeugt Increment-Events am stream. Race-
|
|
57
58
|
* frei: der event-store hängt mit auto-incrementing version an.
|
|
58
59
|
*/
|
|
60
|
+
// @wrapper-known uuid-domain
|
|
59
61
|
export function rollingCapAggregateId(tenantId: string, capName: string): string {
|
|
60
62
|
return uuidv5(`${tenantId}|${capName}`, CAP_COUNTER_ROLLING_NAMESPACE);
|
|
61
63
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import {
|
|
4
|
+
access,
|
|
5
|
+
type ConfigCascade,
|
|
6
|
+
createTenantConfig,
|
|
7
|
+
defineFeature,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import {
|
|
10
|
+
createTestUser,
|
|
11
|
+
setupTestStack,
|
|
12
|
+
type TestStack,
|
|
13
|
+
TestUsers,
|
|
14
|
+
unsafePushTables,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
16
|
+
import { ConfigHandlers, ConfigQueries } from "../constants";
|
|
17
|
+
import { createConfigAccessorFactory, createConfigFeature } from "../feature";
|
|
18
|
+
import { buildEnvConfigOverrides, createConfigResolver } from "../resolver";
|
|
19
|
+
import { configValuesTable } from "../table";
|
|
20
|
+
|
|
21
|
+
// Proves the ENV→app-override bridge end-to-end over real HTTP:
|
|
22
|
+
// - a transparently-inherited key (default inheritedToTenant) surfaces the
|
|
23
|
+
// ENV-bridged app-override to a tenant (the D3 fix — values.query used to
|
|
24
|
+
// fall to keyDef.default and never showed the inherited app-override);
|
|
25
|
+
// - an inheritedToTenant:false key must NOT leak the platform ENV value to a
|
|
26
|
+
// tenant-side viewer through the app-override rung (the regression guard
|
|
27
|
+
// for the #376 redaction, which previously only stripped system-row);
|
|
28
|
+
// - a tenant's own row still beats the app-override.
|
|
29
|
+
|
|
30
|
+
let stack: TestStack;
|
|
31
|
+
let db: DbConnection;
|
|
32
|
+
|
|
33
|
+
const systemAdmin = TestUsers.systemAdmin; // roles ["SystemAdmin"]
|
|
34
|
+
const tenantAdmin = createTestUser({ id: 2 }); // roles ["Admin"]
|
|
35
|
+
|
|
36
|
+
const PAGE_SIZE = "appcfg:config:page-size";
|
|
37
|
+
const API_BASE = "appcfg:config:api-base";
|
|
38
|
+
|
|
39
|
+
const FAKE_ENV = {
|
|
40
|
+
APPCFG_PAGE_SIZE: "25",
|
|
41
|
+
APPCFG_API_BASE: "https://internal.example.com",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const appcfgFeature = defineFeature("appcfg", (r) => {
|
|
45
|
+
r.requires("config");
|
|
46
|
+
return r.config({
|
|
47
|
+
keys: {
|
|
48
|
+
// Transparent inheritance + ENV-bridged: a tenant sees the platform's
|
|
49
|
+
// ENV default until it sets its own value.
|
|
50
|
+
pageSize: createTenantConfig("number", {
|
|
51
|
+
env: "APPCFG_PAGE_SIZE",
|
|
52
|
+
default: 10,
|
|
53
|
+
read: access.admin,
|
|
54
|
+
write: access.admin,
|
|
55
|
+
}),
|
|
56
|
+
// inheritedToTenant:false + ENV-bridged: the platform value must stay
|
|
57
|
+
// hidden from tenant-side viewers — including via app-override.
|
|
58
|
+
apiBase: createTenantConfig("text", {
|
|
59
|
+
env: "APPCFG_API_BASE",
|
|
60
|
+
inheritedToTenant: false,
|
|
61
|
+
read: access.admin,
|
|
62
|
+
write: access.systemAdmin,
|
|
63
|
+
}),
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
type Values = Record<string, { value: unknown; source: string }>;
|
|
69
|
+
type Cascades = Record<string, ConfigCascade>;
|
|
70
|
+
const overrideLevel = (c: Cascades, key: string) =>
|
|
71
|
+
c[key]?.levels.find((l) => l.source === "app-override");
|
|
72
|
+
|
|
73
|
+
beforeAll(async () => {
|
|
74
|
+
stack = await setupTestStack({
|
|
75
|
+
features: [createConfigFeature(), appcfgFeature],
|
|
76
|
+
extraContext: ({ registry }) => {
|
|
77
|
+
const resolver = createConfigResolver({
|
|
78
|
+
appOverrides: buildEnvConfigOverrides(registry, FAKE_ENV),
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
configResolver: resolver,
|
|
82
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
db = stack.db;
|
|
87
|
+
await unsafePushTables(db, { configValuesTable });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterAll(async () => {
|
|
91
|
+
await stack.cleanup();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("ENV→app-override bridge — config:query:values", () => {
|
|
95
|
+
test("D3: a transparently-inherited key surfaces the ENV app-override, not keyDef.default", async () => {
|
|
96
|
+
const res = await stack.http.queryOk<Values>(ConfigQueries.values, {}, tenantAdmin);
|
|
97
|
+
expect(res[PAGE_SIZE]?.value).toBe(25); // number, coerced from env — not 10 (default)
|
|
98
|
+
expect(res[PAGE_SIZE]?.source).toBe("app-override");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("leak guard: inheritedToTenant:false hides the ENV app-override from a tenant", async () => {
|
|
102
|
+
const res = await stack.http.queryOk<Values>(ConfigQueries.values, {}, tenantAdmin);
|
|
103
|
+
expect(res[API_BASE]?.value).not.toBe("https://internal.example.com");
|
|
104
|
+
expect(res[API_BASE]?.source).not.toBe("app-override");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("ENV→app-override bridge — config:query:cascade", () => {
|
|
109
|
+
test("leak guard: the app-override level is redacted for a tenant on an inheritedToTenant:false key", async () => {
|
|
110
|
+
const res = await stack.http.queryOk<Cascades>(
|
|
111
|
+
ConfigQueries.cascade,
|
|
112
|
+
{ keys: [API_BASE] },
|
|
113
|
+
tenantAdmin,
|
|
114
|
+
);
|
|
115
|
+
const ov = overrideLevel(res, API_BASE);
|
|
116
|
+
expect(ov?.value).toBeUndefined();
|
|
117
|
+
expect(ov?.hasValue).toBe(false);
|
|
118
|
+
expect(res[API_BASE]?.value).not.toBe("https://internal.example.com");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("SystemAdmin still sees the inherited ENV app-override", async () => {
|
|
122
|
+
const res = await stack.http.queryOk<Cascades>(
|
|
123
|
+
ConfigQueries.cascade,
|
|
124
|
+
{ keys: [API_BASE] },
|
|
125
|
+
systemAdmin,
|
|
126
|
+
);
|
|
127
|
+
expect(overrideLevel(res, API_BASE)?.value).toBe("https://internal.example.com");
|
|
128
|
+
expect(res[API_BASE]?.value).toBe("https://internal.example.com");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("ENV→app-override bridge — precedence", () => {
|
|
133
|
+
test("a tenant's own row beats the ENV app-override", async () => {
|
|
134
|
+
await stack.http.writeOk(
|
|
135
|
+
ConfigHandlers.set,
|
|
136
|
+
{ key: PAGE_SIZE, value: 50, scope: "tenant" },
|
|
137
|
+
tenantAdmin,
|
|
138
|
+
);
|
|
139
|
+
const res = await stack.http.queryOk<Values>(ConfigQueries.values, {}, tenantAdmin);
|
|
140
|
+
expect(res[PAGE_SIZE]?.value).toBe(50);
|
|
141
|
+
expect(res[PAGE_SIZE]?.source).toBe("tenant-row");
|
|
142
|
+
});
|
|
143
|
+
});
|