@cosmicdrift/kumiko-renderer-web 0.157.2 → 0.159.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.157.2",
3
+ "version": "0.159.1",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.157.2",
20
- "@cosmicdrift/kumiko-headless": "0.157.2",
21
- "@cosmicdrift/kumiko-renderer": "0.157.2",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.159.1",
20
+ "@cosmicdrift/kumiko-headless": "0.159.1",
21
+ "@cosmicdrift/kumiko-renderer": "0.159.1",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -61,6 +61,7 @@ const baseSchema: FeatureSchema = {
61
61
  featureName: "tasks",
62
62
  entities: { task: taskEntity },
63
63
  screens: [editScreen, listScreen],
64
+ navs: [{ id: "task-edit", label: "tasks:nav.task-edit", screen: "tasks:screen:task-edit" }],
64
65
  };
65
66
 
66
67
  // createKumikoApp ruft createRoot(...).render(...) direkt auf — React 18+
@@ -181,6 +182,7 @@ describe("createKumikoApp", () => {
181
182
  featureName: "tasks",
182
183
  entities: { task: taskEntity },
183
184
  screens: [{ ...listScreen, access: { openToAll: true } }],
185
+ navs: [{ id: "task-list", label: "tasks:nav.task-list", screen: "tasks:screen:task-list" }],
184
186
  };
185
187
  const schema: AppSchema = { features: [restrictedFeature, openFeature] };
186
188
  await mountApp({ schema, dispatcher: makeDispatcher() });
@@ -230,6 +232,9 @@ describe("createKumikoApp", () => {
230
232
  columns: [{ field: "color", renderer: { react: { __component: "Swatch" } } }],
231
233
  },
232
234
  ],
235
+ navs: [
236
+ { id: "color-list", label: "tasks:nav.color-list", screen: "tasks:screen:color-list" },
237
+ ],
233
238
  };
234
239
 
235
240
  mountRoot();
