@cosmicdrift/kumiko-bundled-features 0.56.1 → 0.57.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.56.1",
3
+ "version": "0.57.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>",
@@ -211,6 +211,69 @@ describe("prepareConfigWrite", () => {
211
211
  if (!result.ok) throw new Error("unreachable");
212
212
  expect(result.scope).toBe(ConfigScopes.system);
213
213
  });
214
+
215
+ // #396: a tenant self-service key (admin-editable) that provisioning /
216
+ // migration must ALSO set via ctx.systemWriteAs. access.withSystem(admin)
217
+ // adds SYSTEM_ROLE without collapsing the key to system-only.
218
+ describe("access.withSystem (provisionable tenant key)", () => {
219
+ const provisionableKey = createTenantConfig("text", {
220
+ write: access.withSystem(access.admin),
221
+ });
222
+ const registry = registryStub({ "ns:config:branding-title": provisionableKey });
223
+
224
+ test("the system actor (systemWriteAs roles) may provision it", () => {
225
+ const result = prepareConfigWrite({
226
+ registry,
227
+ user: userStub([SYSTEM_ROLE]),
228
+ key: "ns:config:branding-title",
229
+ });
230
+ expect(result.ok).toBe(true);
231
+ if (!result.ok) throw new Error("unreachable");
232
+ expect(result.keyDef).toBe(provisionableKey);
233
+ });
234
+
235
+ test("a tenant admin still self-services it (NOT collapsed to system-only)", () => {
236
+ const result = prepareConfigWrite({
237
+ registry,
238
+ user: userStub(["TenantAdmin"]),
239
+ key: "ns:config:branding-title",
240
+ });
241
+ expect(result.ok).toBe(true);
242
+ if (!result.ok) throw new Error("unreachable");
243
+ expect(result.scope).toBe(ConfigScopes.tenant);
244
+ });
245
+
246
+ test("an unrelated role is still denied (generic, not system-only)", () => {
247
+ const result = prepareConfigWrite({
248
+ registry,
249
+ user: userStub(["ReadOnly"]),
250
+ key: "ns:config:branding-title",
251
+ });
252
+ expect(result.ok).toBe(false);
253
+ if (result.ok) throw new Error("unreachable");
254
+ expect(result.failure.error.code).toBe("access_denied");
255
+ expect(result.failure.error.i18nKey).not.toBe("config.errors.systemOnly");
256
+ });
257
+ });
258
+ });
259
+
260
+ describe("access.withSystem", () => {
261
+ test("prepends SYSTEM_ROLE and preserves the composed roles", () => {
262
+ expect(access.withSystem(access.admin)).toEqual([
263
+ SYSTEM_ROLE,
264
+ "TenantAdmin",
265
+ "Admin",
266
+ "SystemAdmin",
267
+ ]);
268
+ });
269
+
270
+ test("composes arbitrary custom roles (studio's own role lists)", () => {
271
+ expect(access.withSystem(access.roles("Editor", "Owner"))).toEqual([
272
+ SYSTEM_ROLE,
273
+ "Editor",
274
+ "Owner",
275
+ ]);
276
+ });
214
277
  });
215
278
 
