@cosmicdrift/kumiko-bundled-features 0.154.1 → 0.154.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.154.1",
3
+ "version": "0.154.2",
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>",
@@ -115,11 +115,11 @@
115
115
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
116
116
  },
117
117
  "dependencies": {
118
- "@cosmicdrift/kumiko-dispatcher-live": "0.154.1",
119
- "@cosmicdrift/kumiko-framework": "0.154.1",
120
- "@cosmicdrift/kumiko-headless": "0.154.1",
121
- "@cosmicdrift/kumiko-renderer": "0.154.1",
122
- "@cosmicdrift/kumiko-renderer-web": "0.154.1",
118
+ "@cosmicdrift/kumiko-dispatcher-live": "0.154.2",
119
+ "@cosmicdrift/kumiko-framework": "0.154.2",
120
+ "@cosmicdrift/kumiko-headless": "0.154.2",
121
+ "@cosmicdrift/kumiko-renderer": "0.154.2",
122
+ "@cosmicdrift/kumiko-renderer-web": "0.154.2",
123
123
  "@mollie/api-client": "^4.5.0",
124
124
  "imapflow": "^1.3.3",
125
125
  "mailparser": "^3.9.8",
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { passwordPairIssue, resolveLoggedInHref, retryAfterMinutes } from "../auth-form-logic";
3
+
4
+ describe("passwordPairIssue", () => {
5
+ test("too_short when under min length", () => {
6
+ expect(passwordPairIssue("short", "short")).toBe("too_short");
7
+ });
8
+
9
+ test("mismatch when confirm differs", () => {
10
+ expect(passwordPairIssue("validpass1", "otherpass1")).toBe("mismatch");
11
+ });
12
+
13
+ test("null when both valid and matching", () => {
14
+ expect(passwordPairIssue("validpass1", "validpass1")).toBeNull();
15
+ });
16
+
17
+ test("custom minLength", () => {
18
+ expect(passwordPairIssue("123456", "123456", 6)).toBeNull();
19
+ expect(passwordPairIssue("12345", "12345", 6)).toBe("too_short");
20
+ });
21
+ });
22
+
23
+ describe("retryAfterMinutes", () => {
24
+ test("undefined in → undefined out", () => {
25
+ expect(retryAfterMinutes(undefined)).toBeUndefined();
26
+ });
27
+
28
+ test("ceils fractional minutes", () => {
29
+ expect(retryAfterMinutes(1)).toBe(1);
30
+ expect(retryAfterMinutes(60)).toBe(1);
31
+ expect(retryAfterMinutes(61)).toBe(2);
32
+ expect(retryAfterMinutes(540)).toBe(9);
33
+ });
34
+ });
35
+
36
+ describe("resolveLoggedInHref", () => {
37
+ test("string href returned as-is", () => {
38
+ expect(resolveLoggedInHref("/", "acme")).toBe("/");
39
+ });
40
+
41
+ test("function href receives tenantKey", () => {
42
+ expect(resolveLoggedInHref(({ tenantKey }) => `/${tenantKey}/`, "acme")).toBe("/acme/");
43
+ });
44
+ });
@@ -0,0 +1,130 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { fireEvent, screen, waitFor } from "@testing-library/react";
3
+ import { SignupCompleteScreen } from "../signup-complete-screen";
4
+ import { renderWithProviders } from "./test-utils";
5
+
6
+ beforeEach(() => {
7
+ globalThis.fetch = mock(
8
+ async () =>
9
+ new Response(
10
+ JSON.stringify({
11
+ user: { id: "u1", tenantId: "t1", roles: ["User"] },
12
+ tenantKey: "acme",
13
+ }),
14
+ { status: 200, headers: { "Content-Type": "application/json" } },
15
+ ),
16
+ ) as unknown as typeof fetch;
17
+ });
18
+ afterEach(() => {});
19
+
20
+ function fillPasswords(password: string, confirm: string): void {
21
+ fireEvent.change(document.getElementById("signup-password") as HTMLInputElement, {
22
+ target: { value: password },
23
+ });
24
+ fireEvent.change(document.getElementById("signup-confirm-password") as HTMLInputElement, {
25
+ target: { value: confirm },
26
+ });
27
+ }
28
+
29
+ describe("SignupCompleteScreen", () => {
30
+ test("ohne Token in URL UND ohne token-Prop → missing-token-Page", () => {
31
+ renderWithProviders(<SignupCompleteScreen />);
32
+ expect(screen.getByText(/enthält keinen Token/i)).toBeTruthy();
33
+ });
34
+
35
+ test("mit token-Prop → Form rendert", () => {
36
+ renderWithProviders(<SignupCompleteScreen token="abc-token" />);
37
+ expect(document.getElementById("signup-password")).toBeTruthy();
38
+ expect(document.getElementById("signup-confirm-password")).toBeTruthy();
39
+ expect(screen.getByRole("button", { name: "Account aktivieren" })).toBeTruthy();
40
+ });
41
+
42
+ test("Passwort < 8 Zeichen → client-side error, kein fetch-Call", async () => {
43
+ const fetchMock = mock(async () => new Response(null, { status: 200 }));
44
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
45
+
46
+ renderWithProviders(<SignupCompleteScreen token="abc" />);
47
+ fillPasswords("short", "short");
48
+ fireEvent.click(screen.getByRole("button", { name: "Account aktivieren" }));
49
+
50
+ await waitFor(() => {
51
+ expect(screen.getByRole("alert").textContent).toContain("8 Zeichen");
52
+ });
53
+ expect(fetchMock).not.toHaveBeenCalled();
54
+ });
55
+
56
+ test("happy path: gültiges Passwort → signup-confirm fetch + location.assign", async () => {
57
+ const assigned: string[] = [];
58
+ const assignOrig = window.location.assign.bind(window.location);
59
+ window.location.assign = ((url: string | URL) => {
60
+ assigned.push(String(url));
61
+ }) as typeof window.location.assign;
62
+
63
+ const fetchMock = mock(
64
+ async () =>
65
+ new Response(
66
+ JSON.stringify({
67
+ user: { id: "u1", tenantId: "t1", roles: ["User"] },
68
+ tenantKey: "acme",
69
+ }),
70
+ { status: 200, headers: { "Content-Type": "application/json" } },
71
+ ),
72
+ );
73
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
74
+
75
+ try {
76
+ renderWithProviders(
77
+ <SignupCompleteScreen
78
+ token="abc-token"
79
+ loggedInHref={({ tenantKey }) => `/${tenantKey}/`}
80
+ />,
81
+ );
82
+ fillPasswords("validpass1", "validpass1");
83
+ fireEvent.click(screen.getByRole("button", { name: "Account aktivieren" }));
84
+
85
+ await waitFor(() => {
86
+ expect(fetchMock).toHaveBeenCalledWith(
87
+ "/api/auth/signup-confirm",
88
+ expect.objectContaining({
89
+ method: "POST",
90
+ body: JSON.stringify({ token: "abc-token", password: "validpass1" }),
91
+ }),
92
+ );
93
+ expect(assigned).toEqual(["/acme/"]);
94
+ });
95
+ } finally {
96
+ window.location.assign = assignOrig;
97
+ }
98
+ });
99
+
100
+ test("mismatch → client-side error, kein fetch-Call", async () => {
101
+ const fetchMock = mock(async () => new Response(null, { status: 200 }));
102
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
103
+
104
+ renderWithProviders(<SignupCompleteScreen token="abc" />);
105
+ fillPasswords("validpass1", "differentpass");
106
+ fireEvent.click(screen.getByRole("button", { name: "Account aktivieren" }));
107
+
108
+ await waitFor(() => {
109
+ expect(screen.getByRole("alert").textContent).toContain("nicht überein");
110
+ });
111
+ expect(fetchMock).not.toHaveBeenCalled();
112
+ });
113
+
114
+ test("server invalid_signup_token → mapped i18n-error im UI", async () => {
115
+ const errBody = JSON.stringify({
116
+ error: { code: "invalid_signup_token", details: { reason: "invalid_signup_token" } },
117
+ });
118
+ globalThis.fetch = mock(
119
+ async () => new Response(errBody, { status: 422 }),
120
+ ) as unknown as typeof fetch;
121
+
122
+ renderWithProviders(<SignupCompleteScreen token="bad" />);
123
+ fillPasswords("validpass1", "validpass1");
124
+ fireEvent.click(screen.getByRole("button", { name: "Account aktivieren" }));
125
+
126
+ await waitFor(() => {
127
+ expect(screen.getByRole("alert").textContent).toMatch(/ungültig|abgelaufen/i);
128
+ });
129
+ });
130
+ });
@@ -0,0 +1,89 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { fireEvent, screen, waitFor } from "@testing-library/react";
3
+ import { SignupScreen } from "../signup-screen";
4
+ import { renderWithProviders } from "./test-utils";
5
+
6
+ beforeEach(() => {
7
+ globalThis.fetch = mock(
8
+ async () => new Response(null, { status: 200 }),
9
+ ) as unknown as typeof fetch;
10
+ });
11
+ afterEach(() => {});
12
+
13
+ function fillEmail(value: string): void {
14
+ fireEvent.change(document.getElementById("signup-email") as HTMLInputElement, {
15
+ target: { value },
16
+ });
17
+ }
18
+
19
+ describe("SignupScreen", () => {
20
+ test("rendert title + email-input + submit-button (de)", () => {
21
+ renderWithProviders(<SignupScreen />);
22
+ expect(screen.getByText("Account erstellen")).toBeTruthy();
23
+ expect(document.getElementById("signup-email")).toBeTruthy();
24
+ expect(screen.getByRole("button", { name: "Aktivierungs-Link senden" })).toBeTruthy();
25
+ });
26
+
27
+ test("submit ruft /api/auth/signup-request mit der Email", async () => {
28
+ const fetchMock = mock(async () => new Response(null, { status: 200 }));
29
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
30
+
31
+ renderWithProviders(<SignupScreen />);
32
+ fillEmail("new@example.com");
33
+ fireEvent.click(screen.getByRole("button", { name: "Aktivierungs-Link senden" }));
34
+
35
+ await waitFor(() => {
36
+ expect(fetchMock).toHaveBeenCalledWith(
37
+ "/api/auth/signup-request",
38
+ expect.objectContaining({
39
+ method: "POST",
40
+ body: JSON.stringify({ email: "new@example.com" }),
41
+ }),
42
+ );
43
+ });
44
+ });
45
+
46
+ test("nach erfolgreichem Submit: success-Banner + Resend", async () => {
47
+ renderWithProviders(<SignupScreen />);
48
+ fillEmail("new@example.com");
49
+ fireEvent.click(screen.getByRole("button", { name: "Aktivierungs-Link senden" }));
50
+
51
+ await waitFor(() => {
52
+ expect(screen.getByText("Mail gesendet")).toBeTruthy();
53
+ });
54
+ expect(screen.getByRole("button", { name: /Mail erneut senden/i })).toBeTruthy();
55
+ });
56
+
57
+ test("rate_limited mit retryAfterSeconds → interpolated minutes", async () => {
58
+ const errBody = JSON.stringify({
59
+ error: { code: "rate_limited", details: { retryAfterSeconds: 120 } },
60
+ });
61
+ globalThis.fetch = mock(
62
+ async () => new Response(errBody, { status: 429 }),
63
+ ) as unknown as typeof fetch;
64
+
65
+ renderWithProviders(<SignupScreen />);
66
+ fillEmail("new@example.com");
67
+ fireEvent.click(screen.getByRole("button", { name: "Aktivierungs-Link senden" }));
68
+
69
+ await waitFor(() => {
70
+ expect(screen.getByRole("alert").textContent).toMatch(/2/);
71
+ });
72
+ expect(screen.queryByText("Mail gesendet")).toBeNull();
73
+ });
74
+
75
+ test("server 5xx → error-banner statt Success-State", async () => {
76
+ globalThis.fetch = mock(
77
+ async () => new Response(null, { status: 500 }),
78
+ ) as unknown as typeof fetch;
79
+
80
+ renderWithProviders(<SignupScreen />);
81
+ fillEmail("new@example.com");
82
+ fireEvent.click(screen.getByRole("button", { name: "Aktivierungs-Link senden" }));
83
+
84
+ await waitFor(() => {
85
+ expect(screen.getByRole("alert").textContent).toContain("schief");
86
+ });
87
+ expect(screen.queryByText("Mail gesendet")).toBeNull();
88
+ });
89
+ });
@@ -0,0 +1,30 @@
1
+ // @runtime client
2
+ // Pure form helpers shared by auth screens. Extracted so unit tests can
3
+ // pin password / rate-limit / redirect rules without mounting React.
4
+
5
+ export type PasswordPairIssue = "too_short" | "mismatch";
6
+
7
+ /** Client-side password + confirm check before calling signup/reset APIs. */
8
+ export function passwordPairIssue(
9
+ password: string,
10
+ confirmPassword: string,
11
+ minLength = 8,
12
+ ): PasswordPairIssue | null {
13
+ if (password.length < minLength) return "too_short";
14
+ if (password !== confirmPassword) return "mismatch";
15
+ return null;
16
+ }
17
+
18
+ /** Maps server `retryAfterSeconds` to whole minutes for i18n interpolation. */
19
+ export function retryAfterMinutes(retryAfterSeconds?: number): number | undefined {
20
+ if (retryAfterSeconds === undefined) return undefined;
21
+ return Math.ceil(retryAfterSeconds / 60);
22
+ }
23
+
24
+ /** Resolves post-login redirect: string template or function of tenantKey. */
25
+ export function resolveLoggedInHref(
26
+ href: string | ((args: { readonly tenantKey: string }) => string),
27
+ tenantKey: string,
28
+ ): string {
29
+ return typeof href === "function" ? href({ tenantKey }) : href;
30
+ }
@@ -13,6 +13,7 @@
13
13
  import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
14
14
  import { type FormEvent, type ReactNode, useState } from "react";
15
15
  import { resetPassword } from "./auth-client";
16
+ import { passwordPairIssue } from "./auth-form-logic";
16
17
  import { AuthCard, useUrlToken } from "./auth-form-primitives";
17
18
 
18
19
  export type ResetPasswordScreenProps = {
@@ -41,11 +42,12 @@ export function ResetPasswordScreen({
41
42
 
42
43
  const doSubmit = async (): Promise<void> => {
43
44
  setError(null);
44
- if (newPassword.length < 8) {
45
+ const issue = passwordPairIssue(newPassword, confirmPassword);
46
+ if (issue === "too_short") {
45
47
  setError(t("auth.resetPassword.tooShort"));
46
48
  return;
47
49
  }
48
- if (newPassword !== confirmPassword) {
50
+ if (issue === "mismatch") {
49
51
  setError(t("auth.resetPassword.mismatch"));
50
52
  return;
51
53
  }
@@ -18,6 +18,7 @@
18
18
  import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
19
19
  import { type FormEvent, type ReactNode, useState } from "react";
20
20
  import { confirmSignup } from "./auth-client";
21
+ import { passwordPairIssue, resolveLoggedInHref } from "./auth-form-logic";
21
22
  import { AuthCard, useUrlToken } from "./auth-form-primitives";
22
23
 
23
24
  export type SignupCompleteScreenProps = {
@@ -52,11 +53,12 @@ export function SignupCompleteScreen({
52
53
 
53
54
  const doSubmit = async (): Promise<void> => {
54
55
  setError(null);
55
- if (password.length < 8) {
56
+ const issue = passwordPairIssue(password, confirmPassword);
57
+ if (issue === "too_short") {
56
58
  setError(t("auth.signupComplete.tooShort"));
57
59
  return;
58
60
  }
59
- if (password !== confirmPassword) {
61
+ if (issue === "mismatch") {
60
62
  setError(t("auth.signupComplete.mismatch"));
61
63
  return;
62
64
  }
@@ -66,11 +68,7 @@ export function SignupCompleteScreen({
66
68
  if (res.ok) {
67
69
  // Auto-Login: Cookies sind via Set-Cookie schon im Browser. Wir
68
70
  // schicken den User direkt zur eingeloggten Page.
69
- const target =
70
- typeof loggedInHref === "function"
71
- ? loggedInHref({ tenantKey: res.data.tenantKey })
72
- : loggedInHref;
73
- window.location.assign(target);
71
+ window.location.assign(resolveLoggedInHref(loggedInHref, res.data.tenantKey));
74
72
  return;
75
73
  }
76
74
  if (res.error.reason === "invalid_signup_token") {
@@ -15,6 +15,7 @@
15
15
  import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
16
16
  import { type FormEvent, type ReactNode, useState } from "react";
17
17
  import { requestSignup } from "./auth-client";
18
+ import { retryAfterMinutes } from "./auth-form-logic";
18
19
  import { AuthCard } from "./auth-form-primitives";
19
20
 
20
21
  export type SignupScreenProps = {
@@ -45,10 +46,7 @@ export function SignupScreen({
45
46
  if (res.ok) {
46
47
  setDone(true);
47
48
  } else if (res.error.reason === "rate_limited") {
48
- const minutes =
49
- res.error.retryAfterSeconds !== undefined
50
- ? Math.ceil(res.error.retryAfterSeconds / 60)
51
- : undefined;
49
+ const minutes = retryAfterMinutes(res.error.retryAfterSeconds);
52
50
  setError(
53
51
  minutes !== undefined
54
52
  ? t("auth.errors.accountLockedRetry", { minutes })
@@ -43,6 +43,30 @@ function extractSecret(otpauthUri: string): string {
43
43
  return new URLSearchParams(query).get("secret") ?? "";
44
44
  }
45
45
 
46
+ // 24x24 viewBox icon paths (Material-style), solid fill. Real SVG paths
47
+ // instead of emoji glyphs — emoji-as-<text> renders inconsistently across
48
+ // font stacks and never centers cleanly (font-specific glyph padding).
49
+ const QR_ICONS = [
50
+ `<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" fill="#ef4444"/>`,
51
+ `<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" fill="#facc15"/>`,
52
+ `<circle cx="12" cy="12" r="11" fill="#facc15"/><circle cx="8.5" cy="10" r="1.4" fill="#1f2937"/><circle cx="15.5" cy="10" r="1.4" fill="#1f2937"/><path d="M7 14c1 2.2 3 3.4 5 3.4s4-1.2 5-3.4" stroke="#1f2937" stroke-width="1.6" fill="none" stroke-linecap="round"/>`,
53
+ ];
54
+
55
+ // Overlay a random icon in a circle badge over the QR center. cx/cy/r
56
+ // percentages resolve against the SVG viewport regardless of viewBox size,
57
+ // so this string-injection works without parsing qrcode's output dimensions.
58
+ function withIconBadge(svg: string): string {
59
+ const icon = QR_ICONS[Math.floor(Math.random() * QR_ICONS.length)];
60
+ const size = Number(svg.match(/viewBox="0 0 (\d+(?:\.\d+)?) /)?.[1] ?? 100);
61
+ const r = size * 0.16;
62
+ const iconSize = r * 1.3;
63
+ const scale = iconSize / 24;
64
+ const offset = size / 2 - iconSize / 2;
65
+ // html-ok: icon is one of the fixed QR_ICONS literals above, not user input
66
+ const badge = `<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="#fff" stroke="#e5e7eb" stroke-width="${size * 0.01}"/><g transform="translate(${offset} ${offset}) scale(${scale})">${icon}</g>`;
67
+ return svg.replace("</svg>", `${badge}</svg>`);
68
+ }
69
+
46
70
  export type MfaEnableScreenProps = {
47
71
  readonly embedded?: boolean;
48
72
  // Fired once enable-confirm succeeds — MfaEnableScreen has no query of its
@@ -80,7 +104,11 @@ export function MfaEnableScreen({
80
104
  setError(res.error.code);
81
105
  return;
82
106
  }
83
- const qrSvg = await QRCode.toString(res.data.otpauthUri, { type: "svg" });
107
+ // errorCorrectionLevel "H" (~30% redundancy) so the icon badge overlay
108
+ // doesn't break scanning.
109
+ const qrSvg = withIconBadge(
110
+ await QRCode.toString(res.data.otpauthUri, { type: "svg", errorCorrectionLevel: "H" }),
111
+ );
84
112
  setSetup({
85
113
  setupToken: res.data.setupToken,
86
114
  secretParam: extractSecret(res.data.otpauthUri),
@@ -198,7 +198,7 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
198
198
 
199
199
  // postQuery-hook: flatten row.customFields jsonb auf root-level der
200
200
  // API-response. Spec-Promise Z.4 "indistinguishable von Stammfeldern".
201
- r.entityHook("postQuery", entityName, async ({ rows }) => ({
201
+ r.hook("postQuery", { allOf: entityName }, async ({ rows }) => ({
202
202
  rows: rows.map((row) => {
203
203
  const customFields = row["customFields"];
204
204
  if (customFields && typeof customFields === "object" && !Array.isArray(customFields)) {
@@ -88,7 +88,7 @@ function widgetAuditFeature(): FeatureDefinition {
88
88
  r.toggleable({ default: true });
89
89
  r.entity("widget-audit", widgetAuditEntity);
90
90
 
91
- r.entityHook("postSave", "widget", async (result, ctx) => {
91
+ r.hook("postSave", { allOf: "widget" }, async (result, ctx) => {
92
92
  if (result.kind !== "save" || !result.isNew) return;
93
93
  if (!ctx.db) return;
94
94
  const name = result.changes!["name"] as string | undefined;
@@ -28,13 +28,29 @@ function uniqueKey(suffix: string): string {
28
28
 
29
29
  let provider: FileStorageProvider;
30
30
 
31
- beforeAll(() => {
31
+ beforeAll(async () => {
32
+ const endpoint = requireEnv("MINIO_ENDPOINT");
33
+ // Fail loud with an actionable message when Minio is down (otherwise
34
+ // S3 networking errors look like provider bugs).
35
+ try {
36
+ const health = await fetch(`${endpoint.replace(/\/$/, "")}/minio/health/live`);
37
+ if (!health.ok) {
38
+ throw new Error(`HTTP ${health.status}`);
39
+ }
40
+ } catch (err) {
41
+ throw new Error(
42
+ `MinIO not reachable at ${endpoint} — run: docker compose up -d minio minio-init ` +
43
+ `(kumiko-framework compose, default port 19000). ` +
44
+ `Cause: ${err instanceof Error ? err.message : String(err)}`,
45
+ );
46
+ }
47
+
32
48
  // forcePathStyle is deliberately NOT set — resolveForcePathStyle() must
33
49
  // auto-detect path-style from the `endpoint` presence. If auto-detection
34
50
  // regressed, Minio would reject virtual-host-style URLs (bucket.host/key)
35
51
  // and every round-trip below would fail. That's the proof.
36
52
  provider = createS3Provider({
37
- endpoint: requireEnv("MINIO_ENDPOINT"),
53
+ endpoint,
38
54
  region: requireEnv("MINIO_REGION"),
39
55
  accessKeyId: requireEnv("MINIO_ACCESS_KEY"),
40
56
  secretAccessKey: requireEnv("MINIO_SECRET_KEY"),