@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 +8 -7
- package/src/ledger/entity.ts +15 -3
- package/src/template-resolver/README.md +36 -3
- package/src/template-resolver/__tests__/collection-kinds.integration.test.ts +23 -15
- package/src/template-resolver/__tests__/collection-ownership.integration.test.ts +336 -0
- package/src/template-resolver/feature.ts +8 -11
- package/src/template-resolver/handlers/collection-item.query.ts +10 -13
- package/src/template-resolver/handlers/collection-list.query.ts +11 -16
- package/src/template-resolver/handlers/collection-set.write.ts +15 -14
- package/src/template-resolver/handlers/collection-shared.ts +77 -14
- package/src/template-resolver/index.ts +5 -0
- package/src/template-resolver/user-content-table.ts +66 -0
- package/src/template-resolver-user-data/hooks.ts +40 -0
- package/src/template-resolver-user-data/index.ts +25 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
124
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
125
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
126
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
127
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
128
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
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",
|
package/src/ledger/entity.ts
CHANGED
|
@@ -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` (
|
|
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
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
266
|
-
test("
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
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 {
|
|
9
|
-
|
|
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
|
|
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<
|
|
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
|
-
...
|
|
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 {
|
|
10
|
-
|
|
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
|
|
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<
|
|
39
|
-
await selectMany(ctx.db,
|
|
42
|
+
const rows = castTenantRows<CollectionEntryRow>(
|
|
43
|
+
await selectMany(ctx.db, store.table, {
|
|
40
44
|
tenantId,
|
|
41
45
|
kind: collection.kind,
|
|
42
|
-
...
|
|
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 {
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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 {
|
|
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
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
+
});
|