@@ -283,6 +288,9 @@ describe("createKumikoApp", () => {
283
288
  featureName: "tasks",
284
289
  entities: { task: colorEntity },
285
290
  screens: [colorListScreen],
291
+ navs: [
292
+ { id: "color-list", label: "tasks:nav.color-list", screen: "tasks:screen:color-list" },
293
+ ],
286
294
  };
287
295
  const dispatcher = createMockDispatcher({
288
296
  query: (async () => ({
@@ -328,6 +336,13 @@ describe("createKumikoApp", () => {
328
336
  featureName: "cap-counter",
329
337
  entities: {},
330
338
  screens: [dashboardScreen],
339
+ navs: [
340
+ {
341
+ id: "overview",
342
+ label: "cap-counter:nav.cap-list",
343
+ screen: "cap-counter:screen:overview",
344
+ },
345
+ ],
331
346
  translations: {
332
347
  "cap-counter:nav.cap-list": { de: "Limits", en: "Caps" },
333
348
  },
@@ -371,6 +386,13 @@ describe("createKumikoApp", () => {
371
386
  featureName: "cap-counter",
372
387
  entities: {},
373
388
  screens: [dashboardScreen],
389
+ navs: [
390
+ {
391
+ id: "overview",
392
+ label: "cap-counter:nav.cap-list",
393
+ screen: "cap-counter:screen:overview",
394
+ },
395
+ ],
374
396
  translations: {
375
397
  "cap-counter:nav.cap-list": { de: "Limits", en: "Caps" },
376
398
  },
@@ -499,6 +499,31 @@ describe("KumikoScreen", () => {
499
499
  expect(bannerText).not.toBe("errors.access.denied");
500
500
  });
501
501
 
502
+ test("entityList: query-Error zeigt übersetzten Text statt rohem i18nKey (issue #1193)", async () => {
503
+ const dispatcher = makeDispatcher({
504
+ query: (async () => ({
505
+ isSuccess: false,
506
+ error: {
507
+ code: "access_denied",
508
+ httpStatus: 403,
509
+ i18nKey: "errors.access.denied",
510
+ message: "",
511
+ },
512
+ })) as unknown as Dispatcher["query"],
513
+ });
514
+
515
+ render(
516
+ <DispatcherProvider dispatcher={dispatcher}>
517
+ <KumikoScreen schema={schema} qn="tasks:screen:task-list" />
518
+ </DispatcherProvider>,
519
+ );
520
+
521
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-error")).toBeTruthy());
522
+ const bannerText = screen.getByTestId("kumiko-screen-error").textContent;
523
+ expect(bannerText).toBe(kumikoDefaultTranslations["en"]?.["errors.access.denied"] ?? "");
524
+ expect(bannerText).not.toBe("errors.access.denied");
525
+ });
526
+
502
527
  // RowActions-Mapping (Tier 2.7a Resolution-Layer): pinst dass
503
528
  // EntityListBody die Schema-Form (handler-QN, label-i18nKey, payload-
504
529
  // builder, visible-Function, confirmLabel) zu DataTableRowAction
@@ -0,0 +1,58 @@
1
+ //
2
+ // Pins the XSS-escaping behavior for tenant-/pattern-authored content
3
+ // (kumiko-framework#1321). renderer-web has no dangerouslySetInnerHTML
4
+ // and no field-renderer path that wires tenant data into href/src — this
5
+ // test guards JSX auto-escaping against future regressions (e.g. if
6
+ // someone adds rich-text/markdown support via dangerouslySetInnerHTML),
7
+ // and pins the Link primitive's scheme guard (kumiko-framework#1365)
8
+ // against javascript:/data: hrefs as defense-in-depth.
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import { defaultPrimitives } from "../primitives";
12
+ import { StatCard } from "../widgets/stat";
13
+ import { render, screen } from "./test-utils";
14
+
15
+ const PAYLOAD = '<script>window.__xss = true;</script><img src=x onerror="window.__xss = true">';
16
+ const UNSAFE_HREFS = [
17
+ "javascript:window.__xss = true",
18
+ "data:text/html,<script>window.__xss=true</script>",
19
+ ];
20
+
21
+ describe("XSS-safety (tenant-authored content)", () => {
22
+ test("Text-Primitive rendert Payload als Text, nicht als HTML", () => {
23
+ render(<defaultPrimitives.Text testId="txt">{PAYLOAD}</defaultPrimitives.Text>);
24
+ const node = screen.getByTestId("txt");
25
+ expect(node.textContent).toBe(PAYLOAD);
26
+ expect(node.querySelector("script")).toBeNull();
27
+ expect(node.querySelector("img")).toBeNull();
28
+ });
29
+
30
+ test("StatCard-Widget rendert label/value/sub als Text, nicht als HTML", () => {
31
+ render(<StatCard testId="stat" label={PAYLOAD} value={PAYLOAD} sub={PAYLOAD} />);
32
+ const node = screen.getByTestId("stat");
33
+ expect(node.querySelector("script")).toBeNull();
34
+ expect(node.querySelector("img")).toBeNull();
35
+ expect(node.textContent).toContain(PAYLOAD);
36
+ });
37
+
38
+ test.each(UNSAFE_HREFS)("Link-Primitive neutralisiert unsicheren href-Scheme: %s", (href) => {
39
+ render(
40
+ <defaultPrimitives.Link testId="link" href={href}>
41
+ text
42
+ </defaultPrimitives.Link>,
43
+ );
44
+ const node = screen.getByTestId("link");
45
+ expect(node.getAttribute("href")).toBe("#");
46
+ });
47
+
48
+ test("Link-Primitive lässt sichere hrefs (https/mailto/relativ) unverändert", () => {
49
+ for (const href of ["https://example.com", "mailto:a@example.com", "/relative/path"]) {
50
+ render(
51
+ <defaultPrimitives.Link testId={`link-${href}`} href={href}>
52
+ text
53
+ </defaultPrimitives.Link>,
54
+ );
55
+ expect(screen.getByTestId(`link-${href}`).getAttribute("href")).toBe(href);
56
+ }
57
+ });
58
+ });
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { CustomScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
4
+ import { firstOpenScreenQn } from "../create-app";
5
+
6
+ function feature(
7
+ overrides: Partial<FeatureSchema> & { readonly featureName: string },
8
+ ): FeatureSchema {
9
+ return { entities: {}, screens: [], ...overrides };
10
+ }
11
+
12
+ function customScreen(
13
+ overrides: Partial<CustomScreenDefinition> & { readonly id: string },
14
+ ): CustomScreenDefinition {
15
+ return { type: "custom", renderer: {}, ...overrides };
16
+ }
17
+
18
+ describe("firstOpenScreenQn", () => {
19
+ test("picks an open screen that is placed in nav", () => {
20
+ const features: readonly FeatureSchema[] = [
21
+ feature({
22
+ featureName: "shop",
23
+ screens: [customScreen({ id: "catalog" })],
24
+ navs: [{ id: "catalog", label: "shop:nav.catalog", screen: "shop:screen:catalog" }],
25
+ }),
26
+ ];
27
+ expect(firstOpenScreenQn(features)).toBe("shop:screen:catalog");
28
+ });
29
+
30
+ test("skips a dormant open screen that has no nav entry (#1258)", () => {
31
+ const features: readonly FeatureSchema[] = [
32
+ feature({
33
+ featureName: "auth-mfa",
34
+ screens: [customScreen({ id: "auth-mfa-enable", access: { openToAll: true } })],
35
+ // No nav entry — this is auth-mfa's dormant custom-screen convention.
36
+ }),
37
+ feature({
38
+ featureName: "shop",
39
+ screens: [customScreen({ id: "catalog" })],
40
+ navs: [{ id: "catalog", label: "shop:nav.catalog", screen: "shop:screen:catalog" }],
41
+ }),
42
+ ];
43
+ expect(firstOpenScreenQn(features)).toBe("shop:screen:catalog");
44
+ });
45
+
46
+ test("skips role-restricted screens even when placed in nav", () => {
47
+ const features: readonly FeatureSchema[] = [
48
+ feature({
49
+ featureName: "admin",
50
+ screens: [customScreen({ id: "dashboard", access: { roles: ["Admin"] } })],
51
+ navs: [{ id: "dashboard", label: "admin:nav.dashboard", screen: "admin:screen:dashboard" }],
52
+ }),
53
+ ];
54
+ expect(firstOpenScreenQn(features)).toBeUndefined();
55
+ });
56
+
57
+ test("returns undefined when no screen is both open and nav-placed", () => {
58
+ const features: readonly FeatureSchema[] = [
59
+ feature({
60
+ featureName: "auth-mfa",
61
+ screens: [customScreen({ id: "auth-mfa-enable", access: { openToAll: true } })],
62
+ }),
63
+ ];
64
+ expect(firstOpenScreenQn(features)).toBeUndefined();
65
+ });
66
+
67
+ test("resolves a bare screen id in nav to the qualified screen name", () => {
68
+ const features: readonly FeatureSchema[] = [
69
+ feature({
70
+ featureName: "shop",
71
+ screens: [customScreen({ id: "catalog" })],
72
+ navs: [{ id: "catalog", label: "shop:nav.catalog", screen: "catalog" }],
73
+ }),
74
+ ];
75
+ expect(firstOpenScreenQn(features)).toBe("shop:screen:catalog");
76
+ });
77
+ });
@@ -135,10 +135,30 @@ function readInjectedSchema(): AppSchema | FeatureSchema | undefined {
135
135
  // ein role-restricted Screen (z.B. bundled user/tenant, SystemAdmin-only)
136
136
  // darf hier nie gewinnen, sonst landet jeder Nicht-Admin auf einem
137
137
  // Access-Denied-Screen (#1176).
138
- function firstOpenScreenQn(features: readonly FeatureSchema[]): string | undefined {
138
+ // Also requires the screen be reachable via r.nav, otherwise a dormant
139
+ // `type: "custom"` screen a feature only registers for manual app-side
140
+ // placement (e.g. auth-mfa's enable screen) can win by declaration order
141
+ // alone, landing every app without an explicit `screenQn` on a screen
142
+ // nobody wired a component for (#1258).
143
+ export function firstOpenScreenQn(features: readonly FeatureSchema[]): string | undefined {
144
+ // NavDefinition.screen carries two shapes in practice: most bundled
145
+ // features author it pre-qualified ("tenant:screen:members"), but the
146
+ // config settings-hub generator emits the bare short id. Index both
147
+ // forms so nav-reachability doesn't depend on which convention a given
148
+ // feature happens to use.
149
+ const navScreenQns = new Set<string>();
150
+ for (const f of features) {
151
+ for (const n of f.navs ?? []) {
152
+ if (n.screen === undefined) continue;
153
+ navScreenQns.add(n.screen);
154
+ navScreenQns.add(qualifyScreenId(f.featureName, n.screen));
155
+ }
156
+ }
139
157
  for (const feature of features) {
140
158
  const openScreen = feature.screens.find(
141
- (s) => s.access === undefined || "openToAll" in s.access,
159
+ (s) =>
160
+ (s.access === undefined || "openToAll" in s.access) &&
161
+ navScreenQns.has(qualifyScreenId(feature.featureName, s.id)),
142
162
  );
143
163
  if (openScreen !== undefined) return qualifyScreenId(feature.featureName, openScreen.id);
144
164
  }
@@ -1661,6 +1661,15 @@ function DefaultText({ variant = "body", children, testId }: TextProps): ReactNo
1661
1661
 
1662
1662
  // ---- Link (anchor mit Button-/Muted-Optik) ----
1663
1663
 
1664
+ // http(s)/mailto or scheme-less (relative/anchor) allowed; javascript:, data:,
1665
+ // vbscript:, etc. rejected. Mirrors renderer-mail-html's renderSafeMarkdown
1666
+ // href guard — no sanitizer dependency needed for a four-line regex check.
1667
+ function isSafeHref(href: string): boolean {
1668
+ const trimmed = href.trim().toLowerCase();
1669
+ if (!/^[a-z][a-z0-9+.-]*:/.test(trimmed)) return true;
1670
+ return /^(?:https?|mailto):/.test(trimmed);
1671
+ }
1672
+
1664
1673
  // `button` nutzt die Primary-Buttonfläche auf einem semantischen <a> —
1665
1674
  // der Standard für „weiter zu"-Navigationen nach Success-States (ehem.
1666
1675
  // authButtonClass), `muted` der dezente Sekundär-Link (ehem.
@@ -1681,7 +1690,7 @@ function DefaultLink({
1681
1690
  : "text-primary underline-offset-4 hover:underline";
1682
1691
  return (
1683
1692
  <a
1684
- href={href}
1693
+ href={isSafeHref(href) ? href : "#"}
1685
1694
  target={target}
1686
1695
  rel={target === "_blank" ? "noreferrer" : undefined}
1687
1696
  data-testid={testId}