@cosmicdrift/kumiko-bundled-features 0.57.2 → 0.60.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 (53) hide show
  1. package/package.json +10 -7
  2. package/src/auth-email-password/i18n.ts +2 -0
  3. package/src/config/__tests__/app-override-visibility.integration.test.ts +6 -0
  4. package/src/config/__tests__/backing-secrets.integration.test.ts +38 -0
  5. package/src/config/__tests__/inherited-redaction.integration.test.ts +29 -0
  6. package/src/config/handlers/cascade.query.ts +1 -3
  7. package/src/config/handlers/readiness.query.ts +6 -0
  8. package/src/config/handlers/values.query.ts +1 -3
  9. package/src/config/read-redaction.ts +13 -2
  10. package/src/custom-fields/__tests__/feature.test.ts +57 -4
  11. package/src/custom-fields/feature.ts +19 -4
  12. package/src/files-provider-s3/__tests__/s3-provider.test.ts +61 -1
  13. package/src/files-provider-s3/s3-provider.ts +9 -3
  14. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +92 -1
  15. package/src/managed-pages/handlers/set.write.ts +14 -4
  16. package/src/subscription-stripe/__tests__/runtime.test.ts +59 -5
  17. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +105 -0
  18. package/src/subscription-stripe/feature.ts +2 -1
  19. package/src/tags/__tests__/drift.test.ts +46 -0
  20. package/src/tags/__tests__/feature.test.ts +155 -0
  21. package/src/tags/__tests__/tags.integration.test.ts +251 -0
  22. package/src/tags/aggregate-id.ts +23 -0
  23. package/src/tags/constants.ts +37 -0
  24. package/src/tags/entity.ts +35 -0
  25. package/src/tags/executor.ts +11 -0
  26. package/src/tags/feature.ts +75 -0
  27. package/src/tags/handlers/assign-tag.write.ts +48 -0
  28. package/src/tags/handlers/create-tag.write.ts +23 -0
  29. package/src/tags/handlers/remove-tag.write.ts +34 -0
  30. package/src/tags/index.ts +30 -0
  31. package/src/tags/schemas.ts +20 -0
  32. package/src/template-resolver/README.md +22 -0
  33. package/src/template-resolver/__tests__/conformance.integration.test.ts +79 -0
  34. package/src/template-resolver/testing.ts +192 -0
  35. package/src/tier-engine/__tests__/drift.test.ts +4 -0
  36. package/src/tier-engine/__tests__/resolver.integration.test.ts +30 -0
  37. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +118 -0
  38. package/src/tier-engine/constants.ts +13 -0
  39. package/src/tier-engine/entity.ts +5 -0
  40. package/src/tier-engine/feature.ts +51 -3
  41. package/src/tier-engine/handlers/get-tenant-tier.query.ts +36 -0
  42. package/src/tier-engine/handlers/set-tenant-tier.write.ts +99 -0
  43. package/src/tier-engine/i18n.ts +39 -0
  44. package/src/tier-engine/web/client-plugin.tsx +27 -0
  45. package/src/tier-engine/web/index.ts +8 -0
  46. package/src/tier-engine/web/tier-admin-screen.tsx +161 -0
  47. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +11 -0
  48. package/src/user-data-rights/deletion-token.ts +9 -3
  49. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +22 -3
  50. package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +37 -43
  51. package/src/user-profile/__tests__/profile-screen.test.tsx +61 -3
  52. package/src/user-profile/i18n.ts +2 -3
  53. package/src/user-profile/web/profile-screen.tsx +29 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.57.2",
3
+ "version": "0.60.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>",
@@ -26,9 +26,11 @@
26
26
  "./readiness": "./src/readiness/index.ts",
27
27
  "./jobs": "./src/jobs/index.ts",
28
28
  "./tier-engine": "./src/tier-engine/index.ts",
29
+ "./tier-engine/web": "./src/tier-engine/web/index.ts",
29
30
  "./cap-counter": "./src/cap-counter/index.ts",
30
31
  "./custom-fields": "./src/custom-fields/index.ts",
31
32
  "./custom-fields/web": "./src/custom-fields/web/index.ts",
33
+ "./tags": "./src/tags/index.ts",
32
34
  "./billing-foundation": "./src/billing-foundation/index.ts",
33
35
  "./subscription-stripe": "./src/subscription-stripe/index.ts",
34
36
  "./subscription-mollie": "./src/subscription-mollie/index.ts",
@@ -72,6 +74,7 @@
72
74
  "./text-content/seeding": "./src/text-content/seeding.ts",
73
75
  "./text-content/web": "./src/text-content/web/index.ts",
74
76
  "./template-resolver": "./src/template-resolver/index.ts",
