@cosmicdrift/kumiko-bundled-features 0.59.1 → 0.60.1

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 (39) hide show
  1. package/package.json +2 -1
  2. package/src/config/__tests__/app-override-visibility.integration.test.ts +6 -0
  3. package/src/config/__tests__/backing-secrets.integration.test.ts +38 -0
  4. package/src/config/__tests__/inherited-redaction.integration.test.ts +29 -0
  5. package/src/custom-fields/__tests__/feature.test.ts +57 -4
  6. package/src/custom-fields/feature.ts +19 -4
  7. package/src/files-provider-s3/__tests__/s3-provider.test.ts +61 -1
  8. package/src/files-provider-s3/s3-provider.ts +9 -3
  9. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +92 -1
  10. package/src/managed-pages/handlers/set.write.ts +14 -4
  11. package/src/subscription-stripe/__tests__/runtime.test.ts +59 -5
  12. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +105 -0
  13. package/src/subscription-stripe/feature.ts +2 -1
  14. package/src/tags/__tests__/feature.test.ts +34 -0
  15. package/src/tags/__tests__/tags.integration.test.ts +66 -0
  16. package/src/tags/constants.ts +11 -2
  17. package/src/tags/feature.ts +26 -21
  18. package/src/tags/handlers/assign-tag.write.ts +4 -6
  19. package/src/tags/handlers/create-tag.write.ts +4 -6
  20. package/src/tags/handlers/remove-tag.write.ts +4 -6
  21. package/src/tags/index.ts +1 -0
  22. package/src/tier-engine/__tests__/drift.test.ts +4 -0
  23. package/src/tier-engine/__tests__/resolver.integration.test.ts +30 -0
  24. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +118 -0
  25. package/src/tier-engine/constants.ts +13 -0
  26. package/src/tier-engine/entity.ts +5 -0
  27. package/src/tier-engine/feature.ts +51 -3
  28. package/src/tier-engine/handlers/get-tenant-tier.query.ts +36 -0
  29. package/src/tier-engine/handlers/set-tenant-tier.write.ts +99 -0
  30. package/src/tier-engine/i18n.ts +39 -0
  31. package/src/tier-engine/web/client-plugin.tsx +27 -0
  32. package/src/tier-engine/web/index.ts +8 -0
  33. package/src/tier-engine/web/tier-admin-screen.tsx +161 -0
  34. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +11 -0
  35. package/src/user-data-rights/deletion-token.ts +9 -3
  36. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +22 -3
  37. package/src/user-profile/__tests__/profile-screen.test.tsx +61 -3
  38. package/src/user-profile/i18n.ts +2 -3
  39. 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.59.1",
