@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 +4 -4
- package/src/__tests__/create-app.test.tsx +22 -0
- package/src/__tests__/kumiko-screen.test.tsx +25 -0
- package/src/__tests__/xss-safety.test.tsx +58 -0
- package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
- package/src/app/create-app.tsx +22 -2
- package/src/primitives/index.tsx +10 -1
- /package/src/__tests__/{download.test.ts → download.test.tsx} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
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
|
+
});
|
package/src/app/create-app.tsx
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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
|
}
|
package/src/primitives/index.tsx
CHANGED
|
@@ -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}
|
|
File without changes
|