@cosmicdrift/kumiko-bundled-features 0.37.0 → 0.39.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 (55) hide show
  1. package/package.json +7 -5
  2. package/src/auth-email-password/email-templates.ts +4 -0
  3. package/src/auth-email-password/errors.ts +84 -0
  4. package/src/auth-email-password/handlers/change-password.write.ts +1 -10
  5. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -19
  6. package/src/auth-email-password/handlers/invite-accept.write.ts +15 -28
  7. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -14
  8. package/src/auth-email-password/handlers/login.write.ts +7 -51
  9. package/src/auth-email-password/handlers/reset-password.write.ts +3 -10
  10. package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -14
  11. package/src/auth-email-password/handlers/verify-email.write.ts +3 -10
  12. package/src/auth-email-password/i18n.ts +4 -14
  13. package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +24 -0
  14. package/src/auth-email-password/web/forgot-password-screen.tsx +1 -0
  15. package/src/auth-email-password/web/index.ts +1 -1
  16. package/src/auth-email-password/web/tenant-switcher.tsx +2 -1
  17. package/src/cap-counter/enforce-cap.ts +5 -0
  18. package/src/compliance-profiles/README.md +1 -1
  19. package/src/custom-fields/__tests__/feature.test.ts +1 -1
  20. package/src/custom-fields/__tests__/wire-for-entity.test.ts +4 -4
  21. package/src/custom-fields/db/queries/retention.ts +1 -0
  22. package/src/custom-fields/lib/parse-serialized-field.ts +11 -0
  23. package/src/custom-fields/run-retention.ts +4 -22
  24. package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +148 -0
  25. package/src/custom-fields/web/custom-fields-form-section.tsx +26 -12
  26. package/src/custom-fields/wire-for-entity.ts +4 -12
  27. package/src/custom-fields/wire-user-data-rights.ts +3 -22
  28. package/src/data-retention/__tests__/data-retention.integration.test.ts +2 -2
  29. package/src/file-foundation/feature.ts +13 -3
  30. package/src/file-foundation/index.ts +1 -0
  31. package/src/file-provider-inmemory/__tests__/feature.test.ts +4 -7
  32. package/src/file-provider-s3/__tests__/feature.test.ts +4 -6
  33. package/src/files/README.md +1 -1
  34. package/src/subscription-stripe/feature.ts +5 -2
  35. package/src/template-resolver/feature.ts +1 -2
  36. package/src/template-resolver/handlers/list.query.ts +7 -14
  37. package/src/template-resolver/handlers/toggle-status.write.ts +37 -0
  38. package/src/tenant/command-schemas.ts +1 -1
  39. package/src/tenant/feature.ts +1 -2
  40. package/src/tenant/handlers/toggle-enabled.write.ts +23 -0
  41. package/src/user-data-rights/README.md +8 -8
  42. package/src/user-profile/__tests__/change-email.integration.test.ts +222 -0
  43. package/src/user-profile/__tests__/profile-screen.test.tsx +101 -0
  44. package/src/user-profile/constants.ts +27 -0
  45. package/src/user-profile/feature.ts +26 -0
  46. package/src/user-profile/handlers/change-email.write.ts +83 -0
  47. package/src/user-profile/i18n.ts +83 -0
  48. package/src/user-profile/index.ts +11 -0
  49. package/src/user-profile/web/client-plugin.ts +28 -0
  50. package/src/user-profile/web/index.ts +6 -0
  51. package/src/user-profile/web/profile-screen.tsx +326 -0
  52. package/src/template-resolver/handlers/archive.write.ts +0 -39
  53. package/src/template-resolver/handlers/publish.write.ts +0 -42
  54. package/src/tenant/handlers/disable.write.ts +0 -18
  55. package/src/tenant/handlers/enable.write.ts +0 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.37.0",
3
+ "version": "0.39.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>",
@@ -48,6 +48,8 @@
48
48
  "./user": "./src/user/index.ts",
49
49
  "./user/seeding": "./src/user/seeding.ts",
50
50
  "./user/testing": "./src/user/testing.ts",
51
+ "./user-profile": "./src/user-profile/index.ts",
52
+ "./user-profile/web": "./src/user-profile/web/index.ts",
51
53
  "./auth-email-password": "./src/auth-email-password/index.ts",
