@cosmicdrift/kumiko-bundled-features 0.48.1 → 0.50.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 (54) hide show
  1. package/package.json +2 -1
  2. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +51 -0
  3. package/src/auth-email-password/constants.ts +6 -0
  4. package/src/auth-email-password/errors.ts +19 -0
  5. package/src/auth-email-password/handlers/request-email-verification.write.ts +1 -0
  6. package/src/auth-email-password/handlers/request-password-reset.write.ts +1 -0
  7. package/src/auth-email-password/handlers/signup-confirm.write.ts +22 -12
  8. package/src/auth-email-password/handlers/signup-request.write.ts +12 -10
  9. package/src/auth-email-password/i18n.ts +4 -0
  10. package/src/auth-email-password/password-hashing.ts +1 -0
  11. package/src/auth-email-password/reset-token.ts +2 -0
  12. package/src/auth-email-password/seeding.ts +19 -4
  13. package/src/auth-email-password/signup-token-store.ts +1 -0
  14. package/src/auth-email-password/verification-token.ts +2 -0
  15. package/src/billing-foundation/aggregate-id.ts +1 -0
  16. package/src/cap-counter/aggregate-id.ts +2 -0
  17. package/src/config/__tests__/app-override-visibility.integration.test.ts +143 -0
  18. package/src/config/__tests__/cascade.integration.test.ts +111 -1
  19. package/src/config/__tests__/env-overrides.test.ts +134 -0
  20. package/src/config/__tests__/inherited-redaction.integration.test.ts +180 -0
  21. package/src/config/__tests__/read-redaction.test.ts +112 -0
  22. package/src/config/__tests__/settings-hub-feature-name.test.ts +14 -0
  23. package/src/config/constants.ts +3 -1
  24. package/src/config/handlers/cascade.query.ts +9 -1
  25. package/src/config/handlers/values.query.ts +34 -12
  26. package/src/config/index.ts +1 -1
  27. package/src/config/read-redaction.ts +54 -0
  28. package/src/config/resolver.ts +70 -1
  29. package/src/config/web/client-plugin.ts +24 -0
  30. package/src/config/web/i18n.ts +25 -0
  31. package/src/config/web/index.ts +3 -0
  32. package/src/custom-fields/aggregate-id.ts +1 -0
  33. package/src/custom-fields/wire-for-entity.ts +1 -0
  34. package/src/delivery/upsert-preference.ts +1 -0
  35. package/src/file-provider-inmemory/feature.ts +1 -1
  36. package/src/file-provider-s3/feature.ts +1 -1
  37. package/src/mail-transport-inmemory/feature.ts +1 -1
  38. package/src/mail-transport-smtp/feature.ts +1 -1
  39. package/src/step-dispatcher/mail-runner.ts +1 -0
  40. package/src/subscription-stripe/runtime.ts +1 -0
  41. package/src/subscription-stripe/verify-webhook.ts +1 -0
  42. package/src/tenant/__tests__/multi-tenant.integration.test.ts +48 -0
  43. package/src/tenant/handlers/list.query.ts +1 -1
  44. package/src/tenant/handlers/memberships.query.ts +16 -15
  45. package/src/tenant/handlers/toggle-enabled.write.ts +1 -1
  46. package/src/tenant/handlers/update.write.ts +1 -1
  47. package/src/text-content/api.ts +1 -0
  48. package/src/tier-engine/aggregate-id.ts +1 -0
  49. package/src/user/handlers/me.query.ts +1 -1
  50. package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
  51. package/src/user-data-rights/deletion-token.ts +2 -0
  52. package/src/user-data-rights/feature.ts +1 -1
  53. package/src/user-data-rights/run-export-jobs.ts +2 -0
  54. 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.48.1",
3
+ "version": "0.50.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",
@@ -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
- const provisioned = await provisionSignupAccount(dbConn, {
110
- email,
111
- password: event.payload.password,
112
- displayName,
113
- tenantId,
114
- tenantKey,
115
- // Tenant-Display-Name als Default = Email. User wechselt das im
116
- // Settings-Screen. Konzept "Tenant" leakt nicht in die Signup-UI.
117
- tenantName: email,
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 existiert der User
6
- // HIER NOCH NICHT daher kein userId-lookup, kein HMAC-signing (wofür
7
- // gäbe es kein Subject), kein "skip if user already exists in DB"-pattern.
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
- // gleich aus, egal ob sie schon registriert ist oder nicht. Anders als
17
- // reset (das ein "no-op" zurückgibt wenn User nicht existiert) gibt's
18
- // hier nichts zu enumerieren eine Email kann nicht "schon registriert
19
- // sein" weil bei Magic-Link der User-Row erst beim Confirm entsteht.
20
- // Was es geben könnte: dieselbe Email versucht es zum N-ten Mal —
21
- // Resend-Pfad ist by-design idempotent.
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":
@@ -17,6 +17,7 @@ const HASH_OPTIONS = {
17
17
  parallelism: 1,
18
18
  } as const;
19
19
 
20
+ // @wrapper-known semantic-alias
20
21
  export async function hashPassword(password: string): Promise<string> {
21
22
  return argonHash(password, HASH_OPTIONS);
22
23
  }
@@ -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
- * Nicht idempotent: ein zweiter Aufruf für dieselbe Email wirft (über
88
- * seedTenant + seedUser deren idempotenz-Check sich an key/email
89
- * orientiert; bei collidierenden tenantKey ist der Caller
90
- * verantwortlich, einen freien zu finden siehe generateUniqueName). */
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
+ });