@cosmicdrift/kumiko-bundled-features 0.178.1 → 0.181.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.178.1",
3
+ "version": "0.181.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>",
@@ -45,6 +45,7 @@
45
45
  "./notes-history": "./src/notes-history/index.ts",
46
46
  "./notes-history/web": "./src/notes-history/web/index.ts",
47
47
  "./notes-history-user-data": "./src/notes-history-user-data/index.ts",
48
+ "./template-resolver-user-data": "./src/template-resolver-user-data/index.ts",
48
49
  "./folders-user-data": "./src/folders-user-data/index.ts",
49
50
  "./ledger": "./src/ledger/index.ts",
50
51
  "./ledger/web": "./src/ledger/web/index.ts",
@@ -120,12 +121,12 @@
120
121
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
121
122
  },
122
123
  "dependencies": {
123
- "@cosmicdrift/kumiko-dispatcher-live": "0.178.1",
124
- "@cosmicdrift/kumiko-framework": "0.178.1",
125
- "@cosmicdrift/kumiko-headless": "0.178.1",
126
- "@cosmicdrift/kumiko-renderer": "0.178.1",
127
- "@cosmicdrift/kumiko-renderer-web": "0.178.1",
128
- "@cosmicdrift/kumiko-types": "0.178.1",
124
+ "@cosmicdrift/kumiko-dispatcher-live": "0.181.0",
125
+ "@cosmicdrift/kumiko-framework": "0.181.0",
126
+ "@cosmicdrift/kumiko-headless": "0.181.0",
127
+ "@cosmicdrift/kumiko-renderer": "0.181.0",
128
+ "@cosmicdrift/kumiko-renderer-web": "0.181.0",
129
+ "@cosmicdrift/kumiko-types": "0.181.0",
129
130
  "@mollie/api-client": "^4.5.0",
130
131
  "imapflow": "^1.3.3",
131
132
  "mailparser": "^3.9.8",
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createDateField,
3
+ createEmbeddedListField,
3
4
  createEntity,
4
- createJsonbField,
5
5
  createNumberField,
6
6
  createSelectField,
7
7
  createTextField,
@@ -27,7 +27,7 @@ export const accountEntity = createEntity({
27
27
  });
28
28
 
29
29
  // transaction — a journal entry. The balanced posting lines live embedded as
30
- // `lines` (jsonb: { accountId, amount }[], Σ amount = 0), so an entry is atomic:
30
+ // `lines` (embedded list: { accountId, amount }[], Σ amount = 0), so an entry is atomic:
31
31
  // the Σ=0 invariant holds within a single command, no cross-row write. The
32
32
  // framework projects `read_ledger_transactions`; Phase 1 adds a flat
33
33
  // `read_ledger_postings` projection (one row per line) for per-account/period
@@ -50,7 +50,19 @@ export const transactionEntity = createEntity({
50
50
  // For a Storno entry this points at the reversed transaction's id.
51
51
  reference: createTextField({ maxLength: 120 }),
52
52
  status: createSelectField({ options: TRANSACTION_STATUS, required: true }),
53
- lines: createJsonbField(),
53
+ // Posting lines are born with the entry and never change on their own —
54
+ // a correction is a new (reversing) entry. The embedded list validates
55
+ // each line's shape; `money` pins amount to signed integer minor units
56
+ // (the entry's currency is a book-level concern, not per line). The
57
+ // cross-line invariants (Σ=0, ≥2 distinct accounts) stay in
58
+ // createTransactionPayloadSchema.
59
+ lines: createEmbeddedListField(
60
+ {
61
+ accountId: { type: "text", required: true },
62
+ amount: { type: "money", required: true },
63
+ },
64
+ { required: true },
65
+ ),
54
66
  },
55
67
  });
56
68
 
