@cosmicdrift/kumiko-bundled-features 0.65.0 → 0.66.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.65.0",
3
+ "version": "0.66.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>",
@@ -84,11 +84,11 @@
84
84
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
85
85
  },
86
86
  "dependencies": {
87
- "@cosmicdrift/kumiko-dispatcher-live": "0.65.0",
88
- "@cosmicdrift/kumiko-framework": "0.65.0",
89
- "@cosmicdrift/kumiko-headless": "0.65.0",
90
- "@cosmicdrift/kumiko-renderer": "0.65.0",
91
- "@cosmicdrift/kumiko-renderer-web": "0.65.0",
87
+ "@cosmicdrift/kumiko-dispatcher-live": "0.66.0",
88
+ "@cosmicdrift/kumiko-framework": "0.66.0",
89
+ "@cosmicdrift/kumiko-headless": "0.66.0",
90
+ "@cosmicdrift/kumiko-renderer": "0.66.0",
91
+ "@cosmicdrift/kumiko-renderer-web": "0.66.0",
92
92
  "@mollie/api-client": "^4.5.0",
93
93
  "@node-rs/argon2": "^2.0.2",
94
94
  "@types/nodemailer": "^8.0.0",
@@ -98,6 +98,7 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
98
98
  // client can observe it through the HTTP surface.
99
99
  if (!user || user.isDeleted || !user.email || spec.extraSilentSkip(user)) {
100
100
  const data: TokenRequestData<TSuccessKind> = { kind: "no-op" };
101
+ // skip: silent no-op — uniform response prevents user-enumeration probing
101
102
  return { isSuccess: true, data };
102
103
  }
103
104
 
@@ -87,6 +87,7 @@ export async function collectMissingRequiredConfig(
87
87
  if (keyDef.required !== true) continue;
88
88
  if (!effectiveGate(qualifiedKey)) continue;
89
89
  if (options?.skipAccessFilter !== true && !hasConfigAccess(keyDef.access.read, user.roles)) {
90
+ // skip: key not visible to this user's roles (access-filtered listing)
90
91
  continue;
91
92
  }
92
93
  candidates.set(qualifiedKey, keyDef);
@@ -334,11 +334,13 @@ describe("CustomFieldsFormSection — boolean/date-Pfade", () => {
334
334
  </Wrapper>,
335
335
  );
336
336
 
337
- // boolean rendert als Checkbox Bestand steckt in checked, nicht value.
338
- const input = document.getElementById("custom-field-active") as HTMLInputElement;
339
- expect(input.checked).toBe(true);
337
+ // boolean = vendored Radix-Checkbox button[role=checkbox], Bestand steckt
338
+ // in aria-checked (kein natives .checked).
339
+ const checkbox = document.getElementById("custom-field-active");
340
+ if (checkbox === null) throw new Error("boolean checkbox not rendered");
341
+ expect(checkbox.getAttribute("aria-checked")).toBe("true");
340
342
 
341
- fireEvent.click(input);
343
+ fireEvent.click(checkbox);
342
344
  fireEvent.click(screen.getByTestId("custom-fields-form-save"));
343
345
  await Promise.resolve();
344
346
  await Promise.resolve();
@@ -0,0 +1,53 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { TreeNode } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { legalPagesClient } from "../client-plugin";
4
+
5
+ // Deckt die drei neuen Migrations-Pfade (advisor-Gap): navId-Attach,
6
+ // no-leak ohne navId, und der Unwrap (Provider emittiert die slug-Folder
7
+ // direkt, NICHT mehr unter einem "Legal"-Wrapper — der App-r.nav-Knoten
8
+ // IST der Container). legal-pages ist fetch-frei → Provider direkt aufrufbar.
9
+
10
+ function collect(
11
+ provider: () => (emit: (n: readonly TreeNode[]) => void) => () => void,
12
+ ): readonly TreeNode[] {
13
+ let emitted: readonly TreeNode[] | undefined;
14
+ const unsub = provider()((nodes) => {
15
+ emitted = nodes;
16
+ });
17
+ unsub();
18
+ if (emitted === undefined) throw new Error("provider emitted nothing");
19
+ return emitted;
20
+ }
21
+
22
+ describe("legalPagesClient", () => {
23
+ test("ohne navId: kein navProvider (server-only-Consumer leaken keinen Node)", () => {
24
+ const def = legalPagesClient();
25
+ expect(def.name).toBe("legal-pages");
26
+ expect(def.navProviders).toBeUndefined();
27
+ });
28
+
29
+ test("mit navId: Provider hängt unter exakt dieser (pass-through) QN", () => {
30
+ const navId = "publicstatus:nav:legal";
31
+ const def = legalPagesClient({ navId });
32
+ expect(Object.keys(def.navProviders ?? {})).toEqual([navId]);
33
+ });
34
+
35
+ test("Provider unwrappt den Legal-Container: Top-Level sind die slug-Folder", () => {
36
+ const navId = "publicstatus:nav:legal";
37
+ const provider = legalPagesClient({ navId }).navProviders?.[navId];
38
+ if (provider === undefined) throw new Error("provider missing");
39
+ const emitted = collect(provider);
40
+
41
+ // Kein "Legal"-Wrapper mehr — der App-Knoten ist der Container.
42
+ expect(emitted.some((n) => n.label === "Legal")).toBe(false);
43
+ expect(emitted.length).toBeGreaterThan(0);
44
+
45
+ // Jeder Top-Level-Knoten ist ein slug-Folder mit lang-Leaves, die per
46
+ // Cross-Link auf text-content:edit zeigen.
47
+ const folder = emitted[0];
48
+ expect(Array.isArray(folder?.children)).toBe(true);
49
+ const langLeaf = Array.isArray(folder?.children) ? folder?.children[0] : undefined;
50
+ expect(langLeaf?.target?.featureId).toBe("text-content");
51
+ expect(langLeaf?.target?.action).toBe("edit");
52
+ });
53
+ });
@@ -63,20 +63,19 @@ const treeProvider: TreeChildrenSubscribe = () => (emit) => {
63
63
  });
64
64
  }
65
65
 
66
- emit([
67
- {
68
- label: "Legal",
69
- icon: "folder",
70
- state: "filled",
71
- children: slugFolders,
72
- },
73
- ]);
66
+ // Der App-seitige r.nav-Knoten IST der "Legal"-Container → der Provider
67
+ // emittiert die slug-Folder direkt darunter.
68
+ emit(slugFolders);
74
69
  return () => {};
75
70
  };