3
+ "version": "0.60.1",
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,6 +26,7 @@
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",
@@ -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", () => {
@@ -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 {
@@ -5,22 +5,40 @@
5
5
  // (das deckt der Integration-Test ab).
6
6
 
7
7
  import { describe, expect, test } from "bun:test";
8
- import type { ConfigKeyHandle, HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
8
+ import {
9
+ type ConfigKeyHandle,
10
+ type HandlerContext,
11
+ qn,
12
+ toKebab,
13
+ } from "@cosmicdrift/kumiko-framework/engine";
9
14
  import { FeatureDisabledError, UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
10
15
  import { createSecret, type SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
11
16
  import Stripe from "stripe";
17
+ import {
18
+ STRIPE_API_KEY_CONFIG,
19
+ STRIPE_BILLING_LIVE_CONFIG,
20
+ STRIPE_WEBHOOK_SECRET_CONFIG,
21
+ SUBSCRIPTION_STRIPE_FEATURE,
22
+ } from "../constants";
12
23
  import { createStripeClientCache, createStripeRuntimes } from "../runtime";
13
24
 
25
+ // Handle-Namen aus den kanonischen Konstanten + demselben Qualifier ableiten,
26
+ // den r.config zur Build-Zeit anwendet (define-feature.ts: qn(toKebab(feature),
27
+ // "config", toKebab(shortKey))). Eine hand-redeklarierte Fixture konnte still
28
+ // von der Produktion driften (#421/2) — diese Ableitung macht das unmöglich.
29
+ const configHandleName = (shortKey: string): string =>
30
+ qn(toKebab(SUBSCRIPTION_STRIPE_FEATURE), "config", toKebab(shortKey));
31
+
14
32
  const API_KEY_HANDLE: ConfigKeyHandle<"text"> = {
15
- name: "subscription-stripe:config:api-key",
33
+ name: configHandleName(STRIPE_API_KEY_CONFIG),
16
34
  type: "text",
17
35
  };
18
36
  const WEBHOOK_SECRET_HANDLE: ConfigKeyHandle<"text"> = {
19
- name: "subscription-stripe:config:webhook-secret",
37
+ name: configHandleName(STRIPE_WEBHOOK_SECRET_CONFIG),
20
38
  type: "text",
21
39
  };
22
40
  const BILLING_LIVE_HANDLE: ConfigKeyHandle<"boolean"> = {
23
- name: "subscription-stripe:config:billing-live",
41
+ name: configHandleName(STRIPE_BILLING_LIVE_CONFIG),
24
42
  type: "boolean",
25
43
  };
26
44
 
@@ -42,13 +60,36 @@ function stubSecrets(values: Record<string, string>): SecretsContext {
42
60
  };
43
61
  }
44
62
 
63
+ /** Wie stubSecrets, aber speichert den Wert ROH (kein JSON.stringify) — um
64
+ * parseStoredSecret's Fehlerpfad zu treffen: ein Credential, das der Store
65
+ * un-JSON-kodiert zurückgibt (Korruption oder ein außerhalb des
66
+ * backing:"secrets"-Roundtrips geschriebener Wert) muss laut failen, nicht
67
+ * still Müll liefern. */
68
+ function rawSecretsStub(values: Record<string, string>): SecretsContext {
69
+ const nameOf = (k: string | { readonly name: string }): string =>
70
+ typeof k === "string" ? k : k.name;
71
+ return {
72
+ get: async (_tenantId, key) => {
73
+ const value = values[nameOf(key)];
74
+ return value === undefined ? undefined : createSecret(value); // RAW, not JSON
75
+ },
76
+ has: async (_tenantId, key) => values[nameOf(key)] !== undefined,
77
+ set: async () => undefined,
78
+ delete: async () => false,
79
+ };
80
+ }
81
+
45
82
  /** Minimaler HandlerContext-Stub mit nur den Feldern, die die ctx-
46
83
  * Resolution liest (secrets, _userId für audit, config). */
47
84
  function stubCtx(opts: { secrets?: SecretsContext; billingLive?: boolean }): HandlerContext {
48
85
  return {
49
86
  secrets: opts.secrets,
50
87
  _userId: "tester",
51
- config: async () => opts.billingLive,
88
+ // Key-aware: antwortet NUR auf das billing-live-Handle. Liest
89
+ // assertBillingLive versehentlich einen anderen Config-Key, kommt undefined
90
+ // zurück → Gate schließt → der "passes when true"-Test schlägt fehl (#421/3).
91
+ config: async (handle: ConfigKeyHandle<"boolean">) =>
92
+ handle.name === BILLING_LIVE_HANDLE.name ? opts.billingLive : undefined,
52
93
  } as unknown as HandlerContext; // @cast-boundary test-stub — partial ctx
53
94
  }
54
95
 
@@ -116,6 +157,19 @@ describe("StripeCtxRuntime.clientForCtx", () => {
116
157
  const ctx = stubCtx({ secrets: stubSecrets({}) });
117
158
  await expect(rt.ctx.clientForCtx(ctx)).rejects.toBeInstanceOf(UnconfiguredError);
118
159
  });
160
+
161
+ test("throws loudly on a malformed (non-JSON) stored credential (#393/2)", async () => {
162
+ // The store round-trips backing:"secrets" values JSON-encoded; a raw,
163
+ // un-quoted value reaching parseStoredSecret means corruption — it must
164
+ // throw, not silently fall through to undefined/fallback.
165
+ const rt = makeRuntimes({ apiKey: "sk_test_fallback" });
166
+ const ctx = stubCtx({
167
+ secrets: rawSecretsStub({ [API_KEY_HANDLE.name]: "sk_test_raw_unquoted" }),
168
+ });
169
+ await expect(rt.ctx.clientForCtx(ctx)).rejects.toThrow(
170
+ /Invalid JSON in subscription-stripe credential/,
171
+ );
172
+ });
119
173
  });
120
174
 
121
175
  // =============================================================================