77
+ "./template-resolver/testing": "./src/template-resolver/testing.ts",
75
78
  "./renderer-foundation": "./src/renderer-foundation/index.ts",
76
79
  "./legal-pages": "./src/legal-pages/index.ts",
77
80
  "./legal-pages/web": "./src/legal-pages/web/index.ts",
@@ -80,18 +83,18 @@
80
83
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
81
84
  },
82
85
  "dependencies": {
83
- "@cosmicdrift/kumiko-dispatcher-live": "0.55.1",
84
- "@cosmicdrift/kumiko-framework": "0.55.1",
85
- "@cosmicdrift/kumiko-headless": "0.55.1",
86
- "@cosmicdrift/kumiko-renderer": "0.55.1",
87
- "@cosmicdrift/kumiko-renderer-web": "0.55.1",
86
+ "@cosmicdrift/kumiko-dispatcher-live": "0.57.2",
87
+ "@cosmicdrift/kumiko-framework": "0.57.2",
88
+ "@cosmicdrift/kumiko-headless": "0.57.2",
89
+ "@cosmicdrift/kumiko-renderer": "0.57.2",
90
+ "@cosmicdrift/kumiko-renderer-web": "0.57.2",
88
91
  "@mollie/api-client": "^4.5.0",
89
92
  "@node-rs/argon2": "^2.0.2",
90
93
  "@types/nodemailer": "^8.0.0",
91
94
  "clsx": "^2.1.1",
92
95
  "lucide-react": "^1.14.0",
93
96
  "marked": "^18.0.3",
94
- "nodemailer": "^8.0.7",
97
+ "nodemailer": "^9.0.1",
95
98
  "react": "^19.2.6",
96
99
  "stripe": "^22.1.1",
97
100
  "tailwind-merge": "^3.6.0"
@@ -40,6 +40,7 @@ export const defaultTranslations: TranslationsByLocale = {
40
40
  "auth.errors.signupEmailAlreadyRegistered":
41
41
  "Für diese E-Mail-Adresse existiert bereits ein Konto. Bitte logge dich ein oder setze dein Passwort zurück.",
42
42
  "auth.errors.unknownError": "Etwas ist schief gegangen. Bitte erneut versuchen.",
43
+ "auth.errors.originNotAllowed": "Zugriff von dieser Herkunft ist nicht erlaubt.",
43
44
  "auth.forgotPassword.title": "Passwort zurücksetzen",
44
45
  "auth.forgotPassword.intro":
45
46
  "Gib deine E-Mail-Adresse ein. Falls ein Konto existiert, schicken wir dir einen Reset-Link.",
@@ -140,6 +141,7 @@ export const defaultTranslations: TranslationsByLocale = {
140
141
  "auth.errors.signupEmailAlreadyRegistered":
141
142
  "An account already exists for this email. Please sign in or reset your password.",
142
143
  "auth.errors.unknownError": "Something went wrong. Please try again.",
144
+ "auth.errors.originNotAllowed": "Requests from this origin are not allowed.",
143
145
  "auth.forgotPassword.title": "Reset password",
144
146
  "auth.forgotPassword.intro":
145
147
  "Enter your email. If an account exists, we'll send you a reset link.",
@@ -100,8 +100,14 @@ describe("ENV→app-override bridge — config:query:values", () => {
100
100
 
101
101
  test("leak guard: inheritedToTenant:false hides the ENV app-override from a tenant", async () => {
102
102
  const res = await stack.http.queryOk<Values>(ConfigQueries.values, {}, tenantAdmin);
103
+ expect(res[API_BASE]).toBeDefined();
103
104
  expect(res[API_BASE]?.value).not.toBe("https://internal.example.com");
104
105
  expect(res[API_BASE]?.source).not.toBe("app-override");
106
+ // Positive anchor: API_BASE has no keyDef.default → after redaction the key
107
+ // resolves as genuinely unset ("missing"), NOT absent for some other reason
108
+ // (e.g. an access-deny would drop the key entirely and leave the negative
109
+ // asserts above vacuously green).
110
+ expect(res[API_BASE]?.source).toBe("missing");
105
111
  });
106
112
  });
107
113
 
@@ -167,6 +167,44 @@ describe("config backing=secrets — read dispatch", () => {
167
167
  });
168
168
  });
169
169
 
170
+ describe("config backing=secrets — fail-loud when secrets unwired", () => {
171
+ // The PR's central safety promise: a backing="secrets" key throws loudly at
172
+ // request time when ctx.secrets is absent — it never silently degrades into
173
+ // a config_values write. One write exercises the set.write throw site; the
174
+ // resolver and reset.write guards share the identical `!ctx.secrets` shape.
175
+ test("set on a backing=secrets key throws internal_error when ctx.secrets is absent", async () => {
176
+ const unwired = await setupTestStack({
177
+ features: [createConfigFeature(), billingFeature],
178
+ extraContext: ({ registry }) => {
179
+ const resolver = createConfigResolver();
180
+ return {
181
+ configResolver: resolver,
182
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
183
+ // No `secrets` — the backing="secrets" path must fail loudly.
184
+ };
185
+ },
186
+ });
187
+ await unsafePushTables(unwired.db, { configValuesTable, tenantSecretsTable });
188
+ await createEventsTable(unwired.db);
189
+
190
+ try {
191
+ const err = await unwired.http.writeErr(
192
+ ConfigHandlers.set,
193
+ { key: API_KEY, value: "sk-live-should-not-persist", scope: "system" },
194
+ systemAdmin,
195
+ );
196
+ expect(err.code).toBe("internal_error");
197
+ expect(err.httpStatus).toBe(500);
198
+
199
+ // It must NOT have silently fallen back to a config_values row.
200
+ const configRows = await selectMany(unwired.db, configValuesTable, { key: API_KEY });
201
+ expect(configRows).toHaveLength(0);
202
+ } finally {
203
+ await unwired.cleanup();
204
+ }
205
+ });
206
+ });
207
+
170
208
  describe("config backing=secrets — reset dispatch", () => {
171
209
  test("reset clears the secret; the key falls back to unset", async () => {
172
210
  await stack.http.writeOk(ConfigHandlers.reset, { key: API_KEY, scope: "system" }, systemAdmin);
@@ -34,6 +34,7 @@ const tenantAdmin = createTestUser({ id: 2 }); // roles ["Admin"], same tenant
34
34
  const SMTP_HOST = "platform:config:smtp-host";
35
35
  const SMTP_PASS = "platform:config:smtp-pass";
36
36
  const LIST_HITS = "platform:config:list-hits";
37
+ const LIST_CAP = "platform:config:list-cap";
37
38
 
38
39
  const configFeature = createConfigFeature();
39
40
 
@@ -60,6 +61,16 @@ const platformFeature = defineFeature("platform", (r) => {
60
61
  write: access.systemAdmin,
61
62
  read: access.admin,
62
63
  }),
64
+ // Control: default inheritance WITH a set system-row value — proves a
65
+ // tenant receives the inherited system-row value (not just the
66
+ // keyDef.default fallback). The default (5) differs from the seeded
67
+ // system-row (42) so a broken pass-through can't masquerade as the
68
+ // default.
69
+ listCap: createSystemConfig("number", {
70
+ default: 5,
71
+ write: access.systemAdmin,
72
+ read: access.admin,
73
+ }),
63
74
  },
64
75
  });
65
76
  });