52
54
  "./auth-email-password/constants": "./src/auth-email-password/constants.ts",
53
55
  "./auth-email-password/seeding": "./src/auth-email-password/seeding.ts",
@@ -74,10 +76,10 @@
74
76
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
75
77
  },
76
78
  "dependencies": {
77
- "@cosmicdrift/kumiko-dispatcher-live": "0.35.0",
78
- "@cosmicdrift/kumiko-framework": "0.35.0",
79
- "@cosmicdrift/kumiko-renderer": "0.35.0",
80
- "@cosmicdrift/kumiko-renderer-web": "0.35.0",
79
+ "@cosmicdrift/kumiko-dispatcher-live": "0.38.0",
80
+ "@cosmicdrift/kumiko-framework": "0.38.0",
81
+ "@cosmicdrift/kumiko-renderer": "0.38.0",
82
+ "@cosmicdrift/kumiko-renderer-web": "0.38.0",
81
83
  "@mollie/api-client": "^4.5.0",
82
84
  "@node-rs/argon2": "^2.0.2",
83
85
  "@types/nodemailer": "^8.0.0",
@@ -64,6 +64,7 @@ const STRINGS = {
64
64
  de: {
65
65
  resetSubject: (app: string) => `${app} — Passwort zurücksetzen`,
66
66
  resetGreeting: "Hallo,",
67
+ // guard:dup-ok — false positive: i18n-String-Template, gleiche Arrow-Struktur wie anonymous fn in collect-table-metas
67
68
  resetIntro: (app: string) =>
68
69
  `du hast den Reset deines Passworts für ${app} angefordert. Klicke auf den folgenden Link, um ein neues Passwort zu setzen:`,
69
70
  resetButton: "Passwort zurücksetzen",
@@ -160,6 +161,7 @@ function renderTokenEmail(spec: TokenEmailSpec): RenderedEmail {
160
161
 
161
162
  // Plain inline-styled HTML — funktioniert in Gmail/Outlook/Apple-Mail
162
163
  // ohne dass wir Tailwind oder eine HTML-mail-Lib reinziehen müssen.
164
+ // guard:dup-ok — Email-HTML (table-layout, inline CSS) ≠ Web-HTML (legal-pages/markdown.ts)
163
165
  function renderShell(args: { title: string; bodyHtml: string }): string {
164
166
  return `<!DOCTYPE html>
165
167
  <html lang="en">
@@ -182,10 +184,12 @@ function renderShell(args: { title: string; bodyHtml: string }): string {
182
184
  </html>`;
183
185
  }
184
186
 
187
+ // guard:dup-ok — Email-HTML-Helper; selbe normalisierte AST-Form wie wrapInLayout (legal-pages), verschiedene Semantik
185
188
  function renderButton(args: { url: string; label: string }): string {
186
189
  return `<a href="${escapeHtmlAttr(args.url)}" style="display: inline-block; background: #1a1a1a; color: #ffffff; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 500;">${escapeHtml(args.label)}</a>`;
187
190
  }
188
191
 
192
+ // guard:dup-ok — Email-HTML-Helper; selbe normalisierte AST-Form wie wrapInLayout (legal-pages), verschiedene Semantik
189
193
  function renderFallbackUrl(args: { url: string; label: string }): string {
190
194
  return `<p style="margin: 24px 0 0; font-size: 12px; color: #666;">${escapeHtml(args.label)}<br /><a href="${escapeHtmlAttr(args.url)}" style="color: #1a1a1a; word-break: break-all;">${escapeHtml(args.url)}</a></p>`;
191
195
  }
@@ -0,0 +1,84 @@
1
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
2
+ import { AuthErrors } from "./constants";
3
+
4
+ export function invalidCredentials() {
5
+ return writeFailure(
6
+ new UnprocessableError(AuthErrors.invalidCredentials, {
7
+ i18nKey: "auth.errors.invalidCredentials",
8
+ }),
9
+ );
10
+ }
11
+
12
+ export function invalidInviteToken() {
13
+ return writeFailure(
14
+ new UnprocessableError(AuthErrors.invalidInviteToken, {
15
+ i18nKey: "auth.errors.invalidInviteToken",
16
+ }),
17
+ );
18
+ }
19
+
20
+ export function inviteEmailMismatch() {
21
+ return writeFailure(
22
+ new UnprocessableError(AuthErrors.inviteEmailMismatch, {
23
+ i18nKey: "auth.errors.inviteEmailMismatch",
24
+ }),
25
+ );
26
+ }
27
+
28
+ export function invalidResetToken() {
29
+ return writeFailure(
30
+ new UnprocessableError(AuthErrors.invalidResetToken, {
31
+ i18nKey: "auth.errors.invalidResetToken",
32
+ }),
33
+ );
34
+ }
35
+
36
+ export function invalidVerificationToken() {
37
+ return writeFailure(
38
+ new UnprocessableError(AuthErrors.invalidVerificationToken, {
39
+ i18nKey: "auth.errors.invalidVerificationToken",
40
+ }),
41
+ );
42
+ }
43
+
44
+ export function invalidSignupToken() {
45
+ return writeFailure(
46
+ new UnprocessableError(AuthErrors.invalidSignupToken, {
47
+ i18nKey: "auth.errors.invalidSignupToken",
48
+ }),
49
+ );
50
+ }
51
+
52
+ export function noMembership() {
53
+ return writeFailure(
54
+ new UnprocessableError(AuthErrors.noMembership, {
55
+ i18nKey: "auth.errors.noMembership",
56
+ }),
57
+ );
58
+ }
59
+
60
+ export function emailNotVerified() {
61
+ return writeFailure(
62
+ new UnprocessableError(AuthErrors.emailNotVerified, {
63
+ i18nKey: "auth.errors.emailNotVerified",
64
+ }),
65
+ );
66
+ }
67
+
68
+ // retryAfterSeconds drives the login/signup UI countdown — must stay > 0.
69
+ export function accountLocked(retryAfterSeconds: number) {
70
+ return writeFailure(
71
+ new UnprocessableError(AuthErrors.accountLocked, {
72
+ i18nKey: "auth.errors.accountLocked",
73
+ details: { retryAfterSeconds },
74
+ }),
75
+ );
76
+ }
77
+
78
+ export function accountRestricted() {
79
+ return writeFailure(
80
+ new UnprocessableError(AuthErrors.accountRestricted, {
81
+ i18nKey: "auth.errors.accountRestricted",
82
+ }),
83
+ );
84
+ }
@@ -1,19 +1,10 @@
1
1
  import { access, createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
- import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
2
  import { getAggregateStreamTenant } from "@cosmicdrift/kumiko-framework/event-store";
4
3
  import { z } from "zod";
5
4
  import { USER_FEATURE, UserHandlers, UserQueries } from "../../user";
6
- import { AuthErrors } from "../constants";
5
+ import { invalidCredentials } from "../errors";
7
6
  import { hashPassword, verifyPassword } from "../password-hashing";
8
7
 
9
- function invalidCredentials() {
10
- return writeFailure(
11
- new UnprocessableError(AuthErrors.invalidCredentials, {
12
- i18nKey: "auth.errors.invalidCredentials",
13
- }),
14
- );
15
- }
16
-
17
8
  // Change-password — authenticated. The user supplies their current password
18
9
  // (re-auth) and the new one. The new hash is written via ctx.writeAs(system)
19
10
  // against the user feature's update handler; field-access on passwordHash
@@ -25,11 +25,7 @@ import {
25
25
  type SessionUser,
26
26
  type TenantId,
27
27
  } from "@cosmicdrift/kumiko-framework/engine";
28
- import {
29
- InternalError,
30
- UnprocessableError,
31
- writeFailure,
32
- } from "@cosmicdrift/kumiko-framework/errors";
28
+ import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
33
29
  import { z } from "zod";
34
30
  // kumiko-lint-ignore cross-feature-import invite-flow
35
31
  import {
@@ -41,7 +37,7 @@ import {
41
37
  import { seedTenantMembership } from "../../tenant/seeding";
42
38
  // kumiko-lint-ignore cross-feature-import login-style password-check
43
39
  import { userTable } from "../../user/schema/user";
44
- import { AuthErrors } from "../constants";
40
+ import { invalidInviteToken, inviteEmailMismatch } from "../errors";
45
41
  import {
46
42
  burnInviteToken,
47
43
  deleteInviteToken,
@@ -69,14 +65,6 @@ const invitationExecutor = createEventStoreExecutor(
69
65
  { entityName: "tenant-invitation" },
70
66
  );
71
67
 
72
- function invalidInviteToken() {
73
- return writeFailure(
74
- new UnprocessableError(AuthErrors.invalidInviteToken, {
75
- i18nKey: "auth.errors.invalidInviteToken",
76
- }),
77
- );
78
- }
79
-
80
68
  export function createInviteAcceptWithLoginHandler() {
81
69
  return defineWriteHandler<
82
70
  "invite-accept-with-login",
@@ -123,11 +111,7 @@ export function createInviteAcceptWithLoginHandler() {
123
111
 
124
112
  // Email-Match vom User-Input (nicht aus session — User ist anon)
125
113
  if (event.payload.email.toLowerCase() !== invitationEmail) {
126
- return writeFailure(
127
- new UnprocessableError(AuthErrors.inviteEmailMismatch, {
128
- i18nKey: "auth.errors.inviteEmailMismatch",
129
- }),
130
- );
114
+ return inviteEmailMismatch();
131
115
  }
132
116
 
133
117
  // Password-Check gegen userTable. Anti-enumeration: bei
@@ -22,11 +22,7 @@ import {
22
22
  defineWriteHandler,
23
23
  type TenantId,
24
24
  } from "@cosmicdrift/kumiko-framework/engine";
25
- import {
26
- InternalError,
27
- UnprocessableError,
28
- writeFailure,
29
- } from "@cosmicdrift/kumiko-framework/errors";
25
+ import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
30
26
  import { z } from "zod";
31
27
  // kumiko-lint-ignore cross-feature-import invite-flow lebt in auth-email-password (Magic-Link), DB-row-owner ist tenant-feature
32
28
  import {
@@ -34,11 +30,13 @@ import {
34
30
  tenantInvitationEntity,
35
31
  tenantInvitationsTable,
36
32
  } from "../../tenant/invitation-table";
33
+ // kumiko-lint-ignore cross-feature-import direkter membership-Lookup (ungefiltert, s. alreadyMember-Kommentar)
34
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
37
35
  // kumiko-lint-ignore cross-feature-import membership-seed-helper für privilegierten cross-tenant-add (analog provisionSignupAccount)
38
36
  import { seedTenantMembership } from "../../tenant/seeding";
39
37
  // kumiko-lint-ignore cross-feature-import auth handler reads user-row für email-match
40
38
  import { userTable } from "../../user/schema/user";
41
- import { AuthErrors } from "../constants";
39
+ import { invalidInviteToken, inviteEmailMismatch } from "../errors";
42
40
  import {
43
41
  burnInviteToken,
44
42
  deleteInviteToken,
@@ -63,14 +61,6 @@ const invitationExecutor = createEventStoreExecutor(
63
61
  { entityName: "tenant-invitation" },
64
62
  );
65
63
 
66
- function invalidInviteToken() {
67
- return writeFailure(
68
- new UnprocessableError(AuthErrors.invalidInviteToken, {
69
- i18nKey: "auth.errors.invalidInviteToken",
70
- }),
71
- );
72
- }
73
-
74
64
  export function createInviteAcceptHandler() {
75
65
  return defineWriteHandler<"invite-accept", typeof InviteAcceptSchema, InviteAcceptData>({
76
66
  name: "invite-accept",
@@ -122,22 +112,19 @@ export function createInviteAcceptHandler() {
122
112
  });
123
113
  const userEmail = userRow?.email;
124
114
  if (!userRow || !userEmail || userEmail.toLowerCase() !== invitationEmail) {
125
- return writeFailure(
126
- new UnprocessableError(AuthErrors.inviteEmailMismatch, {
127
- i18nKey: "auth.errors.inviteEmailMismatch",
128
- }),
129
- );
115
+ return inviteEmailMismatch();
130
116
  }
131
117
 
132
- // Already-Member-Check via memberships-query. Wenn der User schon
133
- // im invited Tenant Member ist, kein Error no-op + 200 mit
134
- // alreadyMember=true (advisor-Constraint #4: idempotent).
135
- const memberships = (await ctx.queryAs(
136
- createSystemUser(invitationTenantId),
137
- "tenant:query:memberships",
138
- { userId: event.user.id },
139
- )) as Array<{ tenantId: string }>; // @cast-boundary db-row
140
- const alreadyMember = memberships.some((m) => m.tenantId === invitationTenantId);
118
+ // Already-Member-Check direkt gegen die memberships-Projektion
119
+ // NICHT via tenant:query:memberships, die disabled Tenants filtert:
120
+ // ein Re-Invite in einen (vorübergehend) disabled Tenant würde dort
121
+ // alreadyMember=false sehen und am Unique-Constraint scheitern.
122
+ // Idempotenz: schon Member → no-op + 200 mit alreadyMember=true.
123
+ const membershipRow = await fetchOne(ctx.db.raw, tenantMembershipsTable, {
124
+ userId: event.user.id,
125
+ tenantId: invitationTenantId,
126
+ });
127
+ const alreadyMember = membershipRow !== undefined;
141
128
 
142
129
  const dbConn = ctx.db.raw;
143
130
 
@@ -29,11 +29,7 @@ import {
29
29
  type SessionUser,
30
30
  type TenantId,
31
31
  } from "@cosmicdrift/kumiko-framework/engine";
32
- import {
33
- InternalError,
34
- UnprocessableError,
35
- writeFailure,
36
- } from "@cosmicdrift/kumiko-framework/errors";
32
+ import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
37
33
  import { z } from "zod";
38
34
  // kumiko-lint-ignore cross-feature-import invite-flow
39
35
  import {
@@ -45,7 +41,7 @@ import {
45
41
  import { seedTenantMembership } from "../../tenant/seeding";
46
42
  // kumiko-lint-ignore cross-feature-import existence-check
47
43
  import { userTable } from "../../user/schema/user";
48
- import { AuthErrors } from "../constants";
44
+ import { invalidInviteToken } from "../errors";
49
45
  import {
50
46
  burnInviteToken,
51
47
  deleteInviteToken,
@@ -73,14 +69,6 @@ const invitationExecutor = createEventStoreExecutor(
73
69
  { entityName: "tenant-invitation" },
74
70
  );
75
71
 
76
- function invalidInviteToken() {
77
- return writeFailure(
78
- new UnprocessableError(AuthErrors.invalidInviteToken, {
79
- i18nKey: "auth.errors.invalidInviteToken",
80
- }),
81
- );
82
- }
83
-
84
72
  export function createInviteSignupCompleteHandler() {
85
73
  return defineWriteHandler<
86
74
  "invite-signup-complete",
@@ -4,7 +4,6 @@ import {
4
4
  type SessionUser,
5
5
  type TenantId,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
- import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
8
7
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
9
8
  import { z } from "zod";
10
9
  import { USER_STATUS, UserQueries } from "../../user";
@@ -12,60 +11,17 @@ import { parseAuthUserRow } from "../auth-user-row";
12
11
  import {
13
12
  AUTH_LOCKOUT_DEFAULT_DURATION_MINUTES,
14
13
  AUTH_LOCKOUT_DEFAULT_MAX_FAILED_ATTEMPTS,
15
- AuthErrors,
16
14
  } from "../constants";
15
+ import {
16
+ accountLocked,
17
+ accountRestricted,
18
+ emailNotVerified,
19
+ invalidCredentials,
20
+ noMembership,
21
+ } from "../errors";
17
22
  import { clearLockoutState, getLockoutState, recordFailedAttempt } from "../lockout-store";
18
23
  import { verifyPassword } from "../password-hashing";
19
24
 
20
- function invalidCredentials() {
21
- return writeFailure(
22
- new UnprocessableError(AuthErrors.invalidCredentials, {
23
- i18nKey: "auth.errors.invalidCredentials",
24
- }),
25
- );
26
- }
27
-
28
- function noMembership() {
29
- return writeFailure(
30
- new UnprocessableError(AuthErrors.noMembership, {
31
- i18nKey: "auth.errors.noMembership",
32
- }),
33
- );
34
- }
35
-
36
- function emailNotVerified() {
37
- return writeFailure(
38
- new UnprocessableError(AuthErrors.emailNotVerified, {
39
- i18nKey: "auth.errors.emailNotVerified",
40
- }),
41
- );
42
- }
43
-
44
- function accountLocked(retryAfterSeconds: number) {
45
- return writeFailure(
46
- new UnprocessableError(AuthErrors.accountLocked, {
47
- i18nKey: "auth.errors.accountLocked",
48
- // Seconds until auto-unlock — UI renders a countdown, clients can
49
- // schedule a retry. Rounded up so the UI never shows 0 while the
50
- // lock is still active.
51
- details: { retryAfterSeconds },
52
- }),
53
- );
54
- }
55
-
56
- // S2.U6 — DSGVO Art. 18 Account-Freeze. Distinct error code (nicht zu
57
- // invalid_credentials collapsen) damit UI klar sagen kann "Account ist
58
- // pausiert, hier klicken zum Aufheben". User weiss schon dass sein Konto
59
- // restricted ist (er hat selbst die Restriction gesetzt), also kein
60
- // Enumeration-Leak.
61
- function accountRestricted() {
62
- return writeFailure(
63
- new UnprocessableError(AuthErrors.accountRestricted, {
64
- i18nKey: "auth.errors.accountRestricted",
65
- }),
66
- );
67
- }
68
-
69
25
  export type LoginHandlerOptions = {
70
26
  // When true, a valid (email + password) login fails with email_not_verified
71
27
  // if the user row's emailVerified flag is false. Enumeration-leak is
@@ -2,6 +2,7 @@ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import { z } from "zod";
4
4
  import { AuthErrors } from "../constants";
5
+ import { invalidResetToken } from "../errors";
5
6
  import { hashPassword } from "../password-hashing";
6
7
  import { verifyResetToken } from "../reset-token";
7
8
  import { runConfirmTokenFlow } from "./confirm-token-flow";
@@ -10,14 +11,6 @@ export type ResetPasswordOptions = {
10
11
  readonly hmacSecret: string;
11
12
  };
12
13
 
13
- function invalidToken() {
14
- return writeFailure(
15
- new UnprocessableError(AuthErrors.invalidResetToken, {
16
- i18nKey: "auth.errors.invalidResetToken",
17
- }),
18
- );
19
- }
20
-
21
14
  // Confirm step of the reset flow. Token-verify happens inline; the
22
15
  // post-verify pipeline (burn, load user, memberships, try-all-tenants,
23
16
  // burn-release-on-failure) lives in confirm-token-flow to stay in sync
@@ -45,12 +38,12 @@ export function createResetPasswordHandler(opts: ResetPasswordOptions) {
45
38
  // the same invalid_reset_token error — a probing caller can't
46
39
  // distinguish tampered from stale from random-string.
47
40
  const verify = verifyResetToken(event.payload.token, opts.hmacSecret);
48
- if (!verify.ok) return invalidToken();
41
+ if (!verify.ok) return invalidResetToken();
49
42
 
50
43
  return runConfirmTokenFlow(ctx, verify.userId, verify.expiresAtMs, {
51
44
  purpose: "reset",
52
45
  redisRequiredMessage: "password-reset requires ctx.redis to enforce token single-use",
53
- invalidToken,
46
+ invalidToken: invalidResetToken,
54
47
  buildChanges: async () => ({
55
48
  passwordHash: await hashPassword(event.payload.newPassword),
56
49
  }),
@@ -26,17 +26,13 @@ import {
26
26
  type SessionUser,
27
27
  type TenantId,
28
28
  } from "@cosmicdrift/kumiko-framework/engine";
29
- import {
30
- InternalError,
31
- UnprocessableError,
32
- writeFailure,
33
- } from "@cosmicdrift/kumiko-framework/errors";
29
+ import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
34
30
  import { generateUniqueName } from "@cosmicdrift/kumiko-framework/random";
35
31
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
36
32
  import { z } from "zod";
37
33
  // kumiko-lint-ignore cross-feature-import signup-confirm reads tenants.key for slug-uniqueness check (TOCTOU + DB-unique-index zusammen)
38
34
  import { tenantTable } from "../../tenant/schema/tenant";
39
- import { AuthErrors } from "../constants";
35
+ import { invalidSignupToken } from "../errors";
40
36
  // kumiko-lint-ignore cross-feature-import provisioning needs cross-feature seeding helpers
41
37
  import { INITIAL_SIGNUP_ROLES, provisionSignupAccount } from "../seeding";
42
38
  import {
@@ -63,14 +59,6 @@ export type SignupConfirmData = {
63
59
  readonly tenantKey: string;
64
60
  };
65
61
 
66
- function invalidSignupToken() {
67
- return writeFailure(
68
- new UnprocessableError(AuthErrors.invalidSignupToken, {
69
- i18nKey: "auth.errors.invalidSignupToken",
70
- }),
71
- );
72
- }
73
-
74
62
  export function createSignupConfirmHandler() {
75
63
  return defineWriteHandler<"signup-confirm", typeof SignupConfirmSchema, SignupConfirmData>({
76
64
  name: "signup-confirm",
@@ -2,6 +2,7 @@ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import { z } from "zod";
4
4
  import { AuthErrors } from "../constants";
5
+ import { invalidVerificationToken } from "../errors";
5
6
  import { verifyVerificationToken } from "../verification-token";
6
7
  import { runConfirmTokenFlow } from "./confirm-token-flow";
7
8
 
@@ -15,14 +16,6 @@ const VerifyEmailSchema = z.object({
15
16
 
16
17
  export type VerifyEmailData = { readonly kind: "verified" } | { readonly kind: "already-verified" };
17
18
 
18
- function invalidToken() {
19
- return writeFailure(
20
- new UnprocessableError(AuthErrors.invalidVerificationToken, {
21
- i18nKey: "auth.errors.invalidVerificationToken",
22
- }),
23
- );
24
- }
25
-
26
19
  // Sets user.emailVerified = true on a valid token. Idempotent via the
27
20
  // `alreadyDone` short-circuit — when the row already reads verified
28
21
  // (reached through another flow), we skip the write but keep the burn
@@ -44,12 +37,12 @@ export function createVerifyEmailHandler(opts: VerifyEmailOptions) {
44
37
  }
45
38
 
46
39
  const verify = verifyVerificationToken(event.payload.token, opts.hmacSecret);
47
- if (!verify.ok) return invalidToken();
40
+ if (!verify.ok) return invalidVerificationToken();
48
41
 
49
42
  return runConfirmTokenFlow<VerifyEmailData>(ctx, verify.userId, verify.expiresAtMs, {
50
43
  purpose: "verify",
51
44
  redisRequiredMessage: "email-verification requires ctx.redis to enforce token single-use",
52
- invalidToken,
45
+ invalidToken: invalidVerificationToken,
53
46
  buildChanges: async () => ({ emailVerified: true }),
54
47
  successData: { kind: "verified" },
55
48
  alreadyDone: {
@@ -207,17 +207,7 @@ export const defaultTranslations: TranslationsByLocale = {
207
207
  },
208
208
  };
209
209
 
210
- /** Merged zwei TranslationsByLocale-Maps der override gewinnt pro Key,
211
- * die Locales werden zusammengeführt. Wird von emailPasswordClient()
212
- * benutzt, um App-Overrides über die Defaults zu legen. */
213
- export function mergeTranslations(
214
- base: TranslationsByLocale,
215
- override: TranslationsByLocale,
216
- ): TranslationsByLocale {
217
- const locales = new Set([...Object.keys(base), ...Object.keys(override)]);
218
- const merged: Record<string, Record<string, string>> = {};
219
- for (const locale of locales) {
220
- merged[locale] = { ...(base[locale] ?? {}), ...(override[locale] ?? {}) };
221
- }
222
- return merged;
223
- }
210
+ // Kanonische Implementierung lebt jetzt im Renderer (neben
211
+ // TranslationsByLocale) Re-Export hält die bestehende Import-Surface
212
+ // (auth-email-password/web) stabil.
213
+ export { mergeTranslations } from "@cosmicdrift/kumiko-renderer";
@@ -111,3 +111,27 @@ describe("TenantSwitcher", () => {
111
111
  expect(session.switchTenant).not.toHaveBeenCalled();
112
112
  });
113
113
  });
114
+
115
+ describe("TenantSwitcher — key-only-Fallback im geöffneten Dropdown", () => {
116
+ test("Membership ohne name rendert ihren key als Dropdown-Label", async () => {
117
+ const user = userEvent.setup();
118
+ const session = makeSessionApi({
119
+ activeTenantId: "00000000-0000-4000-8000-000000000001",
120
+ tenants: [
121
+ {
122
+ tenantId: "00000000-0000-4000-8000-000000000001",
123
+ roles: ["Admin"],
124
+ name: "Status",
125
+ key: "status",
126
+ },
127
+ { tenantId: "00000000-0000-4000-8000-000000000002", roles: ["Admin"], key: "demo" },
128
+ ],
129
+ });
130
+ renderWithProviders(<TenantSwitcher />, { session });
131
+ await user.click(screen.getByRole("button", { name: /Status/ }));
132
+ // Der key-only-Pfad (kein name) muss im Dropdown sichtbar werden —
133
+ // nicht das UUID-Präfix "00000000".
134
+ expect(screen.getByText("demo")).toBeTruthy();
135
+ expect(screen.queryByText("00000000")).toBeNull();
136
+ });
137
+ });
@@ -34,6 +34,7 @@ export function ForgotPasswordScreen({
34
34
  const [done, setDone] = useState(false);
35
35
  const [error, setError] = useState<string | null>(null);
36
36
 
37
+ // guard:dup-ok — gleiches Submit-Muster wie signup-screen, aber verschiedene API-Endpoints und State
37
38
  const doSubmit = async (): Promise<void> => {
38
39
  setSubmitting(true);
39
40
  setError(null);
@@ -5,7 +5,7 @@
5
5
  // `@cosmicdrift/kumiko-bundled-features/auth-email-password` und hat keine
6
6
  // React-/DOM-Deps. Trennung bleibt sauber so wie renderer vs renderer-web.
7
7
 
8
- export { defaultTranslations } from "../i18n";
8
+ export { defaultTranslations, mergeTranslations } from "../i18n";
9
9
  export type {
10
10
  AuthTokenFailure,
11
11
  CurrentUserProfile,
@@ -63,7 +63,8 @@ export function TenantSwitcher({ tenantName }: TenantSwitcherProps): ReactNode {
63
63
  const nameOf = (tenantId: string): string => {
64
64
  if (tenantName !== undefined) return tenantName(tenantId);
65
65
  const membership = tenants.find((m) => m.tenantId === tenantId);
66
- return membership?.name ?? membership?.key ?? tenantId.slice(0, 8);
66
+ // || statt ??: ein leerer name/key-String darf nicht als Label durchrutschen.
67
+ return membership?.name || membership?.key || tenantId.slice(0, 8);
67
68
  };
68
69
 
69
70
  // Rendering-Gate: kein User → nix; nur ein Tenant → auch nix
@@ -413,6 +413,11 @@ export type StockCapResult =
413
413
  * (ein Delete gibt den Slot sofort frei), braucht keine Counter-Tabelle und
414
414
  * kein Increment/Decrement-Bookkeeping. Misst einen Bestand, keinen Fluss.
415
415
  *
416
+ * **TOCTOU-Caveat:** Zählen und Schreiben sind nicht atomar — zwei parallele
417
+ * Creates können beide `ok` sehen und das Limit um eins überschreiten (gleiche
418
+ * Race wie bei {@link enforceCap}). Exakt harte Slots brauchen zusätzliche
419
+ * Serialisierung im Create-Pfad (z.B. Unique-Index auf tenantId+slot).
420
+ *
416
421
  * Reine Funktion — wirft NICHT und mappt KEINEN HTTP-Status. Ein erreichtes
417
422
  * Stock-Limit heißt „Upgrade nötig", nicht „retry later" (429): der Caller
418
423
  * entscheidet die Reaktion, typisch ein app-eigener 422/`upgrade_required`
@@ -78,7 +78,7 @@ explizit als eigene Entity.
78
78
 
79
79
  ## Tests
80
80
 
81
- `__tests__/compliance-profiles.integration.ts` — 9 full-stack Tests via
81
+ `__tests__/compliance-profiles.integration.test.ts` — 9 full-stack Tests via
82
82
  `setupTestStack` + echte HTTP-Calls (Memory: `feedback_no_fake_dispatcher`):
83
83
  list-profiles, for-tenant ohne/mit Setting, set-profile als TenantAdmin /
84
84
  Member (403) / mit Override / mit invalidem JSON / mit Array statt Object /
@@ -12,7 +12,7 @@ describe("createCustomFieldsFeature shape", () => {
12
12
  test("registers field-definition entity + 6 write-handlers + 1 query-handler", () => {
13
13
  const feature = createCustomFieldsFeature();
14
14
 
15
- expect(Object.keys(feature.entities)).toContain("field-definition");
15
+ expect(Object.keys(feature.entities ?? {})).toContain("field-definition");
16
16
 
17
17
  const writeHandlerNames = Object.keys(feature.writeHandlers);
18
18
  expect(writeHandlerNames).toEqual(