@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 +6 -6
- package/src/auth-email-password/handlers/token-request-handler.ts +1 -0
- package/src/config/handlers/readiness.query.ts +1 -0
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +6 -4
- package/src/legal-pages/web/__tests__/client-plugin.test.ts +53 -0
- package/src/legal-pages/web/client-plugin.ts +9 -10
- package/src/text-content/web/__tests__/client-plugin.test.tsx +65 -0
- package/src/text-content/web/client-plugin.tsx +16 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "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.
|
|
88
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
89
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
90
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
91
|
-
"@cosmicdrift/kumiko-renderer-web": "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
|
|
338
|
-
|
|
339
|
-
|
|
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(
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
},
|