@@ -90,6 +101,11 @@ beforeAll(async () => {
90
101
  { key: SMTP_PASS, value: "s3cr3t-password", scope: "system" },
91
102
  systemAdmin,
92
103
  );
104
+ await stack.http.writeOk(
105
+ ConfigHandlers.set,
106
+ { key: LIST_CAP, value: 42, scope: "system" },
107
+ systemAdmin,
108
+ );
93
109
  });
94
110
 
95
111
  afterAll(async () => {
@@ -152,6 +168,19 @@ describe("inheritedToTenant redaction — config:query:cascade", () => {
152
168
  );
153
169
  expect(res[LIST_HITS]?.value).toBe(10);
154
170
  });
171
+
172
+ test("control: a SET system-row value is inherited by tenants (not the default)", async () => {
173
+ const res = await stack.http.queryOk<Cascades>(
174
+ ConfigQueries.cascade,
175
+ { keys: [LIST_CAP] },
176
+ tenantAdmin,
177
+ );
178
+ // 42 = seeded system-row value; would be 5 (keyDef.default) if pass-through
179
+ // for non-redacted keys were broken.
180
+ expect(res[LIST_CAP]?.value).toBe(42);
181
+ expect(systemLevel(res, LIST_CAP)?.value).toBe(42);
182
+ expect(systemLevel(res, LIST_CAP)?.hasValue).toBe(true);
183
+ });
155
184
  });
156
185
 