216
279
  describe("validateBounds", () => {
@@ -1,4 +1,5 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
2
3
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
3
4
  import {
4
5
  createTestUser,
@@ -396,6 +397,30 @@ describe("managed-pages :: Branding (Config + Render)", () => {
396
397
  expect(htmlB).not.toContain("cdn-a.example.com");
397
398
  expect(htmlB).not.toContain("Acme A");
398
399
  });
400
+
401
+ // #396 — the actual gate: a provisioning/migration seed sets a tenant
402
+ // branding key via the system executor. createSystemUser(tenant) is the
403
+ // exact identity ctx.systemWriteAs injects (roles=[SYSTEM_ROLE]); stack.
404
+ // dispatcher is the same command path systemWriteAs drives. Before the
405
+ // access.withSystem(access.admin) write-role, this returned access_denied
406
+ // (forcing the publicstatus migration onto raw SQL). Drives the REAL
407
+ // config:write:set handler end-to-end and reads the value back per tenant —
408
+ // a unit test on checkWriteAccess alone wouldn't prove the handler accepts it.
409
+ test("system executor provisions a branding key (systemWriteAs path), read back per tenant", async () => {
410
+ const res = await stack.dispatcher.write(
411
+ "config:write:set",
412
+ { key: BRANDING_QN.title, value: "Provisioned by system" },
413
+ createSystemUser(TENANT_A),
414
+ );
415
+ expect(res.isSuccess).toBe(true);
416
+
417
+ const branding = await stack.http.queryOk<{ title: string }>(BRANDING_QUERY_QN, {}, adminA);
418
+ expect(branding.title).toBe("Provisioned by system");
419
+
420
+ // The provisioned value lands on TENANT_A only — not leaked to TENANT_B.
421
+ const brandingB = await stack.http.queryOk<{ title: string }>(BRANDING_QUERY_QN, {}, adminB);
422
+ expect(brandingB.title).not.toBe("Provisioned by system");
423
+ });
399
424
  });
400
425
 
401
426
  // Eigener Stack mit allowCustomCss:true + dem Companion-Toggle-Feature. Der
@@ -1,4 +1,5 @@
1
1
  import {
2
+ access,
2
3
  type ConfigAccessor,
3
4
  type ConfigKeyDefinition,
4
5
  createTenantConfig,
@@ -14,8 +15,14 @@ import { type BrandingTokens, EMPTY_BRANDING } from "../page-render";
14
15
  // Write-validated: accent-color must be a CSS hex, logo/site URLs must be
15
16
  // https. The configEdit screen dispatches `config:write:set` per key, which
16
17
  // runs the keyDef.pattern gate (set.write.ts → validatePattern). `read: all`
17
- // (scope default) so the anonymous public-render path can read them;
18
- // `write: admin` (TenantAdmin/Admin/SystemAdmin, also the scope default).
18
+ // (scope default) so the anonymous public-render path can read them.
19
+ //
20
+ // `write: access.withSystem(access.admin)` — tenant admins self-service via
21
+ // configEdit AND the system actor (ctx.systemWriteAs) may provision/migrate
22
+ // these keys. The scope default (`access.admin`, admin-only) has no system
23
+ // path, so a continuity migration could not set them (issue #396). Adding
24
+ // SYSTEM_ROLE keeps the keys human-writable (checkWriteAccess collapses to
25
+ // system-only only when system is the SOLE writer), so self-service survives.
19
26
  //
20
27
  // CONTINUITY (Phase 5 — Prod trap): these keys land under
21
28
  // `managed-pages:config:branding-*`, NOT the live `publicstatus:config:
@@ -55,15 +62,39 @@ export const MANAGED_PAGES_CSS_FEATURE = "managed-pages-css";
55
62
  // site-url`. BRANDING_QN below is the single source those QN strings are read
56
63
  // from (configEdit screen + render read); the integration test pins write-
57
64
  // target == read-source end-to-end.
65
+ // Admin self-service + system provisioning (see write-access note above, #396).
66
+ const BRANDING_WRITE = access.withSystem(access.admin);
67
+
58
68
  export const BRANDING_KEYS = {
59
- brandingTitle: createTenantConfig("text", { default: "", pattern: TITLE_PATTERN }),
60
- brandingDescription: createTenantConfig("text", { default: "", pattern: DESCRIPTION_PATTERN }),
61
- brandingSiteUrl: createTenantConfig("text", { default: "", pattern: HTTPS_PATTERN }),
62
- brandingAccentColor: createTenantConfig("text", { default: "", pattern: HEX_PATTERN }),
63
- brandingLogoUrl: createTenantConfig("text", { default: "", pattern: HTTPS_PATTERN }),
69
+ brandingTitle: createTenantConfig("text", {
70
+ default: "",
71
+ pattern: TITLE_PATTERN,
72
+ write: BRANDING_WRITE,
73
+ }),
74
+ brandingDescription: createTenantConfig("text", {
75
+ default: "",
76
+ pattern: DESCRIPTION_PATTERN,
77
+ write: BRANDING_WRITE,
78
+ }),
79
+ brandingSiteUrl: createTenantConfig("text", {
80
+ default: "",
81
+ pattern: HTTPS_PATTERN,
82
+ write: BRANDING_WRITE,
83
+ }),
84
+ brandingAccentColor: createTenantConfig("text", {
85
+ default: "",
86
+ pattern: HEX_PATTERN,
87
+ write: BRANDING_WRITE,
88
+ }),
89
+ brandingLogoUrl: createTenantConfig("text", {
90
+ default: "",
91
+ pattern: HTTPS_PATTERN,
92
+ write: BRANDING_WRITE,
93
+ }),
64
94
  brandingLayoutPreset: createTenantConfig("select", {
65
95
  default: "centered",
66
96
  options: LAYOUT_PRESETS,
97
+ write: BRANDING_WRITE,
67
98
  }),
68
99
  } satisfies Record<string, ConfigKeyDefinition>;
69
100