76
71
 
77
- export function legalPagesClient(): ClientFeatureDefinition {
72
+ // `navId` = QN des r.nav({ provider: true })-Knotens den die App registriert.
73
+ // Statisch (kein Fetch, keine Entities) → kein SSE-Refresh nötig. Ohne navId:
74
+ // kein Sidebar-Knoten (server-only-Consumer leaken nichts).
75
+ export function legalPagesClient(opts?: { readonly navId?: string }): ClientFeatureDefinition {
76
+ const navId = opts?.navId;
78
77
  return {
79
78
  name: "legal-pages",
80
- treeProvider,
79
+ ...(navId !== undefined && { navProviders: { [navId]: treeProvider } }),
81
80
  };
82
81
  }
@@ -0,0 +1,65 @@
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import type { TreeNode } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { textContentClient } from "../client-plugin";
4
+
5
+ // Deckt die drei neuen Migrations-Pfade (advisor-Gap): navId-Attach + SSE-
6
+ // Entities, no-leak ohne navId (conditional-spread), und der Unwrap (Provider
7
+ // emittiert die Folder/Leaves direkt, NICHT unter dem "Content"-Wrapper).
8
+ // Der Provider fetcht → fetch wird gemockt.
9
+
10
+ describe("textContentClient — shape", () => {
11
+ test("ohne navId: kein navProvider/navEntities (no-leak), aber Resolver bleibt", () => {
12
+ const def = textContentClient();
13
+ expect(def.name).toBe("text-content");
14
+ expect(def.navProviders).toBeUndefined();
15
+ expect(def.navEntities).toBeUndefined();
16
+ expect(def.resolvers?.["text-content:edit"]).toBeDefined();
17
+ });
18
+
19
+ test("mit navId: Provider + SSE-Entities unter exakt dieser QN", () => {
20
+ const navId = "publicstatus:nav:content";
21
+ const def = textContentClient({ navId });
22
+ expect(Object.keys(def.navProviders ?? {})).toEqual([navId]);
23
+ expect(def.navEntities?.[navId]).toEqual(["text-block"]);
24
+ });
25
+ });
26
+
27
+ describe("textContentClient — Provider unwrappt den Content-Container", () => {
28
+ const origFetch = globalThis.fetch;
29
+ afterEach(() => {
30
+ globalThis.fetch = origFetch;
31
+ });
32
+
33
+ test("emittiert Folder/Leaves direkt, kein 'Content'-Wrapper-Knoten", async () => {
34
+ const blocks = [
35
+ { slug: "imprint", lang: "de", title: "Imprint", body: "x", folder: null, updatedAt: "" },
36
+ { slug: "hero", lang: "de", title: "Hero", body: null, folder: "page", updatedAt: "" },
37
+ ];
38
+ // Test-Mock-Grenze: bun-Mock deckt nicht die volle fetch-Signatur
39
+ // (preconnect etc.) — Double-Cast bewusst, nur dieser Test ruft fetch.
40
+ globalThis.fetch = mock(
41
+ async () =>
42
+ new Response(JSON.stringify({ data: { blocks } }), {
43
+ status: 200,
44
+ headers: { "content-type": "application/json" },
45
+ }),
46
+ ) as unknown as typeof fetch;
47
+
48
+ const navId = "x:nav:content";
49
+ const provider = textContentClient({ navId }).navProviders?.[navId];
50
+ if (provider === undefined) throw new Error("provider missing");
51
+
52
+ let emitted: readonly TreeNode[] | undefined;
53
+ provider()((nodes) => {
54
+ emitted = nodes;
55
+ });
56
+ // fetch().then(...) ist async → eine Makrotask abwarten bis emit lief.
57
+ await new Promise((r) => setTimeout(r, 0));
58
+
59
+ expect(emitted).toBeDefined();
60
+ const labels = (emitted ?? []).map((n) => n.label).sort();
61
+ // Kein "Content"-Wrapper — root-leaf "Imprint" + folder "page" direkt.
62
+ expect(labels).not.toContain("Content");
63
+ expect(labels).toEqual(["Imprint", "page"]);
64
+ });
65
+ });
@@ -157,8 +157,10 @@ const treeProvider: TreeChildrenSubscribe = () => (emit, emitError) => {
157
157
  return r.json();
158
158
  })
