@cosmicdrift/kumiko-bundled-features 0.35.0 → 0.38.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 (48) hide show
  1. package/package.json +5 -5
  2. package/src/auth-email-password/__tests__/auth-mailer.test.ts +138 -0
  3. package/src/auth-email-password/auth-mailer.ts +137 -0
  4. package/src/auth-email-password/email-templates.ts +7 -13
  5. package/src/auth-email-password/errors.ts +84 -0
  6. package/src/auth-email-password/handlers/change-password.write.ts +1 -10
  7. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -19
  8. package/src/auth-email-password/handlers/invite-accept.write.ts +15 -28
  9. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -14
  10. package/src/auth-email-password/handlers/login.write.ts +7 -51
  11. package/src/auth-email-password/handlers/reset-password.write.ts +3 -10
  12. package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -14
  13. package/src/auth-email-password/handlers/verify-email.write.ts +3 -10
  14. package/src/auth-email-password/index.ts +9 -0
  15. package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +24 -0
  16. package/src/auth-email-password/web/forgot-password-screen.tsx +1 -0
  17. package/src/auth-email-password/web/tenant-switcher.tsx +2 -1
  18. package/src/cap-counter/enforce-cap.ts +5 -0
  19. package/src/compliance-profiles/README.md +1 -1
  20. package/src/custom-fields/__tests__/feature.test.ts +1 -1
  21. package/src/custom-fields/__tests__/wire-for-entity.test.ts +4 -4
  22. package/src/custom-fields/db/queries/retention.ts +1 -0
  23. package/src/custom-fields/lib/parse-serialized-field.ts +11 -0
  24. package/src/custom-fields/run-retention.ts +4 -22
  25. package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +148 -0
  26. package/src/custom-fields/web/custom-fields-form-section.tsx +26 -12
  27. package/src/custom-fields/wire-for-entity.ts +4 -12
  28. package/src/custom-fields/wire-user-data-rights.ts +3 -22
  29. package/src/data-retention/__tests__/data-retention.integration.test.ts +2 -2
  30. package/src/file-foundation/feature.ts +13 -3
  31. package/src/file-foundation/index.ts +1 -0
  32. package/src/file-provider-inmemory/__tests__/feature.test.ts +4 -7
  33. package/src/file-provider-s3/__tests__/feature.test.ts +4 -6
  34. package/src/files/README.md +1 -1
  35. package/src/legal-pages/markdown.ts +1 -13
  36. package/src/renderer-simple/simple-renderer.ts +1 -8
  37. package/src/subscription-stripe/feature.ts +5 -2
  38. package/src/template-resolver/feature.ts +1 -2
  39. package/src/template-resolver/handlers/list.query.ts +7 -14
  40. package/src/template-resolver/handlers/toggle-status.write.ts +37 -0
  41. package/src/tenant/command-schemas.ts +1 -1
  42. package/src/tenant/feature.ts +1 -2
  43. package/src/tenant/handlers/toggle-enabled.write.ts +23 -0
  44. package/src/user-data-rights/README.md +8 -8
  45. package/src/template-resolver/handlers/archive.write.ts +0 -39
  46. package/src/template-resolver/handlers/publish.write.ts +0 -42
  47. package/src/tenant/handlers/disable.write.ts +0 -18
  48. 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.35.0",
3
+ "version": "0.38.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>",
@@ -74,10 +74,10 @@
74
74
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
75
75
  },