@@ -208,9 +208,42 @@ Speichern **veröffentlicht sofort** — Status `active`, leeres `variableSchema
208
208
  Wer eine Draft-Stufe oder ein Variablen-Schema braucht, nimmt
209
209
  `upsertSystem`/`upsertTenant` + `publish`.
210
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.
211
+ ### ownership: user vs. tenant
212
+
213
+ `ownership: "tenant"` (Default) heißt: ein Satz, den alle im Tenant teilen —
214
+ kuratierte Bausteine, AI-Prompts. `ownership: "user"` heißt: jeder Enduser
215
+ pflegt seine eigenen Einträge — Mail-Signaturen, persönliche Antwort-Bausteine.
216
+
217
+ User-owned Collections liegen in einer **eigenen Tabelle**
218
+ (`read_user_content_entries`, Entity `user-content-entry`) mit `ownerId NOT
219
+ NULL` und Unique-Index `(tenantId, ownerId, slug, kind, locale)`. Zwei Agents
220
+ können damit beide eine Signatur `standard` haben.
221
+
222
+ Warum nicht eine Tabelle mit nullbarer `ownerId`: das `content`-Feld dort
223
+ trägt `userOwned` (Name, Telefonnummer, Anschrift → crypto-shredding). Diese
224
+ Annotation wird **pro Entity** aufgelöst, nicht pro Zeile, und
225
+ `resolveSubjectForField` wirft, wenn die Owner-Spalte leer ist — jede
226
+ tenant-weite Mail-Template-Zeile würde beim Schreiben scheitern.
227
+
228
+ Beim Mounten einer user-owned Collection sind zwei Dinge Pflicht:
229
+
230
+ 1. **Migration.** Die Entity kommt nur in den Schema-Diff, wenn mindestens eine
231
+ user-owned Collection deklariert ist. Danach in der App
232
+ `bunx kumiko-schema generate add-user-content` + `apply` — ohne die Tabelle
233
+ antwortet der Handler in Prod mit 500.
234
+ 2. **`template-resolver-user-data` mitmounten.** Die Entity trägt Subject-Daten;
235
+ ohne den EXT_USER_DATA-Hook verweigert der Boot-Guard den Start. Das Feature
236
+ liefert den Art.-20-Export; die Löschung (Art. 17) läuft über
237
+ crypto-shredding, nicht über einen physischen DELETE — der wäre bei einer
238
+ event-sourced Entity nicht replay-fest.
239
+
240
+ Der Locale-Fallback von `resolveTemplate` gilt für user-owned Einträge **nicht**:
241
+ sie werden gelistet und editiert, nie als Tenant/System-Override aufgelöst. Wer
242
+ im Client eigene *und* geteilte Bausteine zeigen will, mountet zwei Collections
243
+ nebeneinander und führt sie in der UI zusammen.
244
+
245
+ Unter `tenantIdOverride` (SystemAdmin) bleibt die Zeile die **eigene** des
246
+ Aufrufers im fremden Tenant — ein Admin editiert nie die Signatur eines anderen.
214
247
 
215
248
  ## Out-of-Scope
216
249
 
@@ -262,20 +262,28 @@ describe("public handlers stay public and stay text-block", () => {
262
262
  });
263
263
  });
264
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/);
265
+ describe("mount-time entity registration", () => {
266
+ test("a tenant-only mount does not register the user-content entity", () => {
267
+ // It carries a `userOwned` field, which makes it subject data and puts the
268
+ // EXT_USER_DATA boot guard in play. Apps with no user-owned collection
269
+ // must not inherit that obligation.
270
+ const entities = Object.keys(feature.entities ?? {});
271
+ expect(entities).toContain("template-resource");
272
+ expect(entities).not.toContain("user-content-entry");
273
+ });
274
+
275
+ test("declaring a user-owned collection registers it", () => {
276
+ const withSignatures = createTemplateResolverFeature({
277
+ collections: [
278
+ {
279
+ id: "signatures",
280
+ kind: "mail-html",
281
+ ownership: "user",
282
+ access: { roles: ["Agent"] },
283
+ nav: { label: "mail:nav.signatures" },
284
+ },
285
+ ],
286
+ });
287
+ expect(Object.keys(withSignatures.entities ?? {})).toContain("user-content-entry");
280
288
  });
281
289
  });
@@ -0,0 +1,336 @@
1
+ // Ownership is the second axis next to access: `access` decides WHO may reach
2
+ // a collection, `ownership` decides WHOSE rows they see once they are in.
3
+ //
4
+ // The four levels this pins, because each one has its own failure mode:
5
+ // user — two agents keep separate signatures under the same slug
6
+ // tenant — one curated set everyone in the tenant shares
7
+ // system — a SystemAdmin crossing tenants still writes their OWN entry
8
+ // custom — the app's role vocabulary gates both, independent of ownership
9
+ //
10
+ // The regression that motivates the user level: with one shared table and a
11
+ // nullable owner column, a signature would either collide on the unique index
12
+ // or silently be served to every agent.
13
+
14
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
15
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
16
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
17
+ import { createAnonymousUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
18
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
19
+ import {
20
+ createTestUser,
21
+ setupTestStack,
22
+ type TestStack,
23
+ unsafeCreateEntityTable,
24
+ } from "@cosmicdrift/kumiko-framework/stack";
25
+ import { createTemplateResolverFeature } from "../feature";
26
+ import { collectionHandlerName, collectionQueryName } from "../qualified-names";
27
+ import { templateResourceEntity } from "../table";
28
+ import {
29
+ type UserContentEntryRow,
30
+ userContentEntriesTable,
31
+ userContentEntryEntity,
32
+ } from "../user-content-table";
33
+
34
+ let stack: TestStack;
35
+ let db: DbConnection;
36
+
37
+ // Same tenant, same role, different people — the pair the user level is about.
38
+ const agentA = createTestUser({ id: 11, roles: ["Agent"] });
39
+ const agentB = createTestUser({ id: 12, roles: ["Agent"] });
40
+ const tenantAdmin = createTestUser({ id: 13, roles: ["TenantAdmin"] });
41
+ const promptEngineer = createTestUser({ id: 14, roles: ["PromptEngineer"] });
42
+ const sysAdmin = createTestUser({ id: 15, roles: ["SystemAdmin", "Agent"] });
43
+ const otherTenantId = "00000000-0000-4000-8000-0000000000ff" as TenantId;
44
+ const foreignAgent = createTestUser({ id: 16, roles: ["Agent"], tenantId: otherTenantId });
45
+
46
+ const SIGNATURES_LIST = collectionQueryName("signatures", "list");
47
+ const SIGNATURES_ITEM = collectionQueryName("signatures", "item");
48
+ const SIGNATURES_SET = collectionHandlerName("signatures");
49
+ const SNIPPETS_LIST = collectionQueryName("reply-snippets", "list");
50
+ const SNIPPETS_SET = collectionHandlerName("reply-snippets");
51
+
52
+ const feature = createTemplateResolverFeature({
53
+ collections: [
54
+ {
55
+ id: "signatures",
56
+ kind: "mail-html",
57
+ ownership: "user",
58
+ access: { roles: ["Agent", "TenantAdmin"] },
59
+ nav: { label: "mail:nav.signatures" },
60
+ },
61
+ {
62
+ id: "reply-snippets",
63
+ kind: "mail-html",
64
+ access: { roles: ["Agent", "TenantAdmin"] },
65
+ nav: { label: "mail:nav.snippets" },
66
+ },
67
+ {
68
+ id: "ai-prompts",
69
+ kind: "ai-prompt",
70
+ ownership: "user",
71
+ access: { roles: ["PromptEngineer"] },
72
+ nav: { label: "mail:nav.prompts" },
73
+ },
74
+ ],
75
+ });
76
+
77
+ const signature = (title: string, content: string) => ({
78
+ slug: "standard",
79
+ locale: "de",
80
+ title,
81
+ content,
82
+ contentFormat: "html" as const,
83
+ });
84
+
85
+ beforeAll(async () => {
86
+ stack = await setupTestStack({ features: [feature] });
87
+ db = stack.db;
88
+ await unsafeCreateEntityTable(db, templateResourceEntity);
89
+ await unsafeCreateEntityTable(db, userContentEntryEntity);
90
+ await createEventsTable(db);
91
+ });
92
+
93
+ afterAll(async () => {
94
+ await stack.cleanup();
95
+ });
96
+
97
+ describe("ownership 'user' :: every user keeps their own", () => {
98
+ test("two agents hold the same slug side by side", async () => {
99
+ const a = await stack.http.writeOk<{ isNew: boolean }>(
100
+ SIGNATURES_SET,
101
+ signature("Signatur A", "<p>Viele Grüße, A</p>"),
102
+ agentA,
103
+ );
104
+ const b = await stack.http.writeOk<{ isNew: boolean }>(
105
+ SIGNATURES_SET,
106
+ signature("Signatur B", "<p>Beste Grüße, B</p>"),
107
+ agentB,
108
+ );
109
+
110
+ // Both are creates, not an update of the other's row: the unique index is
111
+ // (tenantId, ownerId, slug, kind, locale), so the second write does not
112
+ // collide with the first.
113
+ expect(a.isNew).toBe(true);
114
+ expect(b.isNew).toBe(true);
115
+
116
+ const rows = await selectMany<UserContentEntryRow>(db, userContentEntriesTable, {
117
+ tenantId: agentA.tenantId,
118
+ slug: "standard",
119
+ });
120
+ expect(rows.length).toBe(2);
121
+ expect(new Set(rows.map((r) => r.ownerId))).toEqual(new Set([agentA.id, agentB.id]));
122
+ });
123
+
124
+ test("each agent lists only their own", async () => {
125
+ const forA = await stack.http.queryOk<{ blocks: readonly { title: string }[] }>(
126
+ SIGNATURES_LIST,
127
+ {},
128
+ agentA,
129
+ );
130
+ const forB = await stack.http.queryOk<{ blocks: readonly { title: string }[] }>(
131
+ SIGNATURES_LIST,
132
+ {},
133
+ agentB,
134
+ );
135
+ expect(forA.blocks.map((b) => b.title)).toEqual(["Signatur A"]);
136
+ expect(forB.blocks.map((b) => b.title)).toEqual(["Signatur B"]);
137
+ });
138
+
139
+ test("the item handler reads the caller's row, not the first match", async () => {
140
+ const forA = await stack.http.queryOk<{ content: string }>(
141
+ SIGNATURES_ITEM,
142
+ { slug: "standard", locale: "de" },
143
+ agentA,
144
+ );
145
+ const forB = await stack.http.queryOk<{ content: string }>(
146
+ SIGNATURES_ITEM,
147
+ { slug: "standard", locale: "de" },
148
+ agentB,
149
+ );
150
+ expect(forA.content).toBe("<p>Viele Grüße, A</p>");
151
+ expect(forB.content).toBe("<p>Beste Grüße, B</p>");
152
+ });
153
+
154
+ test("an update stays inside the caller's row", async () => {
155
+ await stack.http.writeOk(SIGNATURES_SET, signature("Signatur A v2", "<p>MfG, A</p>"), agentA);
156
+
157
+ const forB = await stack.http.queryOk<{ title: string; content: string }>(
158
+ SIGNATURES_ITEM,
159
+ { slug: "standard", locale: "de" },
160
+ agentB,
161
+ );
162
+ expect(forB.title).toBe("Signatur B");
163
+ expect(forB.content).toBe("<p>Beste Grüße, B</p>");
164
+ });
165
+
166
+ test("a user with no entry gets an empty list, not someone else's", async () => {
167
+ const result = await stack.http.queryOk<{ blocks: readonly unknown[] }>(
168
+ SIGNATURES_LIST,
169
+ {},
170
+ tenantAdmin,
171
+ );
172
+ expect(result.blocks).toEqual([]);
173
+ });
174
+
175
+ test("and null from the item handler", async () => {
176
+ const result = await stack.http.queryOk<unknown>(
177
+ SIGNATURES_ITEM,
178
+ { slug: "standard", locale: "de" },
179
+ tenantAdmin,
180
+ );
181
+ expect(result).toBeNull();
182
+ });
183
+ });
184
+
185
+ describe("ownership 'tenant' :: one set everyone shares", () => {
186
+ test("what one agent writes, the other sees", async () => {
187
+ await stack.http.writeOk(
188
+ SNIPPETS_SET,
189
+ {
190
+ slug: "standard",
191
+ locale: "de",
192
+ title: "Team-Baustein",
193
+ content: "<p>Wir melden uns.</p>",
194
+ contentFormat: "html",
195
+ },
196
+ agentA,
197
+ );
198
+
199
+ const forB = await stack.http.queryOk<{ blocks: readonly { title: string }[] }>(
200
+ SNIPPETS_LIST,
201
+ {},
202
+ agentB,
203
+ );
204
+ expect(forB.blocks.map((b) => b.title)).toEqual(["Team-Baustein"]);
205
+ });
206
+
207
+ test("a second write to the same slug updates instead of creating", async () => {
208
+ const result = await stack.http.writeOk<{ isNew: boolean }>(
209
+ SNIPPETS_SET,
210
+ {
211
+ slug: "standard",
212
+ locale: "de",
213
+ title: "Team-Baustein v2",
214
+ content: "<p>Wir melden uns zeitnah.</p>",
215
+ contentFormat: "html",
216
+ },
217
+ agentB,
218
+ );
219
+ expect(result.isNew).toBe(false);
220
+ });
221
+
222
+ test("the two collections share a kind and stay separate stores", async () => {
223
+ // Same kind (mail-html), same slug, same tenant — one is per-user, one is
224
+ // shared. A single table with a nullable owner column would have to make
225
+ // these collide.
226
+ const snippets = await stack.http.queryOk<{ blocks: readonly { title: string }[] }>(
227
+ SNIPPETS_LIST,
228
+ {},
229
+ agentA,
230
+ );
231
+ const signatures = await stack.http.queryOk<{ blocks: readonly { title: string }[] }>(
232
+ SIGNATURES_LIST,
233
+ {},
234
+ agentA,
235
+ );
236
+ expect(snippets.blocks.map((b) => b.title)).toEqual(["Team-Baustein v2"]);
237
+ expect(signatures.blocks.map((b) => b.title)).toEqual(["Signatur A v2"]);
238
+ });
239
+ });
240
+
241
+ describe("system level :: cross-tenant override", () => {
242
+ test("a SystemAdmin writing into another tenant still writes their OWN entry", async () => {
243
+ await stack.http.writeOk(
244
+ SIGNATURES_SET,
245
+ { ...signature("Sysadmin-Signatur", "<p>Admin</p>"), tenantIdOverride: otherTenantId },
246
+ sysAdmin,
247
+ );
248
+
249
+ const rows = await selectMany<UserContentEntryRow>(db, userContentEntriesTable, {
250
+ tenantId: otherTenantId,
251
+ slug: "standard",
252
+ });
253
+ expect(rows.map((r) => r.ownerId)).toEqual([sysAdmin.id]);
254
+ });
255
+
256
+ test("the foreign tenant's own agent does not see it", async () => {
257
+ const result = await stack.http.queryOk<{ blocks: readonly unknown[] }>(
258
+ SIGNATURES_LIST,
259
+ {},
260
+ foreignAgent,
261
+ );
262
+ expect(result.blocks).toEqual([]);
263
+ });
264
+
265
+ test("and the home tenant is untouched by the cross-tenant write", async () => {
266
+ const result = await stack.http.queryOk<{ blocks: readonly { title: string }[] }>(
267
+ SIGNATURES_LIST,
268
+ {},
269
+ agentA,
270
+ );
271
+ expect(result.blocks.map((b) => b.title)).toEqual(["Signatur A v2"]);
272
+ });
273
+
274
+ test("a non-SystemAdmin may not override the tenant", async () => {
275
+ const error = await stack.http.writeErr(
276
+ SIGNATURES_SET,
277
+ { ...signature("Fremd", "<p>x</p>"), tenantIdOverride: otherTenantId },
278
+ agentA,
279
+ );
280
+ expect(error.code).toBe("access_denied");
281
+ });
282
+ });
283
+
284
+ describe("custom roles :: access gates ownership, not the other way round", () => {
285
+ test("the declared role reaches its user-owned collection", async () => {
286
+ const result = await stack.http.queryOk<{ blocks: readonly unknown[] }>(
287
+ collectionQueryName("ai-prompts", "list"),
288
+ {},
289
+ promptEngineer,
290
+ );
291
+ expect(result.blocks).toEqual([]);
292
+ });
293
+
294
+ test("an agent may not reach the prompt collection, user-owned or not", async () => {
295
+ const error = await stack.http.queryErr(collectionQueryName("ai-prompts", "list"), {}, agentA);
296
+ expect(error.code).toBe("access_denied");
297
+ });
298
+
299
+ test("the prompt role may not reach the signatures either", async () => {
300
+ const error = await stack.http.queryErr(SIGNATURES_LIST, {}, promptEngineer);
301
+ expect(error.code).toBe("access_denied");
302
+ });
303
+
304
+ test("anonymous reaches no user-owned collection", async () => {
305
+ const anon = createAnonymousUser(agentA.tenantId);
306
+ expect((await stack.http.queryErr(SIGNATURES_LIST, {}, anon)).code).toBe("access_denied");
307
+ });
308
+ });
309
+
310
+ describe("GDPR :: the owner column is what makes erasure possible", () => {
311
+ test("content is annotated userOwned against the ownerId column", () => {
312
+ // The whole reason for a second table: `userOwned` is resolved per entity,
313
+ // and resolveSubjectForField throws on an empty owner column — so a shared
314
+ // table with nullable ownerId would break every tenant-wide write.
315
+ const content = userContentEntryEntity.fields["content"];
316
+ expect(content).toBeDefined();
317
+ expect((content as { userOwned?: { ownerField: string } }).userOwned).toEqual({
318
+ ownerField: "ownerId",
319
+ });
320
+ });
321
+
322
+ test("the tenant-wide table keeps its business-data declaration", () => {
323
+ const content = templateResourceEntity.fields["content"];
324
+ expect((content as { allowPlaintext?: string }).allowPlaintext).toBe("is-business-data");
325
+ expect((content as { userOwned?: unknown }).userOwned).toBeUndefined();
326
+ });
327
+
328
+ test("every user-owned row names its owner", async () => {
329
+ const rows = await selectMany<UserContentEntryRow>(db, userContentEntriesTable, {});
330
+ expect(rows.length).toBeGreaterThan(0);
331
+ for (const row of rows) {
332
+ expect(typeof row.ownerId).toBe("string");
333
+ expect(row.ownerId.length).toBeGreaterThan(0);
334
+ }
335
+ });
336
+ });
@@ -14,6 +14,7 @@ import { archiveWrite, publishWrite } from "./handlers/toggle-status.write";
14
14
  import { upsertSystemWrite } from "./handlers/upsert-system.write";
15
15
  import { upsertTenantWrite } from "./handlers/upsert-tenant.write";
16
16
  import { templateResourceEntity } from "./table";
17
+ import { userContentEntryEntity } from "./user-content-table";
17
18
 
18
19
  // template-resolver — structured template storage with tenant-override
19
20
  // hierarchy, locale fallback and resource linking via file-foundation.
@@ -38,19 +39,10 @@ export type TemplateResolverOptions = {
38
39
 
39
40
  export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}) {
40
41
  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
- }
42
+ const hasUserOwned = collections.some((c) => c.ownership === "user");
51
43
  return defineFeature("template-resolver", (r) => {
52
44
  r.describe(
53
- "The one content store: notification and mail templates, PDF document templates, AI prompts and plain editable text blocks all live here as one entity, distinguished by `kind` (`notification`, `mail-html`, `document-pdf`, `ai-prompt`, `text-block`, `image-snapshot`). Resolution uses a 4-level fallback: tenant+locale \u2192 system+locale \u2192 tenant+fallback-locale \u2192 system+fallback-locale, so tenants can override system defaults without touching application code. Call `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })` at render time; manage templates via the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers. Apps that want an editable collection in their navigation declare it at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })` \u2014 `access` belongs to the mount because a bundled feature does not know the host's role vocabulary. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers carrying that collection's access rule, so the dispatcher enforces the separation. Replaces the former `text-content` feature, whose blocks now live here as kind `text-block`.",
45
+ "The one content store: notification and mail templates, PDF document templates, AI prompts and plain editable text blocks all live here as one entity, distinguished by `kind` (`notification`, `mail-html`, `document-pdf`, `ai-prompt`, `text-block`, `image-snapshot`). Resolution uses a 4-level fallback: tenant+locale \u2192 system+locale \u2192 tenant+fallback-locale \u2192 system+fallback-locale, so tenants can override system defaults without touching application code. Call `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })` at render time; manage templates via the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers. Apps that want an editable collection in their navigation declare it at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })` \u2014 `access` belongs to the mount because a bundled feature does not know the host's role vocabulary. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers carrying that collection's access rule, so the dispatcher enforces the separation. A collection is either tenant-wide (default) or `ownership: \"user\"`, where every user keeps their own entries (mail signatures) in the separate `user-content-entry` entity — that one carries `userOwned` content, so mounting it also requires the `template-resolver-user-data` feature and an app-side migration. Replaces the former `text-content` feature, whose blocks now live here as kind `text-block`.",
54
46
  );