159
159
  .then((data: ByTenantResponse) => {
160
- const nodes = groupBlocksByFolder(data.data.blocks);
161
- emit(nodes);
160
+ // Der App-seitige r.nav-Knoten IST der "Content"-Container → die
161
+ // Provider-Kinder sind die Folder/Leaves darunter, nicht der Wrapper.
162
+ const content = groupBlocksByFolder(data.data.blocks)[0];
163
+ emit(content !== undefined && Array.isArray(content.children) ? content.children : []);
162
164
  })
163
165
  .catch((e) => {
164
166
  // V.1.4: explicit error-Signal via emitError. ProviderBranch zeigt
@@ -346,19 +348,20 @@ function TextContentEditor({
346
348
  );
347
349
  }
348
350
 
349
- export function textContentClient(): ClientFeatureDefinition {
351
+ // `navId` = die QN des r.nav({ provider: true })-Knotens, den die App für den
352
+ // Content-Tree registriert (z.B. "publicstatus:nav:content"). Die App besitzt
353
+ // Label/Icon/Access des Knotens (managed-pages-Konvention) — das Feature
354
+ // liefert nur die Kinder + den Editor. Ohne navId: nur der Resolver, kein
355
+ // Sidebar-Knoten (server-only-Consumer wie money-horse leaken nichts).
356
+ export function textContentClient(opts?: { readonly navId?: string }): ClientFeatureDefinition {
357
+ const navId = opts?.navId;
350
358
  return {
351
359
  name: "text-content",
352
- treeProvider,
353
- // V.1.5b: SSE-driven Tree-Refresh. Bei jedem text-block-Event
354
- // (created/updated/deleted) ruft ProviderBranch den treeProvider
355
- // neu auf → Tree-State spiegelt save sofort wider (Stale-Tree-Fix).
356
- treeEntities: ["text-block"],
357
- treeActions: {
358
- edit: { args: { slug: "" as string, lang: "" as string } },
359
- list: {},
360
- create: { args: { folder: "" as string } },
361
- },
360
+ ...(navId !== undefined && {
361
+ navProviders: { [navId]: treeProvider },
362
+ // SSE-Refresh: jedes text-block-Event re-fired den Provider → Tree live.
363
+ navEntities: { [navId]: ["text-block"] },
364
+ }),
362
365
  resolvers: {
363
366
  "text-content:edit": TextContentEditor,
364
367
  },