76
76
  "dependencies": {
77
- "@cosmicdrift/kumiko-dispatcher-live": "0.21.0",
78
- "@cosmicdrift/kumiko-framework": "0.21.0",
79
- "@cosmicdrift/kumiko-renderer": "0.21.0",
80
- "@cosmicdrift/kumiko-renderer-web": "0.21.0",
77
+ "@cosmicdrift/kumiko-dispatcher-live": "0.37.0",
78
+ "@cosmicdrift/kumiko-framework": "0.37.0",
79
+ "@cosmicdrift/kumiko-renderer": "0.37.0",
80
+ "@cosmicdrift/kumiko-renderer-web": "0.37.0",
81
81
  "@mollie/api-client": "^4.5.0",
82
82
  "@node-rs/argon2": "^2.0.2",
83
83
  "@types/nodemailer": "^8.0.0",
@@ -0,0 +1,138 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createInMemoryTransport } from "../../channel-email";
3
+ import { createAuthMailerConfig } from "../auth-mailer";
4
+
5
+ function makeArgs(overrides?: Record<string, unknown>) {
6
+ const mailSender = createInMemoryTransport();
7
+ return {
8
+ mailSender,
9
+ hmacSecret: "test-hmac-secret",
10
+ baseUrl: "https://admin.example.com",
11
+ paths: {
12
+ resetPassword: "/reset-password",
13
+ verifyEmail: "/verify-email",
14
+ signupComplete: "/signup/complete",
15
+ inviteAccept: "/invite/accept",
16
+ },
17
+ appName: "TestApp",
18
+ locale: "en" as const,
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ describe("createAuthMailerConfig", () => {
24
+ test("returns all 4 setups", () => {
25
+ const config = createAuthMailerConfig(makeArgs());
26
+ expect(config.passwordReset).toBeDefined();
27
+ expect(config.emailVerification).toBeDefined();
28
+ expect(config.signup).toBeDefined();
29
+ expect(config.invite).toBeDefined();
30
+ });
31
+
32
+ test("constructs URLs from baseUrl + paths", () => {
33
+ const config = createAuthMailerConfig(makeArgs());
34
+ expect(config.passwordReset.appResetUrl).toBe("https://admin.example.com/reset-password");
35
+ expect(config.emailVerification.appVerifyUrl).toBe("https://admin.example.com/verify-email");
36
+ expect(config.signup.appActivationUrl).toBe("https://admin.example.com/signup/complete");
37
+ expect(config.invite.appAcceptUrl).toBe("https://admin.example.com/invite/accept");
38
+ });
39
+
40
+ test("forwards hmacSecret", () => {
41
+ const config = createAuthMailerConfig(makeArgs());
42
+ expect(config.passwordReset.hmacSecret).toBe("test-hmac-secret");
43
+ expect(config.emailVerification.hmacSecret).toBe("test-hmac-secret");
44
+ });
45
+
46
+ test("sendResetEmail calls mailSender.send with rendered content", async () => {
47
+ const args = makeArgs();
48
+ const config = createAuthMailerConfig(args);
49
+
50
+ await config.passwordReset.sendResetEmail({
51
+ email: "user@example.com",
52
+ resetUrl: "https://admin.example.com/reset?token=abc",
53
+ expiresAt: "2026-06-09T12:00:00.000Z",
54
+ });
55
+
56
+ expect(args.mailSender.sent).toHaveLength(1);
57
+ expect(args.mailSender.sent[0]!.to).toBe("user@example.com");
58
+ expect(args.mailSender.sent[0]!.subject).toContain("TestApp");
59
+ expect(args.mailSender.sent[0]!.subject).toContain("Reset");
60
+ expect(args.mailSender.sent[0]!.html).toContain("https://admin.example.com/reset?token=abc");
61
+ });
62
+
63
+ test("sendVerificationEmail calls mailSender.send", async () => {
64
+ const args = makeArgs();
65
+ const config = createAuthMailerConfig(args);
66
+
67
+ await config.emailVerification.sendVerificationEmail({
68
+ email: "user@example.com",
69
+ verificationUrl: "https://admin.example.com/verify?token=abc",
70
+ expiresAt: "2026-06-09T12:00:00.000Z",
71
+ });
72
+
73
+ expect(args.mailSender.sent).toHaveLength(1);
74
+ expect(args.mailSender.sent[0]!.to).toBe("user@example.com");
75
+ expect(args.mailSender.sent[0]!.subject).toContain("TestApp");
76
+ expect(args.mailSender.sent[0]!.subject).toContain("Verify");
77
+ });
78
+
79
+ test("sendActivationEmail calls mailSender.send", async () => {
80
+ const args = makeArgs();
81
+ const config = createAuthMailerConfig(args);
82
+
83
+ await config.signup.sendActivationEmail({
84
+ email: "user@example.com",
85
+ activationUrl: "https://admin.example.com/signup/complete?token=abc",
86
+ expiresAt: "2026-06-09T12:00:00.000Z",
87
+ });
88
+
89
+ expect(args.mailSender.sent).toHaveLength(1);
90
+ expect(args.mailSender.sent[0]!.to).toBe("user@example.com");
91
+ expect(args.mailSender.sent[0]!.subject).toContain("TestApp");
92
+ });
93
+
94
+ test("sendInviteEmail calls mailSender.send with role", async () => {
95
+ const args = makeArgs();
96
+ const config = createAuthMailerConfig(args);
97
+
98
+ await config.invite.sendInviteEmail({
99
+ email: "user@example.com",
100
+ inviteUrl: "https://admin.example.com/invite/accept?token=abc",
101
+ expiresAt: "2026-06-09T12:00:00.000Z",
102
+ role: "Admin",
103
+ });
104
+
105
+ expect(args.mailSender.sent).toHaveLength(1);
106
+ expect(args.mailSender.sent[0]!.to).toBe("user@example.com");
107
+ expect(args.mailSender.sent[0]!.subject).toContain("TestApp");
108
+ expect(args.mailSender.sent[0]!.html).toContain("Admin");
109
+ });
110
+
111
+ test("uses defaults when appName is omitted", () => {
112
+ const config = createAuthMailerConfig(makeArgs({ appName: undefined }));
113
+ expect(config.passwordReset.appResetUrl).toBeDefined();
114
+ });
115
+
116
+ test("emailVerificationMode is absent when not provided", () => {
117
+ const config = createAuthMailerConfig(makeArgs());
118
+ expect("mode" in config.emailVerification).toBe(false);
119
+ });
120
+
121
+ test("emailVerificationMode is set when provided", () => {
122
+ const config = createAuthMailerConfig(makeArgs({ emailVerificationMode: "strict" }));
123
+ expect(config.emailVerification).toHaveProperty("mode", "strict");
124
+ });
125
+
126
+ test("locale 'de' renders German subject", async () => {
127
+ const args = makeArgs({ locale: "de" });
128
+ const config = createAuthMailerConfig(args);
129
+
130
+ await config.passwordReset.sendResetEmail({
131
+ email: "user@example.com",
132
+ resetUrl: "https://example.com/reset?token=abc",
133
+ expiresAt: "2026-06-09T12:00:00.000Z",
134
+ });
135
+
136
+ expect(args.mailSender.sent[0]!.subject).toContain("Passwort");
137
+ });
138
+ });
@@ -0,0 +1,137 @@
1
+ import type { EmailTransport } from "../channel-email";
2
+ import type { AuthMailLocale } from "./email-templates";
3
+ import {
4
+ renderActivationEmail,
5
+ renderInviteEmail,
6
+ renderResetPasswordEmail,
7
+ renderVerifyEmail,
8
+ } from "./email-templates";
9
+ import type {
10
+ EmailVerificationOptions,
11
+ InviteOptions,
12
+ PasswordResetOptions,
13
+ SignupOptions,
14
+ } from "./feature";
15
+
16
+ /**
17
+ * Komplette Konfiguration für die 4 Auth-Mail-Flows einer App.
18
+ *
19
+ * Strukturell kompatibel mit den `*Setup`-Typen aus
20
+ * `@cosmicdrift/kumiko-dev-server` (`PasswordResetSetup`,
21
+ * `EmailVerificationSetup`, `SignupSetup`, `InviteSetup`).
22
+ */
23
+ export type AuthMailerConfig = {
24
+ readonly passwordReset: PasswordResetOptions & {
25
+ readonly appResetUrl: string;
26
+ readonly sendResetEmail: (args: {
27
+ email: string;
28
+ resetUrl: string;
29
+ expiresAt: string;
30
+ }) => Promise<void>;
31
+ };
32
+ readonly emailVerification: EmailVerificationOptions & {
33
+ readonly appVerifyUrl: string;
34
+ readonly sendVerificationEmail: (args: {
35
+ email: string;
36
+ verificationUrl: string;
37
+ expiresAt: string;
38
+ }) => Promise<void>;
39
+ };
40
+ readonly signup: SignupOptions & {
41
+ readonly appActivationUrl: string;
42
+ readonly sendActivationEmail: (args: {
43
+ email: string;
44
+ activationUrl: string;
45
+ expiresAt: string;
46
+ }) => Promise<void>;
47
+ };
48
+ readonly invite: InviteOptions & {
49
+ readonly appAcceptUrl: string;
50
+ readonly sendInviteEmail: (args: {
51
+ email: string;
52
+ inviteUrl: string;
53
+ expiresAt: string;
54
+ role: string;
55
+ }) => Promise<void>;
56
+ };
57
+ };
58
+
59
+ export type CreateAuthMailerConfigArgs = {
60
+ readonly mailSender: EmailTransport;
61
+ readonly hmacSecret: string;
62
+ /** Basis-URL der App inkl. Schema (z.B. "https://admin.example.com").
63
+ * Die factory hängt paths.resetPassword etc. an. */
64
+ readonly baseUrl: string;
65
+ /** Pfad-Konstanten für die Auth-Seiten — jede App hat ihre eigenen
66
+ * in `./auth-paths.ts`. */
67
+ readonly paths: {
68
+ readonly resetPassword: string;
69
+ readonly verifyEmail: string;
70
+ readonly signupComplete: string;
71
+ readonly inviteAccept: string;
72
+ };
73
+ /** App-Name für Mail-Subject + Body. Default "Account". */
74
+ readonly appName?: string;
75
+ /** Locale für die Mail-Templates. Default "de". */
76
+ readonly locale?: AuthMailLocale;
77
+ /** Email-verification mode. Default undefined (kein Gate).
78
+ * "strict" blockt Login solange `emailVerified=false`.
79
+ * "off" mountet die Routes ohne login-gating. */
80
+ readonly emailVerificationMode?: "strict" | "off";
81
+ };
82
+
83
+ /**
84
+ * Factory für `AuthMailerConfig` — baut die 4 Auth-Mail-Setups
85
+ * (passwordReset / emailVerification / signup / invite) inklusive
86
+ * `send*Email`-Wrapper + URL-Konstruktion.
87
+ *
88
+ * Jede App ruft das einmal auf und spreadet das Resultat in die
89
+ * `auth`-Option von `runProdApp` / `runDevApp`.
90
+ */
91
+ export function createAuthMailerConfig(args: CreateAuthMailerConfigArgs): AuthMailerConfig {
92
+ const appName = args.appName ?? "Account";
93
+ const locale = args.locale ?? "de";
94
+ return {
95
+ passwordReset: {
96
+ hmacSecret: args.hmacSecret,
97
+ appResetUrl: `${args.baseUrl}${args.paths.resetPassword}`,
98
+ sendResetEmail: async ({ email, resetUrl, expiresAt }) => {
99
+ await args.mailSender.send({
100
+ to: email,
101
+ ...renderResetPasswordEmail({ resetUrl, expiresAt, locale, appName }),
102
+ });
103
+ },
104
+ },
105
+ emailVerification: {
106
+ hmacSecret: args.hmacSecret,
107
+ ...(args.emailVerificationMode !== undefined && {
108
+ mode: args.emailVerificationMode,
109
+ }),
110
+ appVerifyUrl: `${args.baseUrl}${args.paths.verifyEmail}`,
111
+ sendVerificationEmail: async ({ email, verificationUrl, expiresAt }) => {
112
+ await args.mailSender.send({
113
+ to: email,
114
+ ...renderVerifyEmail({ verificationUrl, expiresAt, locale, appName }),
115
+ });
116
+ },
117
+ },
118
+ signup: {
119
+ appActivationUrl: `${args.baseUrl}${args.paths.signupComplete}`,
120
+ sendActivationEmail: async ({ email, activationUrl, expiresAt }) => {
121
+ await args.mailSender.send({
122
+ to: email,
123
+ ...renderActivationEmail({ activationUrl, expiresAt, locale, appName }),
124
+ });
125
+ },
126
+ },
127
+ invite: {
128
+ appAcceptUrl: `${args.baseUrl}${args.paths.inviteAccept}`,
129
+ sendInviteEmail: async ({ email, inviteUrl, expiresAt, role }) => {
130
+ await args.mailSender.send({
131
+ to: email,
132
+ ...renderInviteEmail({ inviteUrl, expiresAt, role, locale, appName }),
133
+ });
134
+ },
135
+ },
136
+ };
137
+ }
@@ -20,6 +20,7 @@
20
20
  //
21
21
  // Locale: de + en. Apps mit anderen Sprachen rendern selbst.
22
22
 
23
+ import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
23
24
  import { Temporal } from "temporal-polyfill";
24
25
 
25
26
  export type AuthMailLocale = "de" | "en";
@@ -63,6 +64,7 @@ const STRINGS = {
63
64
  de: {
64
65
  resetSubject: (app: string) => `${app} — Passwort zurücksetzen`,
65
66
  resetGreeting: "Hallo,",
67
+ // guard:dup-ok — false positive: i18n-String-Template, gleiche Arrow-Struktur wie anonymous fn in collect-table-metas
66
68
  resetIntro: (app: string) =>
67
69
  `du hast den Reset deines Passworts für ${app} angefordert. Klicke auf den folgenden Link, um ein neues Passwort zu setzen:`,
68
70
  resetButton: "Passwort zurücksetzen",
@@ -159,6 +161,7 @@ function renderTokenEmail(spec: TokenEmailSpec): RenderedEmail {
159
161
 
160
162
  // Plain inline-styled HTML — funktioniert in Gmail/Outlook/Apple-Mail
161
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)
162
165
  function renderShell(args: { title: string; bodyHtml: string }): string {
163
166
  return `<!DOCTYPE html>
164
167
  <html lang="en">
@@ -181,12 +184,14 @@ function renderShell(args: { title: string; bodyHtml: string }): string {
181
184
  </html>`;
182
185
  }
183
186
 
187
+ // guard:dup-ok — Email-HTML-Helper; selbe normalisierte AST-Form wie wrapInLayout (legal-pages), verschiedene Semantik
184
188
  function renderButton(args: { url: string; label: string }): string {
185
- return `<a href="${escapeAttr(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>`;
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>`;
186
190
  }
187
191
 
192
+ // guard:dup-ok — Email-HTML-Helper; selbe normalisierte AST-Form wie wrapInLayout (legal-pages), verschiedene Semantik
188
193
  function renderFallbackUrl(args: { url: string; label: string }): string {
189
- return `<p style="margin: 24px 0 0; font-size: 12px; color: #666;">${escapeHtml(args.label)}<br /><a href="${escapeAttr(args.url)}" style="color: #1a1a1a; word-break: break-all;">${escapeHtml(args.url)}</a></p>`;
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>`;
190
195
  }
191
196
 
192
197
  export function renderResetPasswordEmail(args: RenderResetPasswordEmailArgs): RenderedEmail {
@@ -270,14 +275,3 @@ function formatExpiry(iso: string): string {
270
275
  function pad2(n: number): string {
271
276
  return String(n).padStart(2, "0");
272
277
  }
273
-
274
- function escapeHtml(s: string): string {
275
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
276
- }
277
- function escapeAttr(s: string): string {
278
- return s
279
- .replace(/&/g, "&amp;")
280
- .replace(/"/g, "&quot;")
281
- .replace(/</g, "&lt;")
282
- .replace(/>/g, "&gt;");
283
- }
@@ -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",