157
186
  describe("inheritedToTenant redaction — config:query:values", () => {
@@ -5,11 +5,9 @@ import {
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import { z } from "zod";
7
7
  import { requireConfigResolver } from "../feature";
8
- import { redactInheritedCascade, shouldRedactInherited } from "../read-redaction";
8
+ import { MASKED, redactInheritedCascade, shouldRedactInherited } from "../read-redaction";
9
9
  import { hasConfigAccess } from "../write-helpers";
10
10
 
11
- const MASKED = "••••••";
12
-
13
11
  export const cascadeQuery = defineQueryHandler({
14
12
  name: "cascade",
15
13
  schema: z.object({
@@ -101,6 +101,12 @@ export async function collectMissingRequiredConfig(
101
101
  ctx.secrets,
102
102
  );
103
103
  for (const [qualifiedKey, keyDef] of candidates) {
104
+ // Deliberately the unredacted cascade value: readiness asks "does this
105
+ // tenant functionally have the value?", and an inheritedToTenant:false key
106
+ // set only at system-level IS inherited (the resolver ignores
107
+ // inheritedToTenant — that flag only redacts the value queries' display).
108
+ // Redacting here would report a working key as missing. The is-set bit this
109
+ // exposes is intentional; see read-redaction.ts.
104
110
  const value = cascades.get(qualifiedKey)?.value;
105
111
  if (isUnset(value, keyDef.type)) {
106
112
  missing.push({ key: qualifiedKey, scope: keyDef.scope, type: keyDef.type });
@@ -6,11 +6,9 @@ import {
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { z } from "zod";
8
8
  import { requireConfigResolver } from "../feature";
9
- import { redactInheritedCascade, shouldRedactInherited } from "../read-redaction";
9
+ import { MASKED, redactInheritedCascade, shouldRedactInherited } from "../read-redaction";
10
10
  import { hasConfigAccess } from "../write-helpers";
11
11
 
12
- const MASKED = "••••••";
13
-
14
12
  export const valuesQuery = defineQueryHandler({
15
13
  name: "values",
16
14
  schema: z.object({}),
@@ -14,8 +14,19 @@ const OWN_SOURCES: ReadonlySet<ConfigValueSource> = new Set(["user-row", "tenant
14
14
 
15
15
  // A SystemAdmin owns the platform-level values and may always see them. Every
16
16
  // other viewer (TenantAdmin, User) is tenant-side — for an
17
- // inheritedToTenant:false key they must learn neither the inherited platform
18
- // value nor that it is set.
17
+ // inheritedToTenant:false key the value-returning read handlers (cascade +
18
+ // values) hide both the inherited platform value and that it is set.
19
+ //
20
+ // Scope note: redaction is display-only. The resolver does NOT consult
21
+ // inheritedToTenant (zero reads in resolver.ts), so the tenant still
22
+ // functionally inherits and uses the value, and config:query:readiness
23
+ // deliberately reports such a key as satisfied (is-set) rather than missing —
24
+ // flagging a working key as missing would nag tenants to set already-
25
+ // functioning config. So "nor that it is set" holds for the value queries, not
26
+ // for the functional readiness rollup. See readiness.query.ts.
27
+ // Shared mask for redacted config values across the read handlers (cascade + values).
28
+ export const MASKED = "••••••";
29
+
19
30
  export function mayViewInheritedValue(roles: readonly string[]): boolean {
20
31
  return roles.includes(SYSTEM_ADMIN_ROLE);
21
32
  }
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { fieldDefinitionAggregateId } from "../aggregate-id";
3
3
  import { SUPPORTED_FIELD_TYPES } from "../constants";
4
- import { createCustomFieldsFeature } from "../feature";
4
+ import { createCustomFieldsFeature, resolveFieldDefinitionListRoles } from "../feature";
5
5
  import { defineFieldPayloadSchema, deleteFieldPayloadSchema } from "../schemas";
6
6
 
7
7
  // B1 unit-tests: feature-shape, schema-validation, aggregate-id determinism.
@@ -156,14 +156,67 @@ describe("createCustomFieldsFeature access-options", () => {
156
156
  expect(writeAccess(feature, "define-tenant-field")).toEqual(["TenantAdmin"]);
157
157
  });
158
158
 
159
- test("fieldDefinitionListRoles überschreibt den List-Query (FormSection-Lade-Pfad)", () => {
160
- const feature = createCustomFieldsFeature({ fieldDefinitionListRoles: ["Admin", "Editor"] });
159
+ function listAccess(feature: ReturnType<typeof createCustomFieldsFeature>): readonly string[] {
161
160
  const entry = Object.entries(feature.queryHandlers).find(([qn]) =>
162
161
  qn.includes("field-definition:list"),
163
162
  );
164
163
  if (!entry) throw new Error("field-definition:list not registered");
165
164
  const access = entry[1].access;
166
165
  if (!access || !("roles" in access)) throw new Error("list-query has no roles");
167
- expect(access.roles).toEqual(["Admin", "Editor"]);
166
+ return access.roles;
167
+ }
168
+
169
+ test("fieldDefinitionListRoles überschreibt den List-Query (FormSection-Lade-Pfad)", () => {
170
+ const feature = createCustomFieldsFeature({ fieldDefinitionListRoles: ["Admin", "Editor"] });
171
+ expect(listAccess(feature)).toEqual(["Admin", "Editor"]);
172
+ });
173
+
174
+ // #334/2: valueWriteRoles ohne fieldDefinitionListRoles brach asymmetrisch —
175
+ // Save offen für App-Rollen, aber der List-Lade-Pfad blieb ["TenantAdmin"] →
176
+ // App-User bekamen access_denied, die FormSection lud nie. Die Value-Rollen
177
+ // erben jetzt in den List-Default (Union mit dem Default).
178
+ test("valueWriteRoles erbt in den List-Default wenn fieldDefinitionListRoles fehlt", () => {
179
+ const feature = createCustomFieldsFeature({ valueWriteRoles: ["Admin", "Editor"] });
180
+ const roles = listAccess(feature);
181
+ // Value-Writer können laden …
182
+ expect(roles).toContain("Admin");
183
+ expect(roles).toContain("Editor");
184
+ // … und Admins behalten den List-Zugriff.
185
+ expect(roles).toContain("TenantAdmin");
186
+ });
187
+
188
+ test("explizite fieldDefinitionListRoles gewinnen über die valueWriteRoles-Vererbung", () => {
189
+ const feature = createCustomFieldsFeature({
190
+ valueWriteRoles: ["Admin", "Editor"],
191
+ fieldDefinitionListRoles: ["Viewer"],
192
+ });
193
+ expect(listAccess(feature)).toEqual(["Viewer"]);
194
+ });
195
+ });
196
+
197
+ describe("resolveFieldDefinitionListRoles", () => {
198
+ test("nichts gesetzt → reiner Default", () => {
199
+ expect(resolveFieldDefinitionListRoles({})).toEqual(["TenantAdmin"]);
200
+ });
201
+
202
+ test("valueWriteRoles gesetzt, list ungesetzt → Union mit Default, dedupliziert", () => {
203
+ expect(resolveFieldDefinitionListRoles({ valueWriteRoles: ["Admin", "Editor"] })).toEqual([
204
+ "Admin",
205
+ "Editor",
206
+ "TenantAdmin",
207
+ ]);
208
+ // TenantAdmin schon in valueWriteRoles → keine Dublette.
209
+ expect(resolveFieldDefinitionListRoles({ valueWriteRoles: ["TenantAdmin", "Editor"] })).toEqual(
210
+ ["TenantAdmin", "Editor"],
211
+ );
212
+ });
213
+
214
+ test("explizite list-Rollen gewinnen immer (auch über valueWriteRoles)", () => {
215
+ expect(
216
+ resolveFieldDefinitionListRoles({
217
+ valueWriteRoles: ["Admin"],
218
+ fieldDefinitionListRoles: ["Viewer"],
219
+ }),
220
+ ).toEqual(["Viewer"]);
168
221
  });
169
222
  });
@@ -171,11 +171,27 @@ export type CustomFieldsFeatureOptions = {
171
171
  * das setzen, sonst ist der Value-Save für jeden App-User
172
172
  * access_denied (Role-Naming-Drift). */
173
173
  readonly valueWriteRoles?: readonly string[];
174
- /** Rollen für custom-fields:query:field-definition:list — der
175
- * Lade-Pfad der CustomFieldsFormSection. Default ["TenantAdmin"]. */
174
+ /** Rollen für custom-fields:query:field-definition:list — der Lade-Pfad
175
+ * der CustomFieldsFormSection. Default ["TenantAdmin"]. Wird valueWriteRoles
176
+ * gesetzt, dies aber NICHT, erben die Value-Rollen hier hinein (Union mit
177
+ * dem Default, damit Admins den List-Zugriff behalten) — sonst lädt die
178
+ * FormSection für Value-Writer nie (access_denied), während der Save-Pfad
179
+ * offen wäre (#334/2, asymmetrischer Bruch). */
176
180
  readonly fieldDefinitionListRoles?: readonly string[];
177
181
  };
178
182
 
183
+ // Der List-Pfad muss jeden abdecken, der Values schreiben darf — sonst lädt die
184
+ // FormSection nie. Explizite fieldDefinitionListRoles gewinnen; sonst: gesetzte
185
+ // valueWriteRoles erben in den List-Default (Union mit dem Default), ungesetzte
186
+ // → reiner Default.
187
+ export function resolveFieldDefinitionListRoles(
188
+ opts: Pick<CustomFieldsFeatureOptions, "valueWriteRoles" | "fieldDefinitionListRoles">,
189
+ ): readonly string[] {
190
+ if (opts.fieldDefinitionListRoles !== undefined) return opts.fieldDefinitionListRoles;
191
+ if (opts.valueWriteRoles === undefined) return DEFAULT_FIELD_DEFINITION_LIST_ROLES;
192
+ return [...new Set([...opts.valueWriteRoles, ...DEFAULT_FIELD_DEFINITION_LIST_ROLES])];
193
+ }
194
+
179
195
  // Backwards-compat-wrapper. Bestehende Caller (z.B. integration-tests,
180
196
  // host-apps) nutzen weiterhin `createCustomFieldsFeature()`. Returnt den
181
197
  // module-level-Singleton — kein neuer build pro Aufruf, was für consumer
@@ -200,8 +216,7 @@ export function createCustomFieldsFeature(
200
216
  : defineTenantFieldHandler,
201
217
  setHandler: createSetCustomFieldHandler(opts.valueWriteRoles),
202
218
  clearHandler: createClearCustomFieldHandler(opts.valueWriteRoles),
203
- fieldDefinitionListRoles:
204
- opts.fieldDefinitionListRoles ?? DEFAULT_FIELD_DEFINITION_LIST_ROLES,
219
+ fieldDefinitionListRoles: resolveFieldDefinitionListRoles(opts),
205
220
  }),
206
221
  );
207
222
  }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { S3ProviderConfig } from "../s3-provider";
3
- import { resolveForcePathStyle } from "../s3-provider";
3
+ import { createS3Provider, resolveForcePathStyle, resolveVirtualHostedStyle } from "../s3-provider";
4
4
 
5
5
  const baseConfig: S3ProviderConfig = {
6
6
  bucket: "b",
@@ -34,3 +34,63 @@ describe("resolveForcePathStyle", () => {
34
34
  ).toBe(false);
35
35
  });
36
36
  });
37
+
38
+ // virtualHostedStyle ist die Inversion, die createS3Provider an Bun.S3Client
39
+ // durchreicht (#175/2). Der `!` ist die stille Drift-Stelle: kippt er, picken
40
+ // Minio/R2 die falsche URL-Form ohne Compile- oder Runtime-Fehler.
41
+ describe("resolveVirtualHostedStyle (inverse of forcePathStyle)", () => {
42
+ const cases: ReadonlyArray<{ name: string; config: S3ProviderConfig }> = [
43
+ { name: "no endpoint + no override", config: baseConfig },
44
+ { name: "custom endpoint", config: { ...baseConfig, endpoint: "http://localhost:9000" } },
45
+ { name: "explicit forcePathStyle true", config: { ...baseConfig, forcePathStyle: true } },
46
+ {
47
+ name: "custom endpoint + explicit false",
48
+ config: { ...baseConfig, endpoint: "http://localhost:9000", forcePathStyle: false },
49
+ },
50
+ ];
51
+
52
+ for (const { name, config } of cases) {
53
+ test(`${name} → strict inverse of resolveForcePathStyle`, () => {
54
+ expect(resolveVirtualHostedStyle(config)).toBe(!resolveForcePathStyle(config));
55
+ });
56
+ }
57
+
58
+ test("AWS default (no endpoint) → virtual-host-style true", () => {
59
+ expect(resolveVirtualHostedStyle(baseConfig)).toBe(true);
60
+ });
61
+
62
+ test("Minio/R2 (custom endpoint) → virtual-host-style false (= path-style)", () => {
63
+ expect(resolveVirtualHostedStyle({ ...baseConfig, endpoint: "http://localhost:9000" })).toBe(
64
+ false,
65
+ );
66
+ });
67
+ });
68
+
69
+ // presign ist eine reine lokale Signier-Operation (HMAC, kein Netzwerk) →
70
+ // hermetisch testbar mit Dummy-Credentials. Beweist, dass Bun das
71
+ // contentDisposition-Feld tatsächlich als response-content-disposition-Query-
72
+ // Param signiert (#175/3) — sonst lieferte ein Download den UUID-Key statt des
73
+ // Dateinamens, lautlos.
74
+ describe("getSignedUrl contentDisposition", () => {
75
+ const provider = createS3Provider({
76
+ bucket: "b",
77
+ region: "us-east-1",
78
+ accessKeyId: "AKIAEXAMPLE",
79
+ secretAccessKey: "secret",
80
+ });
81
+
82
+ test("signs response-content-disposition into the presigned URL", async () => {
83
+ const url = await provider.getSignedUrl?.("uuid-key.bin", 60, {
84
+ contentDisposition: 'attachment; filename="report.pdf"',
85
+ });
86
+ expect(url).toBeDefined();
87
+ const params = new URL(url ?? "").searchParams;
88
+ expect(params.get("response-content-disposition")).toBe('attachment; filename="report.pdf"');
89
+ });
90
+
91
+ test("omits the param when no contentDisposition is passed", async () => {
92
+ const url = await provider.getSignedUrl?.("uuid-key.bin", 60);
93
+ expect(url).toBeDefined();
94
+ expect(new URL(url ?? "").searchParams.has("response-content-disposition")).toBe(false);
95
+ });
96
+ });
@@ -54,6 +54,14 @@ export function resolveForcePathStyle(config: S3ProviderConfig): boolean {
54
54
  return config.forcePathStyle ?? config.endpoint !== undefined;
55
55
  }
56
56
 
57
+ // Bun's `virtualHostedStyle` is the inverse of the AWS-SDK `forcePathStyle`
58
+ // knob this config exposes: path-style ⇔ virtualHostedStyle=false. Exported +
59
+ // tested alongside resolveForcePathStyle because the inversion is exactly the
60
+ // seam that silently breaks Minio/R2 if the `!` ever drifts.
61
+ export function resolveVirtualHostedStyle(config: S3ProviderConfig): boolean {
62
+ return !resolveForcePathStyle(config);
63
+ }
64
+
57
65
  export function createS3Provider(config: S3ProviderConfig): FileStorageProvider {
58
66
  const client = new Bun.S3Client({
59
67
  region: config.region,
@@ -61,9 +69,7 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
61
69
  secretAccessKey: config.secretAccessKey,
62
70
  bucket: config.bucket,
63
71
  ...(config.endpoint !== undefined && { endpoint: config.endpoint }),
64
- // Bun's virtualHostedStyle is the inverse of the AWS-SDK forcePathStyle
65
- // knob this config exposes: path-style ⇔ virtualHostedStyle=false.
66
- virtualHostedStyle: !resolveForcePathStyle(config),
72
+ virtualHostedStyle: resolveVirtualHostedStyle(config),
67
73
  });
68
74
 
69
75
  return {
@@ -1,4 +1,5 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
3
  import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
3
4
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
4
5
  import {
@@ -16,7 +17,7 @@ import { BRANDING_QN, BRANDING_QUERY_QN } from "../branding";
16
17
  import { createManagedPagesCssFeature } from "../css-gate";
17
18
  import { createManagedPagesFeature } from "../feature";
18
19
  import { seedPage } from "../seeding";
19
- import { pageEntity } from "../table";
20
+ import { type PageRow, pageEntity, pagesTable } from "../table";
20
21
 
21
22
  const TENANT_A = "11111111-1111-4111-8111-111111111111";
22
23
  const TENANT_B = "22222222-2222-4222-8222-222222222222";
@@ -286,6 +287,96 @@ describe("managed-pages :: set (Provisioning-API)", () => {
286
287
  });
287
288
  });
288
289
 
290
+ // The SystemAdmin-only `tenantIdOverride` cross-tenant write path had zero
291
+ // coverage (#382/2): not the happy path, not the access guard, not the
292
+ // documented `executorUser`-rebase fix (the override must rebase the executor's
293
+ // tenant or getStreamVersion runs against the wrong tenant → version_conflict
294
+ // on the second write).
295
+ describe("managed-pages :: set with tenantIdOverride (cross-tenant, SystemAdmin)", () => {
296
+ const sysAdmin = createTestUser({ id: 40, roles: ["SystemAdmin"] }); // distinct tenant
297
+
298
+ test("(a) SystemAdmin override writes the row under the TARGET tenant", async () => {
299
+ const res = await stack.http.writeOk<{ isNew: boolean }>(
300
+ "managed-pages:write:set",
301
+ {
302
+ slug: "sys-cross",
303
+ lang: "en",
304
+ title: "Cross-tenant by system",
305
+ body: "x",
306
+ published: true,
307
+ tenantIdOverride: TENANT_A,
308
+ },
309
+ sysAdmin,
310
+ );
311
+ expect(res).toMatchObject({ isNew: true });
312
+
313
+ // The persisted row carries TENANT_A — not the SystemAdmin's own tenant.
314
+ const row = await fetchOne<PageRow>(stack.db, pagesTable, {
315
+ tenantId: TENANT_A,
316
+ slug: "sys-cross",
317
+ lang: "en",
318
+ });
319
+ expect(row?.tenantId).toBe(TENANT_A);
320
+ expect(row?.title).toBe("Cross-tenant by system");
321
+
322
+ // End-to-end: it renders under a.* (TENANT_A) and is absent under b.*.
323
+ const aHtml = await (await stack.app.request("http://a.example.com/p/sys-cross")).text();
324
+ expect(aHtml).toContain("Cross-tenant by system");
325
+ const bRes = await stack.app.request("http://b.example.com/p/sys-cross");
326
+ expect(bRes.status).toBe(404);
327
+ });
328
+
329
+ test("(b) non-SystemAdmin with tenantIdOverride → access_denied", async () => {
330
+ const error = await stack.http.writeErr(
331
+ "managed-pages:write:set",
332
+ { slug: "sneak", lang: "en", title: "x", body: "y", tenantIdOverride: TENANT_B },
333
+ tenantAdmin, // TenantAdmin, NOT SystemAdmin
334
+ );
335
+ expectErrorIncludes(error, "access_denied");
336
+ expect(JSON.stringify(error)).toContain("tenant_override_requires_system_admin");
337
+
338
+ // The write was rejected — no row leaked into TENANT_B.
339
+ const leaked = await fetchOne<PageRow>(stack.db, pagesTable, {
340
+ tenantId: TENANT_B,
341
+ slug: "sneak",
342
+ lang: "en",
343
+ });
344
+ expect(leaked).toBeFalsy();
345
+ });
346
+
347
+ test("(c) SystemAdmin override on an EXISTING page updates it — no version_conflict", async () => {
348
+ // First override-write creates the row under TENANT_A.
349
+ await stack.http.writeOk(
350
+ "managed-pages:write:set",
351
+ {
352
+ slug: "sys-rebase",
353
+ lang: "en",
354
+ title: "v1",
355
+ body: "a",
356
+ published: true,
357
+ tenantIdOverride: TENANT_A,
358
+ },
359
+ sysAdmin,
360
+ );
361
+
362
+ // Second override-write must hit the UPDATE path. Two prior bugs broke this:
363
+ // (1) the existing-check used the tenant-scoped ctx.db → blind to the target
364
+ // tenant's row → create → unique_violation; (2) even reaching update,
365
+ // getStreamVersion ran against the executor's tenant → version_conflict.
366
+ // The fix reads the existing row through the unscoped runner AND rebases the
367
+ // executor user to the override tenant.
368
+ const second = await stack.http.writeOk<{ isNew: boolean }>(
369
+ "managed-pages:write:set",
370
+ { slug: "sys-rebase", lang: "en", title: "v2", body: "b", tenantIdOverride: TENANT_A },
371
+ sysAdmin,
372
+ );
373
+ expect(second).toMatchObject({ isNew: false });
374
+
375
+ const html = await (await stack.app.request("http://a.example.com/p/sys-rebase")).text();
376
+ expect(html).toContain("v2");
377
+ });
378
+ });
379
+
289
380
  describe("managed-pages :: Branding (Config + Render)", () => {
290
381
  // config:write:set leitet tenantId aus user.tenantId ab → tenant-spezifische
291
382
  // Admins, damit das Branding auf TENANT_A bzw. TENANT_B landet (Host a.*/b.*).
@@ -1,5 +1,5 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { AccessDeniedError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
5
5
  import { z } from "zod";
@@ -57,7 +57,17 @@ export const setWrite = defineWriteHandler({
57
57
  const executorUser =
58
58
  override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user; // @cast-boundary engine-bridge
59
59
 
60
- const existing = await fetchOne<PageRow>(db, pagesTable, {
60
+ // ctx.db is tenant-scoped to the EXECUTING user (createTenantDb "tenant"
61
+ // mode). For a cross-tenant override that scope is wrong on BOTH the
62
+ // existing-check (blind to the target tenant's projection row → every
63
+ // re-provision retries as a create → unique_violation) AND the executor's
64
+ // stream reads (getStreamVersion/loadAggregate filtered to the executor's
65
+ // tenant → not_found/version_conflict). Re-scope a TenantDb to the resolved
66
+ // target tenant so reads and writes both land there. Safe: the override
67
+ // branch is SystemAdmin-gated above.
68
+ const scopedDb =
69
+ override !== undefined ? createTenantDb(db.raw, override as TenantId, "tenant") : db; // @cast-boundary engine-bridge
70
+ const existing = await fetchOne<PageRow>(scopedDb, pagesTable, {
61
71
  tenantId,
62
72
  slug: event.payload.slug,
63
73
  lang: event.payload.lang,
@@ -81,7 +91,7 @@ export const setWrite = defineWriteHandler({
81
91
  },
82
92
  },
83
93
  executorUser,
84
- db,
94
+ scopedDb,
85
95
  );
86
96
  if (!result.isSuccess) return result;
87
97
  return {
@@ -102,7 +112,7 @@ export const setWrite = defineWriteHandler({
102
112
  tenantId,
103
113
  },
104
114
  executorUser,
105
- db,
115
+ scopedDb,
106
116
  );
107
117
  if (!result.isSuccess) return result;
108
118
  return {