@cosmicdrift/kumiko-bundled-features 0.176.2 → 0.178.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.176.2",
3
+ "version": "0.178.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>",
@@ -120,12 +120,12 @@
120
120
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
121
121
  },
122
122
  "dependencies": {
123
- "@cosmicdrift/kumiko-dispatcher-live": "0.176.2",
124
- "@cosmicdrift/kumiko-framework": "0.176.2",
125
- "@cosmicdrift/kumiko-headless": "0.176.2",
126
- "@cosmicdrift/kumiko-renderer": "0.176.2",
127
- "@cosmicdrift/kumiko-renderer-web": "0.176.2",
128
- "@cosmicdrift/kumiko-types": "0.176.2",
123
+ "@cosmicdrift/kumiko-dispatcher-live": "0.178.0",
124
+ "@cosmicdrift/kumiko-framework": "0.178.0",
125
+ "@cosmicdrift/kumiko-headless": "0.178.0",
126
+ "@cosmicdrift/kumiko-renderer": "0.178.0",
127
+ "@cosmicdrift/kumiko-renderer-web": "0.178.0",
128
+ "@cosmicdrift/kumiko-types": "0.178.0",
129
129
  "@mollie/api-client": "^4.5.0",
130
130
  "imapflow": "^1.3.3",
131
131
  "mailparser": "^3.9.8",
@@ -128,13 +128,15 @@ kennt keinen draft-Status — Speichern veröffentlicht.
128
128
 
129
129
  | Handler | QN | Wer | Was |
130
130
  |---|---|---|---|
131
- | `TemplateResolverHandlers.set` | `template-resolver:write:set` | TenantAdmin + SystemAdmin (via `tenantIdOverride` auch auf `SYSTEM_TENANT_ID`) | Upsert eines Text-Blocks pro `(tenantId, slug, locale)` |
131
+ | `TemplateResolverHandlers.set` | `template-resolver:write:set` | TenantAdmin + SystemAdmin (via `tenantIdOverride` auch auf `SYSTEM_TENANT_ID`) | Upsert einer Ressource pro `(tenantId, slug, kind, locale)` |
132
132
  | `TemplateResolverQueries.bySlug` | `template-resolver:query:by-slug` | anonymous + User + Admins | Ein Text-Block — der Public-Read für Landing-/Legal-Pages |
133
133
  | `TemplateResolverQueries.byTenant` | `template-resolver:query:by-tenant` | anonymous + User + Admins | Alle Text-Blöcke eines Tenants für den Content-Tree |
134
+ | `<collection>-list` / `-item` / `-set` | `template-resolver:query:<id>-list` … | wie deklariert | Pro Content-Collection, siehe unten |
134
135
 
135
- Die beiden Queries sind fest auf `kind: "text-block"` verdrahtet: Mail-Templates
136
- und AI-Prompts liegen in derselben Tabelle und dürfen nicht über den anonymen
137
- Pfad rausfallen.
136
+ Die beiden Public-Queries sind fest auf `kind: "text-block"` verdrahtet und
137
+ nehmen **keinen** kind-Parameter. Mail-Templates und AI-Prompts liegen in
138
+ derselben Tabelle und laufen über die Handler ihrer Collection. So kann ein
139
+ neuer kind nicht versehentlich öffentlich lesbar werden.
138
140
 
139
141
  Seed-Helper: `seedTextBlock` / `seedLegalContentFromJson` aus
140
142
  `@cosmicdrift/kumiko-bundled-features/template-resolver/seeding`.
@@ -149,6 +151,67 @@ createKumikoApp({
149
151
  });
150
152
  ```
151
153
 
154
+ ## Content-Collections
155
+
156
+ Eine Sammlung erscheint dort in der Nav, wo sie fachlich hingehört — statt alles
157
+ in einen zentralen "Content"-Bereich zu kippen. Deklariert wird sie **beim
158
+ Mounten**, nicht im Feature:
159
+
160
+ ```typescript
161
+ createTemplateResolverFeature({
162
+ collections: [
163
+ {
164
+ id: "reply-snippets",
165
+ kind: "mail-html",
166
+ access: { roles: ["Agent", "TenantAdmin"] },
167
+ nav: { label: "mail:nav.snippets", parent: "mail:nav:root" },
168
+ },
169
+ {
170
+ id: "ai-prompts",
171
+ kind: "ai-prompt",
172
+ access: { roles: ["PromptEngineer"] },
173
+ nav: { label: "mail:nav.prompts", parent: "mail:nav:root" },
174
+ },
175
+ ],
176
+ });
177
+ ```
178
+
179
+ `access` gehört an den Mount, weil ein bundled-feature das Rollenvokabular der
180
+ App nicht kennen kann — dasselbe Muster wie die `access`-Option von
181
+ `tags`/`folders`/`ledger`. Ohne Angabe gilt TenantAdmin + SystemAdmin.
182
+
183
+ Jede Collection bekommt **eigene Handler** — `<id>-list`, `<id>-item`,
184
+ `<id>-set` — die je die Access-Regel ihrer Collection tragen. Deshalb setzt der
185
+ Dispatcher die Trennung durch: wer Snippets pflegen darf, kommt an den
186
+ AI-Prompts nicht vorbei. Ein gemeinsamer Handler mit `kind`-Parameter müsste
187
+ die Vereinigung aller Rollen zulassen und im Body sortieren.
188
+
189
+ Der Payload trägt entsprechend **keinen** `kind` — `kind`, `ownership` und
190
+ `access` stammen aus der Deklaration. Zwei Collections dürfen denselben `kind`
191
+ mit unterschiedlichen Regeln führen.
192
+
193
+ `textBlocksClient()` bedient jede deklarierte Collection automatisch: ein
194
+ Folder-Tree pro Collection inklusive SSE-Refresh, Editor-Ziel mit der
195
+ `collectionId`. Die App reicht dafür nichts durch.
196
+
197
+ `nav.parent` darf auf ein fremdes Feature zeigen. Der Boot-Validator lehnt
198
+ dangling Refs ab — eine Collection unter einem nicht gemounteten Feature lässt
199
+ den Boot scheitern statt still aus der Sidebar zu verschwinden. Die
200
+ Sichtbarkeit des Knotens folgt `access`, sofern `nav.access` nichts anderes
201
+ sagt.
202
+
203
+ Ein "+" am Knoten braucht ein explizites `nav.createAction` (Ziel ist
204
+ üblicherweise `treeHandle.create`); ohne das listet die Collection nur, was
205
+ schon existiert. `nav.actions` setzt Hover-Actions auf die Zeile.
206
+
207
+ Speichern **veröffentlicht sofort** — Status `active`, leeres `variableSchema`.
208
+ Wer eine Draft-Stufe oder ein Variablen-Schema braucht, nimmt
209
+ `upsertSystem`/`upsertTenant` + `publish`.
210
+
211
+ `ownership: "user"` (jeder Enduser pflegt seine eigenen Einträge, z.B.
212
+ Signaturen) ist im Typ vorhanden, aber noch nicht implementiert — der Mount
213
+ wirft dann, statt still allen denselben Satz zu zeigen. Siehe #1770.
214
+
152
215
  ## Out-of-Scope
153
216
 
154
217
  - Rendering (Markdown/MJML → HTML/PDF) — siehe `renderer-foundation`
@@ -0,0 +1,281 @@
1
+ // Collections are declared at mount, each with its own access rule, and each
2
+ // gets its own handler trio. The point of the split is that the dispatcher
3
+ // enforces the separation: someone who may curate reply snippets must not
4
+ // reach the AI prompts, and neither must show up on the anonymous path that
5
+ // serves the public legal pages.
6
+
7
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
+ import { type DbConnection, fetchOne } from "@cosmicdrift/kumiko-framework/db";
9
+ import { createAnonymousUser } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
11
+ import {
12
+ createTestUser,
13
+ setupTestStack,
14
+ type TestStack,
15
+ unsafeCreateEntityTable,
16
+ } from "@cosmicdrift/kumiko-framework/stack";
17
+ import { createTemplateResolverFeature } from "../feature";
18
+ import {
19
+ collectionHandlerName,
20
+ collectionQueryName,
21
+ TemplateResolverHandlers,
22
+ TemplateResolverQueries,
23
+ } from "../qualified-names";
24
+ import { seedTextBlock } from "../seeding";
25
+ import { type TemplateResourceRow, templateResourceEntity, templateResourcesTable } from "../table";
26
+
27
+ let stack: TestStack;
28
+ let db: DbConnection;
29
+
30
+ const tenantAdmin = createTestUser({ id: 2, roles: ["TenantAdmin"] });
31
+ const plainUser = createTestUser({ id: 3 });
32
+ // The app's own role vocabulary — the whole reason access is declared at mount.
33
+ const agent = createTestUser({ id: 4, roles: ["Agent"] });
34
+ const promptEngineer = createTestUser({ id: 5, roles: ["PromptEngineer"] });
35
+
36
+ const SNIPPETS_LIST = collectionQueryName("reply-snippets", "list");
37
+ const SNIPPETS_ITEM = collectionQueryName("reply-snippets", "item");
38
+ const SNIPPETS_SET = collectionHandlerName("reply-snippets");
39
+ const PROMPTS_LIST = collectionQueryName("ai-prompts", "list");
40
+ const PROMPTS_SET = collectionHandlerName("ai-prompts");
41
+
42
+ const feature = createTemplateResolverFeature({
43
+ collections: [
44
+ {
45
+ id: "reply-snippets",
46
+ kind: "mail-html",
47
+ access: { roles: ["Agent", "TenantAdmin"] },
48
+ nav: { label: "mail:nav.snippets" },
49
+ },
50
+ {
51
+ id: "ai-prompts",
52
+ kind: "ai-prompt",
53
+ access: { roles: ["PromptEngineer"] },
54
+ nav: { label: "mail:nav.prompts" },
55
+ },
56
+ ],
57
+ });
58
+
59
+ beforeAll(async () => {
60
+ stack = await setupTestStack({ features: [feature] });
61
+ db = stack.db;
62
+ await unsafeCreateEntityTable(db, templateResourceEntity);
63
+ await createEventsTable(db);
64
+
65
+ // A text-block and a snippet on the same tenant with the same slug — proves
66
+ // the collection selects the row rather than decorating the payload.
67
+ await seedTextBlock(db, {
68
+ tenantId: agent.tenantId,
69
+ slug: "welcome",
70
+ locale: "de",
71
+ title: "Willkommen (Text-Block)",
72
+ content: "Statischer Text.",
73
+ });
74
+ await stack.http.writeOk(
75
+ SNIPPETS_SET,
76
+ {
77
+ slug: "welcome",
78
+ locale: "de",
79
+ title: "Willkommen (Snippet)",
80
+ content: "<p>Hallo {{name}}</p>",
81
+ contentFormat: "html",
82
+ },
83
+ agent,
84
+ );
85
+ });
86
+
87
+ afterAll(async () => {
88
+ await stack.cleanup();
89
+ });
90
+
91
+ describe("collection access :: declared roles, not hardcoded admin", () => {
92
+ test("the app's own role may read its collection", async () => {
93
+ const result = await stack.http.queryOk<{ blocks: readonly { title: string }[] }>(
94
+ SNIPPETS_LIST,
95
+ {},
96
+ agent,
97
+ );
98
+ expect(result.blocks.map((b) => b.title)).toEqual(["Willkommen (Snippet)"]);
99
+ });
100
+
101
+ test("the app's own role may write to its collection", async () => {
102
+ const result = await stack.http.writeOk<{ isNew: boolean }>(
103
+ SNIPPETS_SET,
104
+ {
105
+ slug: "thanks",
106
+ locale: "de",
107
+ title: "Danke",
108
+ content: "<p>Danke für Ihre Nachricht.</p>",
109
+ contentFormat: "html",
110
+ },
111
+ agent,
112
+ );
113
+ expect(result.isNew).toBe(true);
114
+ });
115
+
116
+ test("a role that may curate snippets may NOT reach the prompts", async () => {
117
+ const error = await stack.http.queryErr(PROMPTS_LIST, {}, agent);
118
+ expect(error.code).toBe("access_denied");
119
+ });
120
+
121
+ test("and may not write them either", async () => {
122
+ const error = await stack.http.writeErr(
123
+ PROMPTS_SET,
124
+ { slug: "triage", locale: "de", title: "Triage", content: "x", contentFormat: "plain" },
125
+ agent,
126
+ );
127
+ expect(error.code).toBe("access_denied");
128
+ });
129
+
130
+ test("the prompt role reaches the prompts", async () => {
131
+ const result = await stack.http.queryOk<{ blocks: readonly unknown[] }>(
132
+ PROMPTS_LIST,
133
+ {},
134
+ promptEngineer,
135
+ );
136
+ expect(result.blocks).toEqual([]);
137
+ });
138
+
139
+ test("but not the snippets — the separation cuts both ways", async () => {
140
+ const error = await stack.http.queryErr(SNIPPETS_LIST, {}, promptEngineer);
141
+ expect(error.code).toBe("access_denied");
142
+ });
143
+
144
+ test("a user with no declared role reaches neither", async () => {
145
+ expect((await stack.http.queryErr(SNIPPETS_LIST, {}, plainUser)).code).toBe("access_denied");
146
+ expect((await stack.http.queryErr(PROMPTS_LIST, {}, plainUser)).code).toBe("access_denied");
147
+ });
148
+
149
+ test("anonymous reaches neither", async () => {
150
+ const anon = createAnonymousUser(agent.tenantId);
151
+ expect((await stack.http.queryErr(SNIPPETS_LIST, {}, anon)).code).toBe("access_denied");
152
+ expect((await stack.http.queryErr(PROMPTS_LIST, {}, anon)).code).toBe("access_denied");
153
+ });
154
+
155
+ test("TenantAdmin is in the declared list for snippets, so it passes there", async () => {
156
+ const result = await stack.http.queryOk<{ blocks: readonly unknown[] }>(
157
+ SNIPPETS_LIST,
158
+ {},
159
+ tenantAdmin,
160
+ );
161
+ expect(result.blocks.length).toBeGreaterThan(0);
162
+ });
163
+
164
+ test("TenantAdmin is NOT in the prompt list, so being admin doesn't help", async () => {
165
+ // The regression this guards: reverting to a hardcoded admin rule would
166
+ // make this pass and quietly undo the whole point of declaring access.
167
+ const error = await stack.http.queryErr(PROMPTS_LIST, {}, tenantAdmin);
168
+ expect(error.code).toBe("access_denied");
169
+ });
170
+ });
171
+
172
+ describe("collection isolation :: same slug, different collection", () => {
173
+ test("the collection's item handler returns its own row, not the text-block", async () => {
174
+ const snippet = await stack.http.queryOk<{ title: string }>(
175
+ SNIPPETS_ITEM,
176
+ { slug: "welcome", locale: "de" },
177
+ agent,
178
+ );
179
+ expect(snippet.title).toBe("Willkommen (Snippet)");
180
+
181
+ const block = await stack.http.queryOk<{ title: string }>(
182
+ TemplateResolverQueries.bySlug,
183
+ { slug: "welcome", locale: "de" },
184
+ tenantAdmin,
185
+ );
186
+ expect(block.title).toBe("Willkommen (Text-Block)");
187
+ });
188
+
189
+ test("writing through the collection leaves the text-block untouched", async () => {
190
+ await stack.http.writeOk(
191
+ SNIPPETS_SET,
192
+ {
193
+ slug: "welcome",
194
+ locale: "de",
195
+ title: "Willkommen (Snippet, v2)",
196
+ content: "<p>Servus</p>",
197
+ contentFormat: "html",
198
+ },
199
+ agent,
200
+ );
201
+
202
+ const block = await stack.http.queryOk<{ title: string }>(
203
+ TemplateResolverQueries.bySlug,
204
+ { slug: "welcome", locale: "de" },
205
+ tenantAdmin,
206
+ );
207
+ expect(block.title).toBe("Willkommen (Text-Block)");
208
+ });
209
+
210
+ test("creating through a collection publishes immediately — no draft stage", async () => {
211
+ await stack.http.writeOk(
212
+ SNIPPETS_SET,
213
+ {
214
+ slug: "invoice",
215
+ locale: "de",
216
+ title: "Rechnung",
217
+ content: "<p>Rechnung</p>",
218
+ contentFormat: "html",
219
+ },
220
+ agent,
221
+ );
222
+
223
+ const row = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
224
+ tenantId: agent.tenantId,
225
+ slug: "invoice",
226
+ kind: "mail-html",
227
+ locale: "de",
228
+ });
229
+ // The tree editor is the no-draft route; upsertTenant + publish is the one
230
+ // that stages. Pinned because the split is easy to "fix" by accident.
231
+ expect(row?.status).toBe("active");
232
+ expect(row?.variableSchema).toBe("{}");
233
+ });
234
+ });
235
+
236
+ describe("public handlers stay public and stay text-block", () => {
237
+ test("anonymous may list text-blocks", async () => {
238
+ const result = await stack.http.queryOk<{ blocks: readonly { slug: string }[] }>(
239
+ TemplateResolverQueries.byTenant,
240
+ {},
241
+ createAnonymousUser(agent.tenantId),
242
+ );
243
+ expect(result.blocks.map((b) => b.slug)).toContain("welcome");
244
+ });
245
+
246
+ test("a kind in the payload changes nothing — there is no such field", async () => {
247
+ const result = await stack.http.queryOk<{ blocks: readonly { title: string }[] }>(
248
+ TemplateResolverQueries.byTenant,
249
+ { kind: "mail-html" },
250
+ createAnonymousUser(agent.tenantId),
251
+ );
252
+ expect(result.blocks.map((b) => b.title)).toEqual(["Willkommen (Text-Block)"]);
253
+ });
254
+
255
+ test("set still authors text-blocks for the hand-wired content tree", async () => {
256
+ const result = await stack.http.writeOk<{ isNew: boolean }>(
257
+ TemplateResolverHandlers.set,
258
+ { slug: "imprint", locale: "de", title: "Impressum", content: "## Angaben" },
259
+ tenantAdmin,
260
+ );
261
+ expect(result.isNew).toBe(true);
262
+ });
263
+ });
264
+
265
+ describe("mount-time guard", () => {
266
+ test("ownership 'user' is rejected until the ownerId column exists", () => {
267
+ expect(() =>
268
+ createTemplateResolverFeature({
269
+ collections: [
270
+ {
271
+ id: "signatures",
272
+ kind: "mail-html",
273
+ ownership: "user",
274
+ access: { roles: ["Agent"] },
275
+ nav: { label: "mail:nav.signatures" },
276
+ },
277
+ ],
278
+ }),
279
+ ).toThrow(/#1770/);
280
+ });
281
+ });
@@ -1,3 +1,7 @@
1
+ // @runtime client
2
+ // Plain data, no imports — the content tree in web/ needs TEXT_BLOCK_KIND to
3
+ // pick between the public and the admin query.
4
+ //
1
5
  // RenderKind identifiziert die Konsumenten-Klasse eines Templates.
2
6
  // Plugin-Renderer in `renderer-foundation` matchen auf kind; der
3
7
  // Resolver hier ist kind-agnostisch — er lädt nur, das Content-Format
@@ -1,6 +1,12 @@
1
- import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
1
+ import {
2
+ type ContentCollectionDefinition,
3
+ defineFeature,
4
+ } from "@cosmicdrift/kumiko-framework/engine";
2
5
  import { bySlugQuery } from "./handlers/by-slug.query";
3
6
  import { byTenantQuery } from "./handlers/by-tenant.query";
7
+ import { makeCollectionItemQuery } from "./handlers/collection-item.query";
8
+ import { makeCollectionListQuery } from "./handlers/collection-list.query";
9
+ import { makeCollectionSetWrite } from "./handlers/collection-set.write";
4
10
  import { findByIdQuery } from "./handlers/find-by-id.query";
5
11
  import { listQuery } from "./handlers/list.query";
6
12
  import { setWrite } from "./handlers/set.write";
@@ -17,7 +23,31 @@ import { templateResourceEntity } from "./table";
17
23
  // - Render-Time: ctx.templateResolver.resolveTemplate(...) (siehe api.ts)
18
24
  // - Admin-UI: write/query-handlers (upsertSystem, upsertTenant, publish, archive, findById, list)
19
25
  // - Cross-Feature: requireTemplateResolver(ctx, callerName) — Pattern wie requireTextContent
20
- export function createTemplateResolverFeature() {
26
+ export type TemplateResolverOptions = {
27
+ /** Content collections this app mounts. Declared here rather than inside the
28
+ * feature because `access` needs the host's role vocabulary — a bundled
29
+ * feature cannot know that an app calls its support staff "Agent". Same
30
+ * reasoning as the `access` option on tags/folders/ledger.
31
+ *
32
+ * Each collection gets its own list/item/set handlers named
33
+ * `<id>-list` / `<id>-item` / `<id>-set`, each carrying that collection's
34
+ * access rule, so the dispatcher enforces the separation instead of a
35
+ * branch in a shared handler. */
36
+ readonly collections?: readonly ContentCollectionDefinition[];
37
+ };
38
+
39
+ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}) {
40
+ const collections = opts.collections ?? [];
41
+ const userOwned = collections.filter((c) => c.ownership === "user");
42
+ if (userOwned.length > 0) {
43
+ // Fail loudly at mount instead of quietly serving one shared set to every
44
+ // user — the ownerId column doesn't exist yet (#1770).
45
+ throw new Error(
46
+ `template-resolver: ownership "user" is not implemented yet (#1770) — ` +
47
+ `collections ${userOwned.map((c) => `"${c.id}"`).join(", ")} would silently ` +
48
+ `share one tenant-wide set between all users.`,
49
+ );
50
+ }
21
51
  return defineFeature("template-resolver", (r) => {
22
52
  r.describe(
23
53
  "Stores notification and mail templates in the database with a 4-level fallback: tenant+locale \u2192 system+locale \u2192 tenant+fallback-locale \u2192 system+fallback-locale. Call `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })` at render time; manage templates via the `upsertSystem`, `upsertTenant`, `publish`, and `archive` write handlers. Tenants can override system-default templates without touching application code.",
@@ -44,6 +74,13 @@ export function createTemplateResolverFeature() {
44
74
  byTenant: r.queryHandler(byTenantQuery),
45
75
  };
46
76
 
77
+ for (const collection of collections) {
78
+ r.contentCollection(collection);
79
+ r.queryHandler(makeCollectionListQuery(collection));
80
+ r.queryHandler(makeCollectionItemQuery(collection));
81
+ r.writeHandler(makeCollectionSetWrite(collection));
82
+ }
83
+
47
84
  // Visual-tree actions for the text-block content tree. The handle is
48
85
  // propagated through the setup export so other features can build
49
86
  // compile-time-typed cross-feature edit targets.
@@ -9,8 +9,11 @@ import { type TemplateResourceRow, templateResourcesTable } from "../table";
9
9
 
10
10
  // Public read of a single text-block by (tenantId, slug, locale). Anonymous
11
11
  // must be listed explicitly — `openToAll` alone is auth-only (regression
12
- // guard). The kind is pinned to `text-block`: mail templates and AI prompts
13
- // live in the same table and must not be readable without a session.
12
+ // guard). The kind is pinned to `text-block` and takes no parameter: mail
13
+ // templates and AI prompts live in the same table and must not be readable
14
+ // without a session. Collections of any other kind go through
15
+ // `collection-item`, which is admin-only by its access rule rather than by a
16
+ // branch in here.
14
17
  //
15
18
  // Tenant scope defaults to query.user.tenantId (an anonymous context resolves
16
19
  // to SYSTEM_TENANT_ID or the host-resolved tenant, depending on app setup).
@@ -8,9 +8,11 @@ import { z } from "zod";
8
8
  import { TEXT_BLOCK_KIND } from "../constants";
9
9
  import { type TemplateResourceRow, templateResourcesTable } from "../table";
10
10
 
11
- // All text-blocks of one tenant — feeds the content tree sidebar. Anonymous
12
- // is listed explicitly so no-JWT visitors get the sidebar on public pages;
13
- // `kind` is pinned to text-block for the same reason as in by-slug.
11
+ // All text-blocks of one tenant — feeds the public content tree sidebar.
12
+ // Anonymous is listed explicitly so no-JWT visitors get the sidebar on public
13
+ // pages; `kind` is pinned to text-block for the same reason as in by-slug and
14
+ // takes no parameter. Trees for any other kind go through `collection-list`,
15
+ // which is admin-only by its access rule.
14
16
  //
15
17
  // Unlike by-slug this returns summaries for every slug. The body travels along
16
18
  // because the tree marks empty blocks as stubs; full render content for a
@@ -0,0 +1,52 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import {
3
+ type ContentCollectionDefinition,
4
+ crossTenantOverrideDenied,
5
+ defineQueryHandler,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { z } from "zod";
8
+ import { type TemplateResourceRow, templateResourcesTable } from "../table";
9
+ import { DEFAULT_COLLECTION_ACCESS, ownerFilter } from "./collection-shared";
10
+
11
+ // Single entry of one collection, for the editor behind a tree node. Same
12
+ // per-collection construction as collection-list — see there for why.
13
+ export function makeCollectionItemQuery(collection: ContentCollectionDefinition) {
14
+ const isUserOwned = collection.ownership === "user";
15
+ return defineQueryHandler({
16
+ name: `${collection.id}-item`,
17
+ schema: z.object({
18
+ slug: z.string().min(1).max(80),
19
+ locale: z.string().min(2).max(8),
20
+ /** Optional cross-tenant read — SystemAdmin only. */
21
+ tenantIdOverride: z.string().min(1).optional(),
22
+ }),
23
+ access: collection.access ?? DEFAULT_COLLECTION_ACCESS,
24
+ handler: async (query, ctx) => {
25
+ const override = query.payload.tenantIdOverride;
26
+ const overrideDenied = crossTenantOverrideDenied(
27
+ query.user,
28
+ override,
29
+ "templateResolver.errors.tenantOverrideRequiresSystemAdmin",
30
+ );
31
+ if (overrideDenied) throw overrideDenied;
32
+ const tenantId = override ?? query.user.tenantId;
33
+ const row = await fetchOne<TemplateResourceRow>(ctx.db, templateResourcesTable, {
34
+ tenantId,
35
+ slug: query.payload.slug,
36
+ kind: collection.kind,
37
+ locale: query.payload.locale,
38
+ ...ownerFilter(isUserOwned, query.user),
39
+ });
40
+
41
+ if (!row) return null;
42
+ return {
43
+ slug: row.slug,
44
+ locale: row.locale,
45
+ title: row.title,
46
+ content: row.content,
47
+ folder: row.folder,
48
+ updatedAt: row.updatedAt,
49
+ };
50
+ },
51
+ });
52
+ }
@@ -0,0 +1,57 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { castTenantRows } from "@cosmicdrift/kumiko-framework/db";
3
+ import {
4
+ type ContentCollectionDefinition,
5
+ crossTenantOverrideDenied,
6
+ defineQueryHandler,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { z } from "zod";
9
+ import { type TemplateResourceRow, templateResourcesTable } from "../table";
10
+ import { DEFAULT_COLLECTION_ACCESS, ownerFilter } from "./collection-shared";
11
+
12
+ // One list handler per declared collection: `kind`, `ownership` and `access`
13
+ // come from the declaration, never from the payload.
14
+ //
15
+ // Why per collection instead of one handler taking a kind: `access` is then
16
+ // the handler's own rule and the dispatcher enforces it. A shared handler
17
+ // would have to admit the union of every collection's roles and sort them out
18
+ // in its body — one bug there and a prompt engineer's collection is open to
19
+ // everyone who may edit a signature.
20
+ export function makeCollectionListQuery(collection: ContentCollectionDefinition) {
21
+ const isUserOwned = collection.ownership === "user";
22
+ return defineQueryHandler({
23
+ name: `${collection.id}-list`,
24
+ schema: z.object({
25
+ /** Optional cross-tenant read — SystemAdmin only. */
26
+ tenantIdOverride: z.string().min(1).optional(),
27
+ }),
28
+ access: collection.access ?? DEFAULT_COLLECTION_ACCESS,
29
+ handler: async (query, ctx) => {
30
+ const override = query.payload.tenantIdOverride;
31
+ const overrideDenied = crossTenantOverrideDenied(
32
+ query.user,
33
+ override,
34
+ "templateResolver.errors.tenantOverrideRequiresSystemAdmin",
35
+ );
36
+ if (overrideDenied) throw overrideDenied;
37
+ const tenantId = override ?? query.user.tenantId;
38
+ const rows = castTenantRows<TemplateResourceRow>(
39
+ await selectMany(ctx.db, templateResourcesTable, {
40
+ tenantId,
41
+ kind: collection.kind,
42
+ ...ownerFilter(isUserOwned, query.user),
43
+ }),
44
+ );
45
+ return {
46
+ blocks: rows.map((row) => ({
47
+ slug: row.slug,
48
+ locale: row.locale,
49
+ title: row.title,
50
+ content: row.content,
51
+ folder: row.folder,
52
+ updatedAt: row.updatedAt,
53
+ })),
54
+ };
55
+ },
56
+ });
57
+ }
@@ -0,0 +1,112 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import {
3
+ type ContentCollectionDefinition,
4
+ crossTenantOverrideDenied,
5
+ defineWriteHandler,
6
+ SYSTEM_TENANT_ID,
7
+ type TenantId,
8
+ } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
10
+ import { z } from "zod";
11
+ import { type TemplateResourceRow, templateResourcesTable } from "../table";
12
+ import { DEFAULT_COLLECTION_ACCESS, ownerFilter } from "./collection-shared";
13
+ import { contentFormatSchema, executor, folderSchema, localeSchema, slugSchema } from "./shared";
14
+
15
+ // Upsert inside one collection. Mirrors set.write for text-blocks, but the
16
+ // kind comes from the declaration and the access rule is the collection's own,
17
+ // so an app can let agents edit their snippets without letting them near the
18
+ // AI prompts.
19
+ //
20
+ // Like set.write this publishes on save (status active, empty variableSchema)
21
+ // — the draft stage lives on upsertTenant + publish.
22
+ export function makeCollectionSetWrite(collection: ContentCollectionDefinition) {
23
+ const isUserOwned = collection.ownership === "user";
24
+ return defineWriteHandler({
25
+ name: `${collection.id}-set`,
26
+ schema: z.object({
27
+ slug: slugSchema,
28
+ locale: localeSchema,
29
+ title: z.string().min(1).max(200),
30
+ content: z.string().max(200_000).nullable(),
31
+ contentFormat: contentFormatSchema.default("markdown"),
32
+ folder: folderSchema.nullable().optional(),
33
+ /** Optional cross-tenant write — SystemAdmin only. */
34
+ tenantIdOverride: z.string().min(1).optional(),
35
+ }),
36
+ access: collection.access ?? DEFAULT_COLLECTION_ACCESS,
37
+ handler: async (event, ctx) => {
38
+ const db = ctx.db;
39
+ const override = event.payload.tenantIdOverride;
40
+ const overrideDenied = crossTenantOverrideDenied(
41
+ event.user,
42
+ override,
43
+ "templateResolver.errors.tenantOverrideRequiresSystemAdmin",
44
+ );
45
+ if (overrideDenied) return writeFailure(overrideDenied);
46
+ // @cast-boundary engine-payload — override is a zod-validated string, the
47
+ // user's own tenantId is already TenantId-branded.
48
+ const tenantId = (override ?? event.user.tenantId) as TenantId;
49
+ const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
50
+ const owner = ownerFilter(isUserOwned, event.user);
51
+
52
+ const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
53
+ tenantId,
54
+ slug: event.payload.slug,
55
+ kind: collection.kind,
56
+ locale: event.payload.locale,
57
+ ...owner,
58
+ });
59
+
60
+ if (existing) {
61
+ const result = await executor.update(
62
+ {
63
+ id: existing.id,
64
+ version: existing.version,
65
+ // Only the editable columns — slug/kind/locale are the unique key,
66
+ // and rewriting scope/status/variableSchema here would reset what
67
+ // the template upserts put on the row.
68
+ changes: {
69
+ title: event.payload.title,
70
+ content: event.payload.content,
71
+ contentFormat: event.payload.contentFormat,
72
+ folder: event.payload.folder ?? null,
73
+ },
74
+ },
75
+ executorUser,
76
+ db,
77
+ );
78
+ if (!result.isSuccess) return result;
79
+ return {
80
+ isSuccess: true as const,
81
+ data: { slug: event.payload.slug, locale: event.payload.locale, isNew: false },
82
+ };
83
+ }
84
+
85
+ const result = await executor.create(
86
+ {
87
+ tenantId,
88
+ slug: event.payload.slug,
89
+ kind: collection.kind,
90
+ locale: event.payload.locale,
91
+ title: event.payload.title,
92
+ content: event.payload.content,
93
+ contentFormat: event.payload.contentFormat,
94
+ folder: event.payload.folder ?? null,
95
+ variableSchema: "{}",
96
+ linkedResources: "{}",
97
+ scope: tenantId === SYSTEM_TENANT_ID ? ("system" as const) : ("tenant" as const),
98
+ parentTemplateId: null,
99
+ status: "active" as const,
100
+ ...owner,
101
+ },
102
+ executorUser,
103
+ db,
104
+ );
105
+ if (!result.isSuccess) return result;
106
+ return {
107
+ isSuccess: true as const,
108
+ data: { slug: event.payload.slug, locale: event.payload.locale, isNew: true },
109
+ };
110
+ },
111
+ });
112
+ }
@@ -0,0 +1,24 @@
1
+ import type { AccessRule, SessionUser } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ // Applies when an app mounts a collection without saying who may reach it.
4
+ // Deliberately narrow: a collection whose access nobody decided should be
5
+ // invisible to normal users rather than open by default.
6
+ export const DEFAULT_COLLECTION_ACCESS: AccessRule = { roles: ["TenantAdmin", "SystemAdmin"] };
7
+
8
+ // `ownership: "user"` means every user keeps their own entries (signatures);
9
+ // "tenant" means one shared set (reply snippets an admin curates).
10
+ //
11
+ // The user-owned column doesn't exist yet — see #1770. Until it does, a
12
+ // user-owned collection would silently behave like a tenant-wide one, so the
13
+ // feature factory rejects `ownership: "user"` at registration time and this
14
+ // helper never sees it.
15
+ export function ownerFilter(
16
+ isUserOwned: boolean,
17
+ _user: SessionUser,
18
+ ): Readonly<Record<string, unknown>> {
19
+ if (!isUserOwned) return {};
20
+ throw new Error(
21
+ "template-resolver: ownership 'user' needs the ownerId column (#1770) — " +
22
+ "createTemplateResolverFeature should have rejected this collection at mount.",
23
+ );
24
+ }
@@ -7,20 +7,33 @@ import {
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
9
9
  import { z } from "zod";
10
- import { TEXT_BLOCK_KIND } from "../constants";
10
+ import { TEMPLATE_KINDS, TEXT_BLOCK_KIND } from "../constants";
11
11
  import { type TemplateResourceRow, templateResourcesTable } from "../table";
12
12
  import { contentFormatSchema, executor, folderSchema, localeSchema, slugSchema } from "./shared";
13
13
 
14
- // Upsert of a single text-block, one operation per (tenantId, slug, locale).
15
- // This is the authoring path for kind=text-block: unlike upsertTenant it takes
16
- // a title and a folder, has no draft stage (text blocks are live when saved)
17
- // and it may write onto SYSTEM_TENANT_ID, which is where legal and marketing
18
- // copy lives. Cross-tenant writes are SystemAdmin-only.
14
+ // Upsert of a single resource, one operation per (tenantId, slug, kind,
15
+ // locale). This is the content-tree authoring path: unlike upsertTenant it
16
+ // takes a title and a folder, has no draft stage (saving publishes) and it may
17
+ // write onto SYSTEM_TENANT_ID, which is where legal and marketing copy lives.
18
+ // Cross-tenant writes are SystemAdmin-only.
19
+ //
20
+ // `kind` defaults to text-block. A collection editor passes its own kind; on
21
+ // update only title/content/contentFormat/folder move, so a mail template's
22
+ // variableSchema and status survive being edited through the tree.
23
+ //
24
+ // On CREATE this path stays what it is for text-blocks: status "active",
25
+ // empty variableSchema, no parent. A mail template created through a
26
+ // collection tree is therefore live immediately and skips the
27
+ // upsertTenant → publish draft stage. That is the deliberate split between
28
+ // the two authoring routes — use upsertSystem/upsertTenant when a template
29
+ // needs a draft or a variable schema.
19
30
  export const setWrite = defineWriteHandler({
20
31
  name: "set",
21
32
  schema: z.object({
22
33
  slug: slugSchema,
23
34
  locale: localeSchema,
35
+ /** Which kind to author. Defaults to text-block. */
36
+ kind: z.enum(TEMPLATE_KINDS).optional(),
24
37
  title: z.string().min(1).max(200),
25
38
  content: z.string().max(200_000).nullable(),
26
39
  contentFormat: contentFormatSchema.default("markdown"),
@@ -51,16 +64,17 @@ export const setWrite = defineWriteHandler({
51
64
  // and reports a version conflict although the projection row exists.
52
65
  const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
53
66
 
67
+ const kind = event.payload.kind ?? TEXT_BLOCK_KIND;
54
68
  const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
55
69
  tenantId,
56
70
  slug: event.payload.slug,
57
- kind: TEXT_BLOCK_KIND,
71
+ kind,
58
72
  locale: event.payload.locale,
59
73
  });
60
74
 
61
75
  const fields = {
62
76
  slug: event.payload.slug,
63
- kind: TEXT_BLOCK_KIND,
77
+ kind,
64
78
  locale: event.payload.locale,
65
79
  title: event.payload.title,
66
80
  content: event.payload.content,
@@ -13,10 +13,23 @@ export const TemplateResolverHandlers = {
13
13
  export const TemplateResolverQueries = {
14
14
  findById: "template-resolver:query:find-by-id",
15
15
  list: "template-resolver:query:list",
16
+ // Public pair — anonymous-reachable, kind pinned to text-block.
16
17
  bySlug: "template-resolver:query:by-slug",
17
18
  byTenant: "template-resolver:query:by-tenant",
18
19
  } as const;
19
20
 
21
+ // Every r.contentCollection() gets its own handler trio, so each carries that
22
+ // collection's access rule and the dispatcher enforces it. The names are
23
+ // derived from the collection id on both sides — server-side in
24
+ // createTemplateResolverFeature, client-side here.
25
+ export function collectionQueryName(collectionId: string, op: "list" | "item"): string {
26
+ return `${TEMPLATE_RESOLVER_FEATURE}:query:${collectionId}-${op}`;
27
+ }
28
+
29
+ export function collectionHandlerName(collectionId: string): string {
30
+ return `${TEMPLATE_RESOLVER_FEATURE}:write:${collectionId}-set`;
31
+ }
32
+
20
33
  export const TemplateResolverErrors = {
21
34
  notFound: "template_resource_not_found",
22
35
  invalidSlug: "invalid_slug",
@@ -70,3 +70,125 @@ describe("textBlocksClient — Provider unwrappt den Content-Container", () => {
70
70
  expect(labels).toEqual(["Imprint", "page"]);
71
71
  });
72
72
  });
73
+
74
+ describe("textBlocksClient — content collections", () => {
75
+ const origFetch = globalThis.fetch;
76
+ afterEach(() => {
77
+ globalThis.fetch = origFetch;
78
+ });
79
+
80
+ const collection = (id: string, kind: string) => ({
81
+ id,
82
+ kind,
83
+ nav: { label: `mail:nav.${id}` },
84
+ navQn: `mail:nav:${id}`,
85
+ });
86
+
87
+ test("one provider + SSE entities per declared collection, keyed on the schema's nav QN", () => {
88
+ const derived = textBlocksClient().navProvidersFromCollections?.([
89
+ collection("templates", "mail-html"),
90
+ collection("prompts", "ai-prompt"),
91
+ ]);
92
+
93
+ expect(Object.keys(derived?.providers ?? {}).sort()).toEqual([
94
+ "mail:nav:prompts",
95
+ "mail:nav:templates",
96
+ ]);
97
+ expect(derived?.entities?.["mail:nav:templates"]).toEqual(["template-resource"]);
98
+ });
99
+
100
+ test("no collections declared → nothing derived", () => {
101
+ const derived = textBlocksClient().navProvidersFromCollections?.([]);
102
+ expect(Object.keys(derived?.providers ?? {})).toEqual([]);
103
+ });
104
+
105
+ test("a collection's provider calls that collection's handler and stamps its id on the edit target", async () => {
106
+ let sentType = "";
107
+ globalThis.fetch = mock(async (_url: string, init?: RequestInit) => {
108
+ const body = JSON.parse(String(init?.body)) as {
109
+ type: string;
110
+ payload: Record<string, unknown>;
111
+ };
112
+ sentType = body.type;
113
+ return new Response(
114
+ JSON.stringify({
115
+ data: {
116
+ blocks: [
117
+ {
118
+ slug: "reminder",
119
+ locale: "de",
120
+ title: "Reminder",
121
+ content: "x",
122
+ folder: null,
123
+ updatedAt: "",
124
+ },
125
+ ],
126
+ },
127
+ }),
128
+ { status: 200, headers: { "content-type": "application/json" } },
129
+ );
130
+ }) as unknown as typeof fetch;
131
+
132
+ const derived = textBlocksClient().navProvidersFromCollections?.([
133
+ collection("templates", "mail-html"),
134
+ ]);
135
+ const provider = derived?.providers["mail:nav:templates"];
136
+ if (provider === undefined) throw new Error("provider missing");
137
+
138
+ let emitted: readonly TreeNode[] | undefined;
139
+ provider()((nodes) => {
140
+ emitted = nodes;
141
+ });
142
+ await new Promise((r) => setTimeout(r, 0));
143
+
144
+ // The collection's own handler carries its access rule — a shared one
145
+ // taking a kind would have to admit every collection's roles at once.
146
+ expect(sentType).toBe("template-resolver:query:templates-list");
147
+ // Without the collection id on the target, the editor would read and write
148
+ // through the public text-block pair instead of this collection.
149
+ expect(emitted?.[0]?.target?.args).toMatchObject({
150
+ slug: "reminder",
151
+ collectionId: "templates",
152
+ });
153
+ });
154
+
155
+ test("a text-block collection also uses its own handler — declared means declared", async () => {
156
+ let sentType = "";
157
+ globalThis.fetch = mock(async (_url: string, init?: RequestInit) => {
158
+ sentType = (JSON.parse(String(init?.body)) as { type: string }).type;
159
+ return new Response(JSON.stringify({ data: { blocks: [] } }), {
160
+ status: 200,
161
+ headers: { "content-type": "application/json" },
162
+ });
163
+ }) as unknown as typeof fetch;
164
+
165
+ const derived = textBlocksClient().navProvidersFromCollections?.([
166
+ collection("pages", "text-block"),
167
+ ]);
168
+ derived?.providers["mail:nav:pages"]?.()(() => {});
169
+ await new Promise((r) => setTimeout(r, 0));
170
+
171
+ // Same kind as the public tree, but declared as a collection → it gets the
172
+ // access the app declared, not anonymous reach.
173
+ expect(sentType).toBe("template-resolver:query:pages-list");
174
+ });
175
+
176
+ test("the hand-wired navId path stays on the anonymous-capable query", async () => {
177
+ let sentType = "";
178
+ globalThis.fetch = mock(async (_url: string, init?: RequestInit) => {
179
+ sentType = (JSON.parse(String(init?.body)) as { type: string }).type;
180
+ return new Response(JSON.stringify({ data: { blocks: [] } }), {
181
+ status: 200,
182
+ headers: { "content-type": "application/json" },
183
+ });
184
+ }) as unknown as typeof fetch;
185
+
186
+ // publicstatus renders its content sidebar on public pages without a
187
+ // session — that path must not move onto a collection handler.
188
+ const navId = "publicstatus:nav:content";
189
+ textBlocksClient({ navId }).navProviders?.[navId]?.()(() => {});
190
+ await new Promise((r) => setTimeout(r, 0));
191
+
192
+ expect(sentType).toBe("template-resolver:query:by-tenant");
193
+ });
194
+ });
@@ -25,7 +25,12 @@ import {
25
25
  } from "@cosmicdrift/kumiko-renderer";
26
26
  import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
27
27
  import { type FormEvent, type ReactNode, useEffect, useState } from "react";
28
- import { TemplateResolverHandlers, TemplateResolverQueries } from "../qualified-names";
28
+ import {
29
+ collectionHandlerName,
30
+ collectionQueryName,
31
+ TemplateResolverHandlers,
32
+ TemplateResolverQueries,
33
+ } from "../qualified-names";
29
34
  import { defaultTranslations } from "./i18n";
30
35
 
31
36
  // Exported for the unit test — groupBlocksByFolder is a pure function.
@@ -62,20 +67,26 @@ function newFolderNode(): FolderNode {
62
67
  return { leaves: [], subFolders: new Map() };
63
68
  }
64
69
 
65
- function attachBlock(root: FolderNode, block: BlockSummary, tenantIdOverride?: string): void {
70
+ function attachBlock(
71
+ root: FolderNode,
72
+ block: BlockSummary,
73
+ tenantIdOverride?: string,
74
+ collectionId?: string,
75
+ ): void {
66
76
  const leaf: TreeNode = {
67
77
  label: block.title || block.slug,
68
78
  icon: "file",
69
79
  target: {
70
80
  featureId: "template-resolver",
71
81
  action: "edit",
72
- // tenantIdOverride travels with the node → the editor's by-slug read +
73
- // set write target the same tenant the tree was loaded from (SystemAdmin
74
- // editing SYSTEM-tenant content). Omitted → session tenant.
82
+ // tenantIdOverride and collectionId travel with the node → the editor
83
+ // reads and writes through the same tenant and the same collection the
84
+ // tree was loaded from. Omitted → session tenant, public text-blocks.
75
85
  args: {
76
86
  slug: block.slug,
77
87
  locale: block.locale,
78
88
  ...(tenantIdOverride !== undefined && { tenantIdOverride }),
89
+ ...(collectionId !== undefined && { collectionId }),
79
90
  },
80
91
  },
81
92
  state: block.content ? "filled" : "stub",
@@ -115,10 +126,11 @@ function renderFolderNode(node: FolderNode): TreeNode[] {
115
126
  export function groupBlocksByFolder(
116
127
  blocks: readonly BlockSummary[],
117
128
  tenantIdOverride?: string,
129
+ collectionId?: string,
118
130
  ): readonly TreeNode[] {
119
131
  const root = newFolderNode();
120
132
  for (const block of blocks) {
121
- attachBlock(root, block, tenantIdOverride);
133
+ attachBlock(root, block, tenantIdOverride, collectionId);
122
134
  }
123
135
  const rendered = renderFolderNode(root);
124
136
  if (rendered.length === 0) return [];
@@ -135,7 +147,14 @@ export function groupBlocksByFolder(
135
147
  // tenantIdOverride (SystemAdmin-only) lets an app point the content tree at a
136
148
  // tenant other than the session's — publicstatus seeds marketing/legal blocks
137
149
  // on SYSTEM_TENANT_ID, so its SystemAdmin must read that tenant, not their own.
138
- function makeTreeProvider(tenantIdOverride?: string): TreeChildrenSubscribe {
150
+ // Without a collection id: the anonymous-capable by-tenant query, so a public
151
+ // page keeps its content sidebar without a session. With one: that
152
+ // collection's own list handler, which carries the collection's access rule.
153
+ function makeTreeProvider(tenantIdOverride?: string, collectionId?: string): TreeChildrenSubscribe {
154
+ const queryType =
155
+ collectionId === undefined
156
+ ? TemplateResolverQueries.byTenant
157
+ : collectionQueryName(collectionId, "list");
139
158
  return () => (emit, emitError) => {
140
159
  // CSRF header is mandatory on authenticated requests (auth-middleware
141
160
  // double-submit pattern). Anonymous/pre-login has no token → header
@@ -147,7 +166,7 @@ function makeTreeProvider(tenantIdOverride?: string): TreeChildrenSubscribe {
147
166
  method: "POST",
148
167
  headers,
149
168
  body: JSON.stringify({
150
- type: TemplateResolverQueries.byTenant,
169
+ type: queryType,
151
170
  payload: tenantIdOverride !== undefined ? { tenantIdOverride } : {},
152
171
  }),
153
172
  })
@@ -162,7 +181,7 @@ function makeTreeProvider(tenantIdOverride?: string): TreeChildrenSubscribe {
162
181
  .then((data: ByTenantResponse) => {
163
182
  // The app-side r.nav node IS the "Content" container → the provider's
164
183
  // children are the folders/leaves below it, not the wrapper.
165
- const content = groupBlocksByFolder(data.data.blocks, tenantIdOverride)[0];
184
+ const content = groupBlocksByFolder(data.data.blocks, tenantIdOverride, collectionId)[0];
166
185
  emit(content !== undefined && Array.isArray(content.children) ? content.children : []);
167
186
  })
168
187
  .catch((e) => {
@@ -208,11 +227,14 @@ function TextBlockEditor({
208
227
  // same way event payloads do. Optional chaining absorbs missing fields so a
209
228
  // hand-edited URL cannot crash the editor.
210
229
  const args = target.args as
211
- | { slug?: string; locale?: string; tenantIdOverride?: string }
230
+ | { slug?: string; locale?: string; collectionId?: string; tenantIdOverride?: string }
212
231
  | undefined;
213
232
  const slug = args?.slug ?? "";
214
233
  const locale = args?.locale ?? "";
215
234
  const tenantIdOverride = args?.tenantIdOverride;
235
+ // Which collection the node came from. Absent → the public text-block pair,
236
+ // which is what the hand-wired content tree uses.
237
+ const collectionId = args?.collectionId;
216
238
 
217
239
  const { Form, Field, Input, Button, Banner } = usePrimitives();
218
240
  const dispatcher = useDispatcher();
@@ -221,12 +243,20 @@ function TextBlockEditor({
221
243
  const canWrite =
222
244
  user?.roles.includes("TenantAdmin") === true || user?.roles.includes("SystemAdmin") === true;
223
245
 
246
+ // Same split as the tree provider: a node without a collection reads through
247
+ // the public by-slug, a collection node through that collection's handlers.
248
+ const readQuery =
249
+ collectionId === undefined
250
+ ? TemplateResolverQueries.bySlug
251
+ : collectionQueryName(collectionId, "item");
252
+ const writeHandler =
253
+ collectionId === undefined ? TemplateResolverHandlers.set : collectionHandlerName(collectionId);
224
254
  const {
225
255
  data: loaded,
226
256
  loading,
227
257
  error: loadError,
228
258
  } = useQuery<TextBlock | null>(
229
- TemplateResolverQueries.bySlug,
259
+ readQuery,
230
260
  { slug, locale, ...(tenantIdOverride !== undefined && { tenantIdOverride }) },
231
261
  { enabled: slug !== "" && locale !== "" },
232
262
  );
@@ -252,7 +282,7 @@ function TextBlockEditor({
252
282
  setSaveError(null);
253
283
  setSavedMsg(null);
254
284
  try {
255
- const result = await dispatcher.write<SetResponse>(TemplateResolverHandlers.set, {
285
+ const result = await dispatcher.write<SetResponse>(writeHandler, {
256
286
  slug,
257
287
  locale,
258
288
  title,
@@ -346,18 +376,33 @@ function TextBlockEditor({
346
376
  // `tenantId` (SystemAdmin-only): which tenant the tree and editor serve —
347
377
  // SYSTEM_TENANT_ID for apps that seed marketing/legal there. Omit for the
348
378
  // session tenant (default, no cross-tenant access).
379
+ //
380
+ // Beyond that single tree, this client also serves every content collection
381
+ // the app declares via r.contentCollection() — one provider per collection,
382
+ // filtered to its kind. Those need no navId here; their nav QN comes from the
383
+ // schema, so navId and kind can't drift apart.
349
384
  export function textBlocksClient(opts?: {
350
385
  readonly navId?: string;
351
386
  readonly tenantId?: string;
352
387
  }): ClientFeatureDefinition {
353
388
  const navId = opts?.navId;
389
+ const tenantId = opts?.tenantId;
354
390
  return {
355
391
  name: "template-resolver",
356
392
  ...(navId !== undefined && {
357
- navProviders: { [navId]: makeTreeProvider(opts?.tenantId) },
393
+ navProviders: { [navId]: makeTreeProvider(tenantId) },
358
394
  // SSE refresh: every template-resource event re-fires the provider.
359
395
  navEntities: { [navId]: ["template-resource"] },
360
396
  }),
397
+ // Every r.contentCollection() in the app gets its own tree, filtered to
398
+ // that collection's kind. The explicit navId above stays independent of
399
+ // this — an app can have both a hand-wired content tree and collections.
400
+ navProvidersFromCollections: (collections) => ({
401
+ providers: Object.fromEntries(
402
+ collections.map((c) => [c.navQn, makeTreeProvider(tenantId, c.id)]),
403
+ ),
404
+ entities: Object.fromEntries(collections.map((c) => [c.navQn, ["template-resource"]])),
405
+ }),
361
406
  resolvers: {
362
407
  "template-resolver:edit": TextBlockEditor,
363
408
  },