55
47
  r.uiHints({
56
48
  displayLabel: "Template Resolver",
@@ -58,6 +50,11 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
58
50
  recommended: false,
59
51
  });
60
52
  r.entity("template-resource", templateResourceEntity);
53
+ // Only when the app actually mounts a user-owned collection: the entity's
54
+ // `content` is `userOwned`, which makes it subject data — the boot guard
55
+ // then requires an EXT_USER_DATA hook (mount `template-resolver-user-data`).
56
+ // Registering it unconditionally would impose that on every app.
57
+ if (hasUserOwned) r.entity("user-content-entry", userContentEntryEntity);
61
58
 
62
59
  const handlers = {
63
60
  upsertSystem: r.writeHandler(upsertSystemWrite),
@@ -5,13 +5,17 @@ import {
5
5
  defineQueryHandler,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { z } from "zod";
8
- import { type TemplateResourceRow, templateResourcesTable } from "../table";
9
- import { DEFAULT_COLLECTION_ACCESS, ownerFilter } from "./collection-shared";
8
+ import {
9
+ type CollectionEntryRow,
10
+ collectionStore,
11
+ DEFAULT_COLLECTION_ACCESS,
12
+ toCollectionEntry,
13
+ } from "./collection-shared";
10
14
 
11
15
  // Single entry of one collection, for the editor behind a tree node. Same
12
16
  // per-collection construction as collection-list — see there for why.
13
17
  export function makeCollectionItemQuery(collection: ContentCollectionDefinition) {
14
- const isUserOwned = collection.ownership === "user";
18
+ const store = collectionStore(collection);
15
19
  return defineQueryHandler({
16
20
  name: `${collection.id}-item`,
17
21
  schema: z.object({
@@ -30,23 +34,16 @@ export function makeCollectionItemQuery(collection: ContentCollectionDefinition)
30
34
  );
31
35
  if (overrideDenied) throw overrideDenied;
32
36
  const tenantId = override ?? query.user.tenantId;
33
- const row = await fetchOne<TemplateResourceRow>(ctx.db, templateResourcesTable, {
37
+ const row = await fetchOne<CollectionEntryRow>(ctx.db, store.table, {
34
38
  tenantId,
35
39
  slug: query.payload.slug,
36
40
  kind: collection.kind,
37
41
  locale: query.payload.locale,
38
- ...ownerFilter(isUserOwned, query.user),
42
+ ...store.scopeOf(query.user),
39
43
  });
40
44
 
41
45
  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
- };
46
+ return toCollectionEntry(row);
50
47
  },
51
48
  });
52
49
  }
@@ -6,8 +6,12 @@ import {
6
6
  defineQueryHandler,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { z } from "zod";
9
- import { type TemplateResourceRow, templateResourcesTable } from "../table";
10
- import { DEFAULT_COLLECTION_ACCESS, ownerFilter } from "./collection-shared";
9
+ import {
10
+ type CollectionEntryRow,
11
+ collectionStore,
12
+ DEFAULT_COLLECTION_ACCESS,
13
+ toCollectionEntry,
14
+ } from "./collection-shared";
11
15
 
12
16
  // One list handler per declared collection: `kind`, `ownership` and `access`
13
17
  // come from the declaration, never from the payload.
@@ -18,7 +22,7 @@ import { DEFAULT_COLLECTION_ACCESS, ownerFilter } from "./collection-shared";
18
22
  // in its body — one bug there and a prompt engineer's collection is open to
19
23
  // everyone who may edit a signature.
20
24
  export function makeCollectionListQuery(collection: ContentCollectionDefinition) {
21
- const isUserOwned = collection.ownership === "user";
25
+ const store = collectionStore(collection);
22
26
  return defineQueryHandler({
23
27
  name: `${collection.id}-list`,
24
28
  schema: z.object({
@@ -35,23 +39,14 @@ export function makeCollectionListQuery(collection: ContentCollectionDefinition)
35
39
  );
36
40
  if (overrideDenied) throw overrideDenied;
37
41
  const tenantId = override ?? query.user.tenantId;
38
- const rows = castTenantRows<TemplateResourceRow>(
39
- await selectMany(ctx.db, templateResourcesTable, {
42
+ const rows = castTenantRows<CollectionEntryRow>(
43
+ await selectMany(ctx.db, store.table, {
40
44
  tenantId,
41
45
  kind: collection.kind,
42
- ...ownerFilter(isUserOwned, query.user),
46
+ ...store.scopeOf(query.user),
43
47
  }),
44
48
  );
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
- };
49
+ return { blocks: rows.map(toCollectionEntry) };
55
50
  },
56
51
  });
57
52
  }
@@ -3,14 +3,16 @@ import {
3
3
  type ContentCollectionDefinition,
4
4
  crossTenantOverrideDenied,
5
5
  defineWriteHandler,
6
- SYSTEM_TENANT_ID,
7
6
  type TenantId,
8
7
  } from "@cosmicdrift/kumiko-framework/engine";
9
8
  import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
10
9
  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";
10
+ import {
11
+ type CollectionEntryRow,
12
+ collectionStore,
13
+ DEFAULT_COLLECTION_ACCESS,
14
+ } from "./collection-shared";
15
+ import { contentFormatSchema, folderSchema, localeSchema, slugSchema } from "./shared";
14
16
 
15
17
  // Upsert inside one collection. Mirrors set.write for text-blocks, but the
16
18
  // kind comes from the declaration and the access rule is the collection's own,
@@ -20,7 +22,7 @@ import { contentFormatSchema, executor, folderSchema, localeSchema, slugSchema }
20
22
  // Like set.write this publishes on save (status active, empty variableSchema)
21
23
  // — the draft stage lives on upsertTenant + publish.
22
24
  export function makeCollectionSetWrite(collection: ContentCollectionDefinition) {
23
- const isUserOwned = collection.ownership === "user";
25
+ const store = collectionStore(collection);
24
26
  return defineWriteHandler({
25
27
  name: `${collection.id}-set`,
26
28
  schema: z.object({
@@ -47,9 +49,12 @@ export function makeCollectionSetWrite(collection: ContentCollectionDefinition)
47
49
  // user's own tenantId is already TenantId-branded.
48
50
  const tenantId = (override ?? event.user.tenantId) as TenantId;
49
51
  const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
50
- const owner = ownerFilter(isUserOwned, event.user);
52
+ // Scoped to the acting user even under a tenant override: a SystemAdmin
53
+ // writing into another tenant still writes their own entry, never
54
+ // someone else's signature.
55
+ const owner = store.scopeOf(event.user);
51
56
 
52
- const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
57
+ const existing = await fetchOne<CollectionEntryRow>(db, store.table, {
53
58
  tenantId,
54
59
  slug: event.payload.slug,
55
60
  kind: collection.kind,
@@ -58,7 +63,7 @@ export function makeCollectionSetWrite(collection: ContentCollectionDefinition)
58
63
  });
59
64
 
60
65
  if (existing) {
61
- const result = await executor.update(
66
+ const result = await store.executor.update(
62
67
  {
63
68
  id: existing.id,
64
69
  version: existing.version,
@@ -82,7 +87,7 @@ export function makeCollectionSetWrite(collection: ContentCollectionDefinition)
82
87
  };
83
88
  }
84
89
 
85
- const result = await executor.create(
90
+ const result = await store.executor.create(
86
91
  {
87
92
  tenantId,
88
93
  slug: event.payload.slug,
@@ -92,11 +97,7 @@ export function makeCollectionSetWrite(collection: ContentCollectionDefinition)
92
97
  content: event.payload.content,
93
98
  contentFormat: event.payload.contentFormat,
94
99
  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
+ ...store.createDefaults(tenantId),
100
101
  ...owner,
101
102
  },
102
103
  executorUser,
@@ -1,24 +1,87 @@
1
- import type { AccessRule, SessionUser } from "@cosmicdrift/kumiko-framework/engine";
1
+ import type { EventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
3
+ import type {
4
+ AccessRule,
5
+ ContentCollectionDefinition,
6
+ SessionUser,
7
+ TenantId,
8
+ } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { templateResourceEntity, templateResourcesTable } from "../table";
11
+ import { userContentEntriesTable, userContentEntryEntity } from "../user-content-table";
2
12
 
3
13
  // Applies when an app mounts a collection without saying who may reach it.
4
14
  // Deliberately narrow: a collection whose access nobody decided should be
5
15
  // invisible to normal users rather than open by default.
6
16
  export const DEFAULT_COLLECTION_ACCESS: AccessRule = { roles: ["TenantAdmin", "SystemAdmin"] };
7
17
 
18
+ const templateExecutor = createEventStoreExecutor(templateResourcesTable, templateResourceEntity, {
19
+ entityName: "template-resource",
20
+ });
21
+
22
+ const userContentExecutor = createEventStoreExecutor(
23
+ userContentEntriesTable,
24
+ userContentEntryEntity,
25
+ { entityName: "user-content-entry" },
26
+ );
27
+
28
+ // The columns a collection reads. Both tables carry them; the tenant-wide one
29
+ // has more (scope, status, variableSchema) that a collection never touches.
30
+ export type CollectionEntryRow = {
31
+ readonly id: string | number;
32
+ readonly version: number;
33
+ readonly slug: string;
34
+ readonly locale: string;
35
+ readonly title: string | null;
36
+ readonly content: string | null;
37
+ readonly folder: string | null;
38
+ readonly updatedAt: Date;
39
+ };
40
+
41
+ export type CollectionStore = {
42
+ readonly table: typeof templateResourcesTable | typeof userContentEntriesTable;
43
+ readonly executor: EventStoreExecutor;
44
+ /** Extra WHERE columns scoping a read to its owner. Empty for tenant-wide. */
45
+ scopeOf(user: SessionUser): Readonly<Record<string, unknown>>;
46
+ /** Columns a create needs beyond the editable ones the payload carries. */
47
+ createDefaults(tenantId: TenantId): Readonly<Record<string, unknown>>;
48
+ };
49
+
8
50
  // `ownership: "user"` means every user keeps their own entries (signatures);
9
51
  // "tenant" means one shared set (reply snippets an admin curates).
10
52
  //
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
- );
53
+ // The two live in different tables — see user-content-table.ts for why a
54
+ // nullable owner column on one table cannot work with `userOwned`.
55
+ export function collectionStore(collection: ContentCollectionDefinition): CollectionStore {
56
+ if (collection.ownership === "user") {
57
+ return {
58
+ table: userContentEntriesTable,
59
+ executor: userContentExecutor,
60
+ scopeOf: (user) => ({ ownerId: user.id }),
61
+ createDefaults: () => ({}),
62
+ };
63
+ }
64
+ return {
65
+ table: templateResourcesTable,
66
+ executor: templateExecutor,
67
+ scopeOf: () => ({}),
68
+ createDefaults: (tenantId) => ({
69
+ variableSchema: "{}",
70
+ linkedResources: "{}",
71
+ scope: tenantId === SYSTEM_TENANT_ID ? "system" : "tenant",
72
+ parentTemplateId: null,
73
+ status: "active",
74
+ }),
75
+ };
76
+ }
77
+
78
+ export function toCollectionEntry(row: CollectionEntryRow) {
79
+ return {
80
+ slug: row.slug,
81
+ locale: row.locale,
82
+ title: row.title,
83
+ content: row.content,
84
+ folder: row.folder,
85
+ updatedAt: row.updatedAt,
86
+ };
24
87
  }
@@ -33,3 +33,8 @@ export {
33
33
  TemplateResolverQueries,
34
34
  } from "./qualified-names";
35
35
  export { type TemplateResourceRow, templateResourceEntity, templateResourcesTable } from "./table";
36
+ export {
37
+ type UserContentEntryRow,
38
+ userContentEntriesTable,
39
+ userContentEntryEntity,
40
+ } from "./user-content-table";
@@ -0,0 +1,66 @@
1
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ createEntity,
4
+ createLongTextField,
5
+ createSelectField,
6
+ createTextField,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { CONTENT_FORMATS, TEMPLATE_KINDS } from "./constants";
9
+
10
+ // UserContentEntry — the per-user half of the content store: mail signatures
11
+ // and personal reply snippets, one row per (tenantId, ownerId, slug, kind,
12
+ // locale).
13
+ //
14
+ // Why a separate table instead of a nullable ownerId on read_template_resources:
15
+ // `content` here carries the owner's name, phone and address, so it needs
16
+ // `userOwned` for crypto-shredding. That annotation is resolved per entity, not
17
+ // per row — `resolveSubjectForField` throws when the owner column is empty
18
+ // (crypto/subject-resolver.ts). On a mixed table every tenant-wide mail template
19
+ // would fail its write. `ownerId` NOT NULL keeps the invariant in the schema and
20
+ // makes the unique index a plain one instead of two partial indexes.
21
+ //
22
+ // Not part of `resolveTemplate`'s 4-level fallback: these entries are listed and
23
+ // edited by their owner, never resolved as a tenant/system template override.
24
+ export const userContentEntryEntity = createEntity({
25
+ table: "read_user_content_entries",
26
+ fields: {
27
+ ownerId: createTextField({ required: true, subjectRef: true }),
28
+ slug: createTextField({ required: true }),
29
+ kind: createSelectField({ required: true, options: [...TEMPLATE_KINDS] }),
30
+ locale: createTextField({ required: true }),
31
+ title: createTextField({}),
32
+ folder: createTextField({}),
33
+ content: createLongTextField({ userOwned: { ownerField: "ownerId" } }),
34
+ contentFormat: createSelectField({ required: true, options: [...CONTENT_FORMATS] }),
35
+ },
36
+ indexes: [
37
+ {
38
+ unique: true,
39
+ columns: ["tenantId", "ownerId", "slug", "kind", "locale"],
40
+ name: "read_user_content_entries_unique",
41
+ },
42
+ ],
43
+ });
44
+
45
+ export const userContentEntriesTable = buildEntityTable(
46
+ "user-content-entry",
47
+ userContentEntryEntity,
48
+ );
49
+
50
+ export type UserContentEntryRow = {
51
+ readonly id: string | number;
52
+ readonly version: number;
53
+ readonly tenantId: string;
54
+ readonly ownerId: string;
55
+ readonly slug: string;
56
+ readonly kind: string;
57
+ readonly locale: string;
58
+ readonly title: string | null;
59
+ readonly folder: string | null;
60
+ readonly content: string | null;
61
+ readonly contentFormat: string;
62
+ readonly createdAt: Date;
63
+ readonly updatedAt: Date;
64
+ readonly createdBy: string;
65
+ readonly updatedBy: string;
66
+ };
@@ -0,0 +1,40 @@
1
+ // EXT_USER_DATA hooks for the template-resolver's `user-content-entry` entity
2
+ // (GDPR Art. 20 export / Art. 17 erasure). Lives apart from template-resolver
3
+ // so apps without the user-data-rights pipeline don't pull a hard dependency.
4
+ // Mirrors notes-history-user-data: export-only, erasure via crypto-shredding.
5
+
6
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
7
+ import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { userContentEntriesTable } from "../template-resolver";
9
+
10
+ // Genuinely per-user content — the export filters by ownerId alone. Tenant
11
+ // scoping comes from the tenant-scoped ctx.db; a user who belongs to two
12
+ // tenants gets each tenant's entries from that tenant's forget run.
13
+ export const userContentExportHook: UserDataExportHook = async (ctx) => {
14
+ const rows = await selectMany<Record<string, unknown>>(ctx.db, userContentEntriesTable, {
15
+ ownerId: ctx.userId,
16
+ });
17
+ if (rows.length === 0) return null;
18
+ return {
19
+ entity: "user-content-entry",
20
+ rows: rows.map((r) => ({
21
+ slug: r["slug"],
22
+ kind: r["kind"],
23
+ locale: r["locale"],
24
+ title: r["title"],
25
+ content: r["content"],
26
+ folder: r["folder"],
27
+ updatedAt: String(r["updatedAt"] ?? ""),
28
+ })),
29
+ };
30
+ };
31
+
32
+ // Deliberate no-op: `content` is annotated `userOwned`, so erasure runs via
33
+ // crypto-shredding — destroying the owner's subject key makes every event AND
34
+ // the projected row unreadable at once. A physical DELETE would not be
35
+ // rebuild-safe here: user-content-entry is event-sourced, so a replay would
36
+ // bring the row back (with unreadable content, but back).
37
+ // Precondition: this only erases anything if the app mounts a KMS adapter —
38
+ // without one, userOwned fields fall back to plaintext framework-wide and
39
+ // forget is a true no-op for `content`. Same property as notes-history.
40
+ export const userContentDeleteHook: UserDataDeleteHook = async () => {};
@@ -0,0 +1,25 @@
1
+ // Provides the EXT_USER_DATA export/delete hooks for the template-resolver's
2
+ // `user-content-entry` entity — mount it alongside template-resolver and
3
+ // user-data-rights when an app declares a collection with `ownership: "user"`.
4
+ // Kept separate from template-resolver so apps with only tenant-wide
5
+ // collections stay usable without the user-data-rights stack. Mirrors
6
+ // notes-history-user-data.
7
+
8
+ import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { userContentDeleteHook, userContentExportHook } from "./hooks";
10
+
11
+ export const templateResolverUserDataFeature = defineFeature("template-resolver-user-data", (r) => {
12
+ r.describe(
13
+ "GDPR (Art. 20 export / Art. 17 erasure) coverage for the `template-resolver` feature's `user-content-entry` entity — the per-user half of the content store (mail signatures, personal reply snippets). Mounts the EXT_USER_DATA export hook so a user's own entries land in the export bundle; the delete hook is a deliberate no-op because `content` is annotated `userOwned`, so erasure runs via crypto-shredding (destroying the owner's subject key) rather than a physical delete, which would not survive an event replay. Mount this whenever `createTemplateResolverFeature` declares a collection with `ownership: \"user\"` — the boot guard otherwise refuses the entity. Requires `user-data-rights`, optionalRequires `template-resolver`.",
14
+ );
15
+ // user-data-rights is the hard dependency (EXT_USER_DATA host).
16
+ // template-resolver is optional for the same reason as in
17
+ // notes-history-user-data: a toggleable mount would otherwise trip the
18
+ // "effectively disabled" boot warning even though the entity is there.
19
+ r.requires("user-data-rights");
20
+ r.optionalRequires("template-resolver");
21
+ r.useExtension(EXT_USER_DATA, "user-content-entry", {
22
+ export: userContentExportHook,
23
+ delete: userContentDeleteHook,
24
+ });
25
+ });