@pramen/cms 0.0.59 → 0.0.61
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/README.md +62 -3
- package/dist/blockkit.d.ts +258 -0
- package/dist/blockkit.js +195 -0
- package/dist/index.d.ts +945 -4
- package/dist/index.js +1902 -75
- package/dist/nav.d.ts +30 -0
- package/dist/nav.js +37 -0
- package/package.json +2 -2
- package/src/blockkit.ts +316 -0
- package/src/index.ts +2024 -89
- package/src/nav.ts +38 -0
package/dist/index.js
CHANGED
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
// const acl = [ role("anonymous", [...cmsPolicies().public]),
|
|
25
25
|
// role("editor", [...cmsPolicies().editor]) ];
|
|
26
26
|
// const app = { schema, handlers, acl, tasks: { ...cmsTasks } };
|
|
27
|
-
import { Entity, query, mutation, primaryKey, generated, notNull, unique, indexed, defaultTo, expr, policy, allow, $now, partitionOf, DEFAULT_PARTITION, BadRequest, Forbidden, PramenError, Conflict, signToken, verifyToken, resolveSecret, } from "@pramen/server";
|
|
27
|
+
import { Entity, query, mutation, primaryKey, generated, notNull, unique, indexed, defaultTo, expr, policy, allow, $now, partitionOf, DEFAULT_PARTITION, BadRequest, Forbidden, PramenError, Conflict, signToken, verifyToken, resolveSecret, authorizeHandler, } from "@pramen/server";
|
|
28
28
|
import { isSafeHref, normalizeHref } from "./href";
|
|
29
|
+
import { NAV_ORDER } from "./nav";
|
|
29
30
|
/** Declare a typed block type. Pass `fields as const` to preserve the literals so
|
|
30
31
|
* `BlockFieldsOf<typeof def>` infers the field shape:
|
|
31
32
|
*
|
|
@@ -38,6 +39,15 @@ import { isSafeHref, normalizeHref } from "./href";
|
|
|
38
39
|
* Spread `hero` (minus fieldsSchema key naming) into `createBlockType`, and use
|
|
39
40
|
* `BlockFieldsOf<typeof hero>` to type the block's React component. */
|
|
40
41
|
export function defineBlockType(slug, fields, opts = {}) {
|
|
42
|
+
// A PURE constructor: it returns exactly what it was given, so `fieldsSchema` really is
|
|
43
|
+
// `F` and `BlockFieldsOf<typeof def>` describes the array that will be stored. Validation
|
|
44
|
+
// deliberately does NOT live here — it lives in `cmsBootstrap`, the thing that writes.
|
|
45
|
+
// `BlockTypeDef` is a structural interface, so an object literal, a `.map` or a codegen
|
|
46
|
+
// step reaches the store without passing through this function at all; checking here would
|
|
47
|
+
// have guarded the convenient path and left the sink open. Canonicalizing here was worse
|
|
48
|
+
// still: `validateFieldSchema` REBUILDS every entry (trimming names, dropping type-inert
|
|
49
|
+
// keys), so the returned array stopped matching the const literal `F` is inferred from and
|
|
50
|
+
// the cast became a lie a component would follow into `fields[" title "] === undefined`.
|
|
41
51
|
return { slug, name: opts.name ?? slug, fieldsSchema: fields, description: opts.description, icon: opts.icon, category: opts.category };
|
|
42
52
|
}
|
|
43
53
|
/** Declare a content type in code. Spread the result into `cmsBootstrap({ contentTypes })`:
|
|
@@ -48,17 +58,108 @@ export function defineBlockType(slug, fields, opts = {}) {
|
|
|
48
58
|
* regions: [{ name: "content", allowedTypes: ["rich_text", "image"] }],
|
|
49
59
|
* }); */
|
|
50
60
|
export function defineContentType(slug, opts) {
|
|
61
|
+
// Pure, for the same reason as `defineBlockType` — `cmsBootstrap` validates.
|
|
51
62
|
return { slug, name: opts.name ?? slug, description: opts.description, fields: opts.fields, regions: opts.regions, defaultBlocks: opts.defaultBlocks };
|
|
52
63
|
}
|
|
53
64
|
const sameJson = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
65
|
+
/** The default `owner` — see {@link cmsBootstrap}. */
|
|
66
|
+
export const CMS_BOOTSTRAP_OWNER = "cms";
|
|
67
|
+
/**
|
|
68
|
+
* Validate + canonicalize what a `cmsBootstrap` will write, at FACTORY-call time.
|
|
69
|
+
*
|
|
70
|
+
* This lives here rather than in `defineBlockType`/`defineContentType` because those are
|
|
71
|
+
* optional conveniences: `BlockTypeDef`/`ContentTypeDef` are exported STRUCTURAL interfaces,
|
|
72
|
+
* so an object literal, a `.map` over a config file or a codegen step reaches `upsertBySlug`
|
|
73
|
+
* without passing through either helper. Checking in the helper guarded the convenient path
|
|
74
|
+
* and left the sink open — and the row it wrote was then locked `managedBy`, so an invalid
|
|
75
|
+
* schema could never be repaired through the product. `cmsBootstrap(defs)` is the one call
|
|
76
|
+
* every code-defined type goes through.
|
|
77
|
+
*
|
|
78
|
+
* Held to the SAME rules the editor's `createBlockType`/`createContentType` enforce
|
|
79
|
+
* (`normalizeFieldSchema`, `normalizeRegions`, `normalizeDefaultBlocks`), because the
|
|
80
|
+
* alternative is a code-declared type storing a schema the builder then refuses to save —
|
|
81
|
+
* the only surface reporting the problem being the one that cannot fix it.
|
|
82
|
+
*
|
|
83
|
+
* EVERY problem is reported together, not just the first: this throws at app construction
|
|
84
|
+
* (`app.ts` module scope, like `validateCollections` in `createCollectionHandlers` and
|
|
85
|
+
* `validateMigrations` in `createPramen`), where fixing them one deploy at a time is the
|
|
86
|
+
* difference between one round trip and six.
|
|
87
|
+
*/
|
|
88
|
+
function validateCmsDefinitions(defs) {
|
|
89
|
+
const problems = [];
|
|
90
|
+
const at = (what, slug, e) => {
|
|
91
|
+
problems.push(` ${what} ${JSON.stringify(slug)}: ${e instanceof Error ? e.message : String(e)}`);
|
|
92
|
+
};
|
|
93
|
+
const blockTypes = [];
|
|
94
|
+
const btSeen = new Set();
|
|
95
|
+
for (const bt of defs.blockTypes ?? []) {
|
|
96
|
+
try {
|
|
97
|
+
const slug = assertRegistryKey(bt.slug, "block type slug");
|
|
98
|
+
// Last-wins on a repeated slug is how two feature modules both exporting a `cta` block
|
|
99
|
+
// type converge to whichever import order won, with nothing said about it.
|
|
100
|
+
if (btSeen.has(slug))
|
|
101
|
+
throw new BadRequest(`declared twice — the second declaration would silently overwrite the first`);
|
|
102
|
+
btSeen.add(slug);
|
|
103
|
+
blockTypes.push({
|
|
104
|
+
name: assertLabel(bt.name ?? slug, "block type name"),
|
|
105
|
+
slug,
|
|
106
|
+
description: bt.description ?? null,
|
|
107
|
+
fieldsSchema: normalizeFieldSchema(bt.fieldsSchema, "fieldsSchema"),
|
|
108
|
+
icon: bt.icon ?? null,
|
|
109
|
+
category: bt.category ?? null,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
at("block type", bt.slug, e);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const contentTypes = [];
|
|
117
|
+
const ctSeen = new Set();
|
|
118
|
+
for (const ct of defs.contentTypes ?? []) {
|
|
119
|
+
try {
|
|
120
|
+
// The slug FIRST: it is what every other message names, and validating regions ahead
|
|
121
|
+
// of it produced an error that never mentioned the type whose slug was also wrong —
|
|
122
|
+
// fix the regions, redeploy, meet the second failure.
|
|
123
|
+
const slug = assertRegistryKey(ct.slug, "content type slug");
|
|
124
|
+
if (ctSeen.has(slug))
|
|
125
|
+
throw new BadRequest(`declared twice — the second declaration would silently overwrite the first`);
|
|
126
|
+
ctSeen.add(slug);
|
|
127
|
+
const regions = normalizeRegions(ct.regions);
|
|
128
|
+
contentTypes.push({
|
|
129
|
+
name: assertLabel(ct.name ?? slug, "content type name"),
|
|
130
|
+
slug,
|
|
131
|
+
description: ct.description ?? null,
|
|
132
|
+
fieldsSchema: normalizeFieldSchema(ct.fields, "fields"),
|
|
133
|
+
regions,
|
|
134
|
+
defaultBlocks: normalizeDefaultBlocks(ct.defaultBlocks, regions),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
at("content type", ct.slug, e);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (problems.length > 0) {
|
|
142
|
+
throw new Error(`cmsBootstrap: ${problems.length} invalid type definition(s)\n${problems.join("\n")}`);
|
|
143
|
+
}
|
|
144
|
+
return { blockTypes, contentTypes };
|
|
145
|
+
}
|
|
54
146
|
/** Insert `values` if no row has this `slug`, else patch only the columns that drifted
|
|
55
|
-
* (never `id`/`slug`/`createdAt`). Idempotent — an identical definition is a no-op.
|
|
56
|
-
|
|
147
|
+
* (never `id`/`slug`/`createdAt`). Idempotent — an identical definition is a no-op.
|
|
148
|
+
*
|
|
149
|
+
* Returns false, having written NOTHING, when the existing row belongs to someone else —
|
|
150
|
+
* an editor-authored type (`managedBy` null) or another reconciler's. Adopting it was a
|
|
151
|
+
* silent takeover: name and schema replaced by the code literal, and then the row locked, so
|
|
152
|
+
* the editor could not even put back what it had just lost. `createBlockType` refuses this
|
|
153
|
+
* exact slug collision at the RPC edge; the reconciler used to win it without a word. */
|
|
154
|
+
async function upsertBySlug(db, table, owner, values) {
|
|
155
|
+
const slug = String(values.slug);
|
|
57
156
|
const existing = (await db.find({ from: table, where: { slug }, limit: 1 }))[0];
|
|
58
157
|
if (!existing) {
|
|
59
|
-
await db.insert(table, values);
|
|
60
|
-
return;
|
|
158
|
+
await db.insert(table, { ...values, managedBy: owner });
|
|
159
|
+
return true;
|
|
61
160
|
}
|
|
161
|
+
if (existing.managedBy !== owner)
|
|
162
|
+
return false;
|
|
62
163
|
const patch = {};
|
|
63
164
|
for (const [k, v] of Object.entries(values)) {
|
|
64
165
|
if (k === "slug")
|
|
@@ -68,6 +169,28 @@ async function upsertBySlug(db, table, slug, values) {
|
|
|
68
169
|
}
|
|
69
170
|
if (Object.keys(patch).length)
|
|
70
171
|
await db.update(table, String(existing.id), patch);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
/** Release the rows THIS owner wrote and no longer declares.
|
|
175
|
+
*
|
|
176
|
+
* A type dropped from the repo keeps its row — pages are still built out of it — but nothing
|
|
177
|
+
* converges it any more, so leaving it read-only in the builder would be a lock with nothing
|
|
178
|
+
* behind it, next to a note pointing at code that no longer mentions it.
|
|
179
|
+
*
|
|
180
|
+
* Scoped to `managedBy = owner`, which is what makes `cmsBootstrap` composable: a sweep
|
|
181
|
+
* cannot otherwise tell "not mine" from "no longer declared", so two reconcilers in one
|
|
182
|
+
* `app.bootstrap` released each other's rows on every boot and half the types silently fell
|
|
183
|
+
* back to editable. A table the call says nothing about (`blockTypes` absent — the KEY, not
|
|
184
|
+
* an empty array) is left alone rather than swept. */
|
|
185
|
+
async function releaseUndeclared(db, table, owner, declared) {
|
|
186
|
+
// `select` because this runs on the boot critical path — inside `blockConcurrencyWhile` on
|
|
187
|
+
// a DO's first fetch, and at every isolate init on D1 where each statement is a round trip.
|
|
188
|
+
// Without it the read pulls and JSON-parses every row's whole field schema to look at two
|
|
189
|
+
// columns (the GitHub #22 shape).
|
|
190
|
+
for (const row of await db.find({ from: table, where: { managedBy: owner }, select: ["id", "slug"] })) {
|
|
191
|
+
if (!declared.has(String(row.slug)))
|
|
192
|
+
await db.update(table, String(row.id), { managedBy: null });
|
|
193
|
+
}
|
|
71
194
|
}
|
|
72
195
|
/** Build a pramen `bootstrap` reconciler that upserts code-defined block + content types by
|
|
73
196
|
* `slug` on each boot. Idempotent: inserts a missing type, updates a drifted one, leaves an
|
|
@@ -77,30 +200,51 @@ async function upsertBySlug(db, table, slug, values) {
|
|
|
77
200
|
* bootstrap: [ cmsBootstrap({ blockTypes: [...], contentTypes: [...] }) ] };
|
|
78
201
|
*
|
|
79
202
|
* Runs with a privileged system Db, so a fresh/reprovisioned database converges to the
|
|
80
|
-
* code-declared types with no manual createContentType/createBlockType call.
|
|
81
|
-
|
|
203
|
+
* code-declared types with no manual createContentType/createBlockType call.
|
|
204
|
+
*
|
|
205
|
+
* Every row it writes is stamped `managedBy: owner`, which makes the editor show it
|
|
206
|
+
* read-only — convergence and an editor pointed at the same rows are otherwise a silent
|
|
207
|
+
* data-loss pair (GitHub #48). A type that drops out of the declaration is released back to
|
|
208
|
+
* the editor; a row this owner did not write is never touched, so a second reconciler (a
|
|
209
|
+
* package shipping its own block types, say) composes as long as it passes its own `owner`.
|
|
210
|
+
*
|
|
211
|
+
* The definitions are validated HERE, when the app is constructed, and every problem is
|
|
212
|
+
* reported at once — see {@link validateCmsDefinitions}. */
|
|
213
|
+
export function cmsBootstrap(defs, opts = {}) {
|
|
214
|
+
const owner = opts.owner ?? CMS_BOOTSTRAP_OWNER;
|
|
215
|
+
const { blockTypes, contentTypes } = validateCmsDefinitions(defs);
|
|
216
|
+
// Presence of the KEY, not truthiness of the array: `blockTypes: []` is "I declare none",
|
|
217
|
+
// which must sweep, while an absent key is "I say nothing about block types", which must
|
|
218
|
+
// not. Truthiness read `[]` as the latter four lines after `?? []` read it as the former,
|
|
219
|
+
// and the difference was invisible at the call site — `features.flatMap(f => f.blockTypes)`
|
|
220
|
+
// on an empty list silently unlocked every code-defined type in every tenant.
|
|
221
|
+
const sweepBlockTypes = "blockTypes" in defs;
|
|
222
|
+
const sweepContentTypes = "contentTypes" in defs;
|
|
82
223
|
return async ({ db }) => {
|
|
83
224
|
const sys = db;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
225
|
+
// Per-definition, so one failure does not skip every later type AND both sweeps. The
|
|
226
|
+
// boot runner only logs a throwing reconciler, so an aborted pass leaves the store half
|
|
227
|
+
// converged for that isolate's whole lifetime with nothing to retry it. A UNIQUE
|
|
228
|
+
// violation is the expected instance: `app.bootstrap` has no lease, and on D1 two cold
|
|
229
|
+
// isolates can both find a slug missing and both insert it.
|
|
230
|
+
const reconcile = async (table, values) => {
|
|
231
|
+
try {
|
|
232
|
+
if (!(await upsertBySlug(sys, table, owner, values))) {
|
|
233
|
+
console.warn(`@pramen/cms: ${table}.${String(values.slug)} already exists and is not owned by '${owner}' — leaving it alone (code-defined types cannot take over a row someone else authored)`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch (e) {
|
|
237
|
+
console.error(`@pramen/cms: failed to reconcile ${table}.${String(values.slug)}:`, e);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
for (const bt of blockTypes)
|
|
241
|
+
await reconcile("cms_block_types", bt);
|
|
242
|
+
if (sweepBlockTypes)
|
|
243
|
+
await releaseUndeclared(sys, "cms_block_types", owner, new Set(blockTypes.map((bt) => String(bt.slug))));
|
|
244
|
+
for (const ct of contentTypes)
|
|
245
|
+
await reconcile("cms_content_types", ct);
|
|
246
|
+
if (sweepContentTypes)
|
|
247
|
+
await releaseUndeclared(sys, "cms_content_types", owner, new Set(contentTypes.map((ct) => String(ct.slug))));
|
|
104
248
|
};
|
|
105
249
|
}
|
|
106
250
|
// --- codegen: emit .ts field interfaces from DB-stored block schemas ------------------
|
|
@@ -129,6 +273,9 @@ function tsTypeOf(f) {
|
|
|
129
273
|
return "boolean";
|
|
130
274
|
case "media":
|
|
131
275
|
return "ResolvedMedia | null";
|
|
276
|
+
// An opaque id — the record it points at may not be ours to type.
|
|
277
|
+
case "reference":
|
|
278
|
+
return f.multiple ? "string[]" : "string";
|
|
132
279
|
case "group":
|
|
133
280
|
return `{ ${(f.fields ?? []).map(tsFieldLine).join(" ")} }`;
|
|
134
281
|
case "repeater":
|
|
@@ -199,9 +346,21 @@ export function generateBlockTypes(blockTypes) {
|
|
|
199
346
|
const importLine = used.length ? `import type { ${used.join(", ")} } from "@pramen/cms";\n\n` : "";
|
|
200
347
|
return `// AUTO-GENERATED by @pramen/cms — do not edit.\n${importLine}${interfaces}\n\nexport interface BlockFieldsBySlug {\n${registry}\n}\n`;
|
|
201
348
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
*
|
|
349
|
+
/** How deep a menu tree may nest. Menus are stored as one document, so without a cap a
|
|
350
|
+
* client could post a tree deep enough to blow the stack in the resolver — and no real
|
|
351
|
+
* navigation is more than three levels anyway. */
|
|
352
|
+
export const MAX_MENU_DEPTH = 5;
|
|
353
|
+
/** How many items one menu may hold, at every level combined.
|
|
354
|
+
*
|
|
355
|
+
* Depth alone is not a bound: a FLAT list of 800 `page` items is legal under
|
|
356
|
+
* `MAX_MENU_DEPTH` and turns every anonymous `getMenu` — the read on every page render of
|
|
357
|
+
* the site — into a single `WHERE id IN (?×800)`, which the read engine emits with no
|
|
358
|
+
* chunking. Every other read added alongside this one is bounded (`MAX_TERMS`,
|
|
359
|
+
* `clampLimit`); this one was not, and it is the one on the hot path. */
|
|
360
|
+
export const MAX_MENU_ITEMS = 200;
|
|
361
|
+
/** How deep a term hierarchy may nest — the same argument as {@link MAX_MENU_DEPTH}, except
|
|
362
|
+
* here the tree is rows and the risk is a parent CYCLE, which `assertTermParent` refuses. */
|
|
363
|
+
export const MAX_TERM_DEPTH = 5;
|
|
205
364
|
export const cmsSchema = {
|
|
206
365
|
cms_content_types: Entity((t) => ({
|
|
207
366
|
id: primaryKey(generated(t.uuid())),
|
|
@@ -211,6 +370,8 @@ export const cmsSchema = {
|
|
|
211
370
|
fieldsSchema: t.json(), // FieldDefinition[] for page-level fields
|
|
212
371
|
regions: t.json(), // RegionDefinition[]
|
|
213
372
|
defaultBlocks: t.json(), // DefaultBlockDefinition[]
|
|
373
|
+
// Which reconciler owns this row. See cms_block_types.managedBy.
|
|
374
|
+
managedBy: t.text(),
|
|
214
375
|
createdAt: defaultTo(t.text(), expr.now()),
|
|
215
376
|
})),
|
|
216
377
|
cms_block_types: Entity((t) => ({
|
|
@@ -221,6 +382,21 @@ export const cmsSchema = {
|
|
|
221
382
|
fieldsSchema: t.json(), // FieldDefinition[]
|
|
222
383
|
icon: t.text(),
|
|
223
384
|
category: t.text(),
|
|
385
|
+
// NON-NULL while this row is CODE-DEFINED, holding the OWNER id of the `cmsBootstrap`
|
|
386
|
+
// that declares it. The editor authors these rows too (GitHub #9), and the two were
|
|
387
|
+
// otherwise indistinguishable: an editor would add a field, get a 200, and lose it
|
|
388
|
+
// silently at the next cold start when `upsertBySlug` patched the column back to the
|
|
389
|
+
// literal in `app.ts`. So it is set by the reconciler, refused by `updateBlockType` /
|
|
390
|
+
// `updateContentType`, and rendered read-only in the builder. Cleared again when the
|
|
391
|
+
// definition leaves the repo — a lock with nothing behind it is worse than no lock.
|
|
392
|
+
//
|
|
393
|
+
// An OWNER id rather than a boolean because `app.bootstrap` is a composable array. With
|
|
394
|
+
// a flag, two `cmsBootstrap` calls each released the other's rows on every boot: the
|
|
395
|
+
// sweep cannot tell "this row is not mine" from "this row is no longer declared", so
|
|
396
|
+
// half the types silently fell back to editable and #48 came straight back for them. A
|
|
397
|
+
// reconciler now only releases what IT wrote, which is also what lets a package ship its
|
|
398
|
+
// own block types beside the app's.
|
|
399
|
+
managedBy: t.text(),
|
|
224
400
|
createdAt: defaultTo(t.text(), expr.now()),
|
|
225
401
|
})),
|
|
226
402
|
cms_blocks: Entity((t) => ({
|
|
@@ -261,7 +437,8 @@ export const cmsSchema = {
|
|
|
261
437
|
unpublishAt: t.text(),
|
|
262
438
|
// The revision the public content API serves — set on publish. A direct pointer
|
|
263
439
|
// (not "latest by timestamp") so selection is deterministic even when two publishes
|
|
264
|
-
// land in the same
|
|
440
|
+
// land in the same instant. `expr.now()` carries milliseconds now, which narrows the
|
|
441
|
+
// window without closing it — a pointer has no window at all.
|
|
265
442
|
currentRevisionId: t.uuid(),
|
|
266
443
|
// Soft delete: the epoch-ISO instant the page was trashed, NULL while it is live.
|
|
267
444
|
// Every read scope AND-merges `deletedAt IS NULL` (see cmsPolicies), so a trashed
|
|
@@ -284,6 +461,10 @@ export const cmsSchema = {
|
|
|
284
461
|
}), (r) => ({
|
|
285
462
|
type: r.belongsTo("cms_content_types", "typeId"),
|
|
286
463
|
placements: r.hasMany("cms_page_blocks", "pageId"),
|
|
464
|
+
// Taxonomy terms, through the explicit junction. `where: { terms: { slug: "news" } }`
|
|
465
|
+
// compiles to a nested subquery, so "pages in this category" is an ordinary query and
|
|
466
|
+
// not a second handler.
|
|
467
|
+
terms: r.manyToMany("cms_terms", { through: "cms_page_terms", sourceColumn: "pageId", targetColumn: "termId" }),
|
|
287
468
|
}), { unique: [["slug", "locale"]] }),
|
|
288
469
|
cms_page_blocks: Entity((t) => ({
|
|
289
470
|
id: primaryKey(generated(t.uuid())),
|
|
@@ -337,11 +518,11 @@ export const cmsSchema = {
|
|
|
337
518
|
collection: indexed(notNull(t.text())),
|
|
338
519
|
rowId: indexed(notNull(t.text())),
|
|
339
520
|
// A monotonic per-row counter, and the ONLY ordering key. Timestamps cannot do this
|
|
340
|
-
// job
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
521
|
+
// job even at millisecond resolution: a collection revision is written on EVERY edit,
|
|
522
|
+
// and two writes land in the same millisecond often enough to be reproducible. Ordering
|
|
523
|
+
// then falls to a uuid tiebreak, which is deterministic but NOT insertion order — so
|
|
524
|
+
// "restore the previous version" could pick the wrong snapshot. (`expr.now()` was also
|
|
525
|
+
// second-resolution when this was written, which made the same point louder.)
|
|
345
526
|
//
|
|
346
527
|
// The read-then-increment in `snapshotRow` is serialized by the DO's single writer. On
|
|
347
528
|
// the D1 store it is NOT — `D1Driver.transaction` is a no-op (D1 has no interactive
|
|
@@ -352,13 +533,133 @@ export const cmsSchema = {
|
|
|
352
533
|
snapshot: t.json(),
|
|
353
534
|
note: t.text(),
|
|
354
535
|
actor: t.text(),
|
|
355
|
-
// NO expr.now() default
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
// would make "the previous version" an arbitrary pick between them.
|
|
536
|
+
// NO expr.now() default: `snapshotRow` is the only writer and stamps it. That is now a
|
|
537
|
+
// consistency choice rather than a precision one — `expr.now()` carries milliseconds
|
|
538
|
+
// too — but `revision` above is what actually orders these rows, and a column no
|
|
539
|
+
// writer but `snapshotRow` touches cannot drift from it.
|
|
360
540
|
createdAt: t.text(),
|
|
361
541
|
}), undefined, { unique: [["collection", "rowId", "revision"]] }),
|
|
542
|
+
// --- site furniture: menus, redirects, taxonomies, widget areas ------------------
|
|
543
|
+
//
|
|
544
|
+
// The WordPress-parity furniture every client project reinvents by hand. All four are
|
|
545
|
+
// SITE-level, not page-level: they exist once per deployment and are read by the layout,
|
|
546
|
+
// not by a page's regions.
|
|
547
|
+
// A named navigation menu. `items` is a nested `MenuItem[]` document rather than a rows
|
|
548
|
+
// table, because a menu is edited and read WHOLE — every read is `getMenu("primary")`,
|
|
549
|
+
// and every write is "here is the new tree". Rows would buy per-item queries nobody makes
|
|
550
|
+
// and cost a recursive assemble on the one read that matters. The tree is depth-capped on
|
|
551
|
+
// write (`MAX_MENU_DEPTH`), which is the constraint a rows table would have got for free.
|
|
552
|
+
cms_menus: Entity((t) => ({
|
|
553
|
+
id: primaryKey(generated(t.uuid())),
|
|
554
|
+
// The key `getMenu(name)` resolves — stable, referenced from layout code, and so NOT
|
|
555
|
+
// renameable through `updateMenu` (the label is what an editor retitles).
|
|
556
|
+
name: unique(notNull(t.text())),
|
|
557
|
+
label: notNull(t.text()),
|
|
558
|
+
items: t.json(), // MenuItem[]
|
|
559
|
+
// Optimistic concurrency, as on cms_pages/cms_blocks. It matters MORE here, not less:
|
|
560
|
+
// `updateMenu` writes the whole `items` document, so two editors on one menu meant the
|
|
561
|
+
// second silently replaced the first's entire tree — where a page edit at least
|
|
562
|
+
// conflicts per field.
|
|
563
|
+
version: defaultTo(t.int(), 1),
|
|
564
|
+
createdAt: defaultTo(t.text(), expr.now()),
|
|
565
|
+
updatedAt: defaultTo(t.text(), expr.now()),
|
|
566
|
+
})),
|
|
567
|
+
// A URL redirect. Needed the moment a slug changes on a live site — which the `slug`
|
|
568
|
+
// field's own docs already flag ("silently rewriting a slug changes a live URL and breaks
|
|
569
|
+
// every link to it").
|
|
570
|
+
//
|
|
571
|
+
// `fromPath`/`toPath`, not `from`/`to`: `from` is a SQL keyword, and while the dialect
|
|
572
|
+
// quotes every identifier, a column named `from` also collides with the `find({ from })`
|
|
573
|
+
// query key — a `where: { from: ... }` reads as a table reference to anyone skimming.
|
|
574
|
+
cms_redirects: Entity((t) => ({
|
|
575
|
+
id: primaryKey(generated(t.uuid())),
|
|
576
|
+
// Unique because resolution is an exact lookup: two rows for one path is a coin flip
|
|
577
|
+
// over which redirect a visitor gets, and the DB is the only place that can refuse it.
|
|
578
|
+
fromPath: unique(notNull(t.text())),
|
|
579
|
+
toPath: notNull(t.text()),
|
|
580
|
+
// 301 (permanent) or 302 (temporary). INT, and constrained on write — a redirect status
|
|
581
|
+
// is not free-form, and a typo here is a broken response, not a broken page.
|
|
582
|
+
status: defaultTo(t.int(), 301),
|
|
583
|
+
// Off-switch that keeps the row. A redirect is usually disabled to TEST whether it is
|
|
584
|
+
// still needed; deleting it loses the record of what the old URL was.
|
|
585
|
+
enabled: defaultTo(t.bool(), true),
|
|
586
|
+
note: t.text(),
|
|
587
|
+
createdAt: defaultTo(t.text(), expr.now()),
|
|
588
|
+
updatedAt: defaultTo(t.text(), expr.now()),
|
|
589
|
+
})),
|
|
590
|
+
// NOTE: deliberately no hit counter. Counting would make `resolveRedirect` — the one
|
|
591
|
+
// handler anonymous traffic calls on every 404 — a WRITE, which is an unauthenticated
|
|
592
|
+
// row mutation on the hot path and, on the DO, a transaction per miss. Redirect usage
|
|
593
|
+
// belongs in the edge's own logs.
|
|
594
|
+
// A classification vocabulary: `category` (hierarchical) and `tag` (flat) are just two
|
|
595
|
+
// rows here, which is why there is no built-in of either — a deployment declares what it
|
|
596
|
+
// classifies by, the same way it declares its content types.
|
|
597
|
+
cms_taxonomies: Entity((t) => ({
|
|
598
|
+
id: primaryKey(generated(t.uuid())),
|
|
599
|
+
slug: unique(notNull(t.text())),
|
|
600
|
+
label: notNull(t.text()),
|
|
601
|
+
pluralLabel: t.text(),
|
|
602
|
+
description: t.text(),
|
|
603
|
+
// Hierarchical vocabularies allow `parentId` on their terms; flat ones reject it on
|
|
604
|
+
// write. Enforced in the handler, not the schema — one term table serves both.
|
|
605
|
+
hierarchical: defaultTo(t.bool(), false),
|
|
606
|
+
createdAt: defaultTo(t.text(), expr.now()),
|
|
607
|
+
})),
|
|
608
|
+
cms_terms: Entity((t) => ({
|
|
609
|
+
id: primaryKey(generated(t.uuid())),
|
|
610
|
+
taxonomyId: indexed(notNull(t.uuid())),
|
|
611
|
+
slug: notNull(t.text()),
|
|
612
|
+
label: notNull(t.text()),
|
|
613
|
+
description: t.text(),
|
|
614
|
+
// Self-referential, and a REAL FK: deleting a parent term must not leave children
|
|
615
|
+
// pointing at a row that is gone (the front end would render an orphan branch that
|
|
616
|
+
// no listing can reach). `setNull` promotes them to the top level instead, which is
|
|
617
|
+
// the only non-destructive answer — `cascade` would silently delete a subtree.
|
|
618
|
+
parentId: t.uuid(),
|
|
619
|
+
position: defaultTo(t.int(), 0),
|
|
620
|
+
createdAt: defaultTo(t.text(), expr.now()),
|
|
621
|
+
}), (r) => ({
|
|
622
|
+
taxonomy: r.belongsTo("cms_taxonomies", "taxonomyId", { onDelete: "cascade" }),
|
|
623
|
+
parent: r.belongsTo("cms_terms", "parentId", { onDelete: "setNull" }),
|
|
624
|
+
pages: r.hasMany("cms_page_terms", "termId"),
|
|
625
|
+
}),
|
|
626
|
+
// A slug identifies a term WITHIN its vocabulary — `/category/news` and `/tag/news`
|
|
627
|
+
// are two different terms, and both are legitimate.
|
|
628
|
+
{ unique: [["taxonomyId", "slug"]] }),
|
|
629
|
+
// The term-assignment junction — an EXPLICIT entity, which is what `manyToMany` means
|
|
630
|
+
// here: `ctx.db.insert("cms_page_terms", …)` links, `delete` unlinks, and `where`
|
|
631
|
+
// traverses it as a nested subquery. No synthetic table, no write API to learn.
|
|
632
|
+
cms_page_terms: Entity((t) => ({
|
|
633
|
+
id: primaryKey(generated(t.uuid())),
|
|
634
|
+
pageId: indexed(notNull(t.uuid())),
|
|
635
|
+
termId: indexed(notNull(t.uuid())),
|
|
636
|
+
}), (r) => ({
|
|
637
|
+
page: r.belongsTo("cms_pages", "pageId", { onDelete: "cascade" }),
|
|
638
|
+
term: r.belongsTo("cms_terms", "termId", { onDelete: "cascade" }),
|
|
639
|
+
}),
|
|
640
|
+
// One assignment per (page, term). Without it a double-submit leaves a page tagged
|
|
641
|
+
// twice and every `with: { terms: true }` renders the term twice.
|
|
642
|
+
{ unique: [["pageId", "termId"]] }),
|
|
643
|
+
// A named template region an admin fills without touching code — the sidebar, the footer
|
|
644
|
+
// column, the pre-footer strip.
|
|
645
|
+
//
|
|
646
|
+
// Kept as its own entity rather than a page-less `cms_blocks` region, which was the
|
|
647
|
+
// tempting reuse. A block placement is `(pageId, region, position)` with `pageId` NOT
|
|
648
|
+
// NULL: making it nullable to model "belongs to no page" would put a null branch through
|
|
649
|
+
// every placement read, every region assemble and every page-scoped ACL clause, to model
|
|
650
|
+
// something that shares no field with a page (no slug, no status, no revisions, no
|
|
651
|
+
// workflow). A widget area is a small ordered document, and that is what it is stored as.
|
|
652
|
+
cms_widget_areas: Entity((t) => ({
|
|
653
|
+
id: primaryKey(generated(t.uuid())),
|
|
654
|
+
name: unique(notNull(t.text())),
|
|
655
|
+
label: notNull(t.text()),
|
|
656
|
+
description: t.text(),
|
|
657
|
+
widgets: t.json(), // Widget[]
|
|
658
|
+
// Same argument as cms_menus.version — `updateWidgetArea` replaces the whole list.
|
|
659
|
+
version: defaultTo(t.int(), 1),
|
|
660
|
+
createdAt: defaultTo(t.text(), expr.now()),
|
|
661
|
+
updatedAt: defaultTo(t.text(), expr.now()),
|
|
662
|
+
})),
|
|
362
663
|
// Media: a fileRef column holds only R2 metadata; bytes live in R2, uploaded via
|
|
363
664
|
// ctx.files + the Worker /files/* route. Block `fields` reference a media id.
|
|
364
665
|
cms_media: Entity((t) => ({
|
|
@@ -371,6 +672,29 @@ export const cmsSchema = {
|
|
|
371
672
|
createdAt: defaultTo(t.text(), expr.now()),
|
|
372
673
|
})),
|
|
373
674
|
};
|
|
675
|
+
/** Block Kit — custom admin pages, described as JSON and rendered by the editor. See
|
|
676
|
+
* `./blockkit`. Re-exported so a host imports `adminPage` beside `collection`. */
|
|
677
|
+
export { adminPage, createAdminPageHandlers, normalizeAdminResponse, validateAdminPages, MAX_ADMIN_BLOCK_DEPTH, } from "./blockkit";
|
|
678
|
+
/**
|
|
679
|
+
* Columns this package wrote in the pre-ISO space form that the SCHEMA cannot identify.
|
|
680
|
+
*
|
|
681
|
+
* `isoTimestampBackfill()` finds every column whose DEFAULT is `expr.now()` on its own.
|
|
682
|
+
* `cms_pages.publishedAt` has no default at all — `doPublish` stamped it from handler code,
|
|
683
|
+
* in the same space form, to stay comparable with the `updatedAt` written beside it. There
|
|
684
|
+
* is nothing on that column to find, so it is named here.
|
|
685
|
+
*
|
|
686
|
+
* Spread it into the migration if your store was ever written by a build older than this
|
|
687
|
+
* one:
|
|
688
|
+
*
|
|
689
|
+
* ```ts
|
|
690
|
+
* migrations: [isoTimestampBackfill({ extraColumns: CMS_LEGACY_TIMESTAMP_COLUMNS })]
|
|
691
|
+
* ```
|
|
692
|
+
*
|
|
693
|
+
* Costs nothing on a store that was not — the UPDATE matches no rows.
|
|
694
|
+
*/
|
|
695
|
+
export const CMS_LEGACY_TIMESTAMP_COLUMNS = {
|
|
696
|
+
cms_pages: ["publishedAt"],
|
|
697
|
+
};
|
|
374
698
|
/** Validate a block/page's `fields` payload against a field schema, throwing a 400 on
|
|
375
699
|
* the first violation. Recursive (repeater/group). Lenient on unknown field types. */
|
|
376
700
|
/** A calendar date, `YYYY-MM-DD` (what an <input type="date"> emits) — must also parse. */
|
|
@@ -459,6 +783,22 @@ export function validateFields(schema, values, path = "", opts = {}) {
|
|
|
459
783
|
if (typeof v !== "string")
|
|
460
784
|
throw new BadRequest(`field '${at}' must be a media id (string)`);
|
|
461
785
|
break;
|
|
786
|
+
// An OPAQUE id: the record may live in another table, or in a system we do not own,
|
|
787
|
+
// so there is nothing to check it against here beyond its shape. The picker's
|
|
788
|
+
// `referenceFrom` handler is the authority on which ids exist, and it runs under the
|
|
789
|
+
// caller's own ACL — so validating against a list fetched here would be both a second
|
|
790
|
+
// round trip and a weaker check than the one the storing handler already makes.
|
|
791
|
+
case "reference":
|
|
792
|
+
if (def.multiple) {
|
|
793
|
+
if (!Array.isArray(v))
|
|
794
|
+
throw new BadRequest(`field '${at}' must be a list of ids`);
|
|
795
|
+
if (v.some((id) => typeof id !== "string"))
|
|
796
|
+
throw new BadRequest(`field '${at}' must be a list of ids (strings)`);
|
|
797
|
+
}
|
|
798
|
+
else if (typeof v !== "string") {
|
|
799
|
+
throw new BadRequest(`field '${at}' must be an id (string)`);
|
|
800
|
+
}
|
|
801
|
+
break;
|
|
462
802
|
case "group": {
|
|
463
803
|
// The baseline MUST descend. Stopping at the top level meant a pre-migration
|
|
464
804
|
// richtext value nested in a group was rejected on every write that echoed the
|
|
@@ -498,6 +838,216 @@ export function validateFields(schema, values, path = "", opts = {}) {
|
|
|
498
838
|
}
|
|
499
839
|
}
|
|
500
840
|
}
|
|
841
|
+
// --- validating an AUTHORED field schema -------------------------------------
|
|
842
|
+
//
|
|
843
|
+
// `validateFields` above checks a VALUE against a schema. This checks the SCHEMA itself.
|
|
844
|
+
//
|
|
845
|
+
// It did not exist while the only way to create a block type was a developer writing
|
|
846
|
+
// `defineBlockType(...)` in the repo, where tsc is the check. Now that the editor authors
|
|
847
|
+
// types (GitHub #9), `fieldsSchema` arrives from a browser as free JSON into a `t.json()`
|
|
848
|
+
// column — and a malformed one is not caught anywhere downstream: `FieldForm` renders
|
|
849
|
+
// `null` for an unknown type, `validateFields` skips it ("lenient on unknown field types"),
|
|
850
|
+
// and the block silently loses that field's content on every save. A duplicate `name` is
|
|
851
|
+
// worse: two controls write the same key, so one of them can never be saved at all.
|
|
852
|
+
/** Every field type the runtime knows. Exported because the editor's type-builder offers
|
|
853
|
+
* exactly this list — one definition, so a type added here appears there without a second
|
|
854
|
+
* edit, and a type removed here cannot be authored. */
|
|
855
|
+
export const FIELD_TYPES = [
|
|
856
|
+
"text", "textarea", "richtext", "url", "number", "boolean", "date", "datetime",
|
|
857
|
+
"publish", "slug", "media", "select", "reference", "repeater", "group",
|
|
858
|
+
];
|
|
859
|
+
/** Types whose `fields` nest a further schema. */
|
|
860
|
+
const NESTING_TYPES = ["group", "repeater"];
|
|
861
|
+
/** How deep an authored field schema may nest. A schema is rendered by a recursive
|
|
862
|
+
* component and validated by a recursive function, so the cap is what keeps both bounded
|
|
863
|
+
* against a hand-posted document; nothing real nests past two or three. */
|
|
864
|
+
export const MAX_FIELD_DEPTH = 5;
|
|
865
|
+
/** A field NAME is an object key in a `fields` bag and a property name in generated TS
|
|
866
|
+
* (`generateBlockTypes`), so it is held to what can be both. */
|
|
867
|
+
const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
868
|
+
/** A REGION name. Looser than a field name by one character, because the two are not the
|
|
869
|
+
* same kind of thing: a field name is emitted as a TS property by `generateBlockTypes`, so
|
|
870
|
+
* it must be an identifier, while a region name is only ever an object key on the assembled
|
|
871
|
+
* page (`regions["main-content"]`). Held to `FIELD_NAME` it rejected hyphenated names that
|
|
872
|
+
* pre-date this validation and are stored today — which would have made every future save
|
|
873
|
+
* of such a content type fail, with the only fix being a rename that orphans its
|
|
874
|
+
* placements. */
|
|
875
|
+
const REGION_NAME = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
876
|
+
/**
|
|
877
|
+
* Validate and canonicalize an authored `FieldDefinition[]`, throwing a 400 on the first
|
|
878
|
+
* problem. Returns the CLEANED schema — each field rebuilt from the keys its type actually
|
|
879
|
+
* uses, so a `select`'s stale `options` cannot ride along on a field someone switched to
|
|
880
|
+
* `text` and reappear if they switch back.
|
|
881
|
+
*/
|
|
882
|
+
export function validateFieldSchema(raw, path = "fieldsSchema", depth = 0) {
|
|
883
|
+
if (raw == null)
|
|
884
|
+
return [];
|
|
885
|
+
if (!Array.isArray(raw))
|
|
886
|
+
throw new BadRequest(`${path} must be a list of field definitions`);
|
|
887
|
+
if (depth >= MAX_FIELD_DEPTH)
|
|
888
|
+
throw new BadRequest(`${path} nests deeper than ${MAX_FIELD_DEPTH} levels`);
|
|
889
|
+
const seen = new Set();
|
|
890
|
+
return raw.map((entry, i) => {
|
|
891
|
+
const o = asObj(entry);
|
|
892
|
+
const at = `${path}[${i}]`;
|
|
893
|
+
const name = typeof o.name === "string" ? o.name.trim() : "";
|
|
894
|
+
if (!FIELD_NAME.test(name)) {
|
|
895
|
+
throw new BadRequest(`${at}.name must be a field name (a letter or underscore, then letters/digits/underscores), got ${JSON.stringify(o.name)}`);
|
|
896
|
+
}
|
|
897
|
+
// Siblings only — a nested `group` legitimately reuses a name from the outer level,
|
|
898
|
+
// because it writes into its own bag.
|
|
899
|
+
if (seen.has(name))
|
|
900
|
+
throw new BadRequest(`${path} declares '${name}' twice — two controls would write the same key and one could never be saved`);
|
|
901
|
+
seen.add(name);
|
|
902
|
+
const type = o.type;
|
|
903
|
+
if (!FIELD_TYPES.includes(type)) {
|
|
904
|
+
throw new BadRequest(`${at}.type is '${String(o.type)}', which is not a field type (known: ${FIELD_TYPES.join(", ")})`);
|
|
905
|
+
}
|
|
906
|
+
const f = { name, type };
|
|
907
|
+
if (typeof o.label === "string" && o.label.trim() !== "")
|
|
908
|
+
f.label = o.label.trim();
|
|
909
|
+
if (o.required === true)
|
|
910
|
+
f.required = true;
|
|
911
|
+
if (o.default !== undefined)
|
|
912
|
+
f.default = o.default;
|
|
913
|
+
if (NESTING_TYPES.includes(type)) {
|
|
914
|
+
f.fields = validateFieldSchema(o.fields, `${at}.fields`, depth + 1);
|
|
915
|
+
// A `group` with no fields renders an empty box; a `repeater` with none renders rows
|
|
916
|
+
// of nothing and an Add button. Both are the shape of a half-finished edit, and both
|
|
917
|
+
// are silently useless rather than visibly wrong, so they are refused here.
|
|
918
|
+
if (f.fields.length === 0)
|
|
919
|
+
throw new BadRequest(`${at} is a '${type}' and needs at least one nested field`);
|
|
920
|
+
if (type === "repeater") {
|
|
921
|
+
if (typeof o.min === "number" && Number.isFinite(o.min))
|
|
922
|
+
f.min = Math.max(0, Math.trunc(o.min));
|
|
923
|
+
if (typeof o.max === "number" && Number.isFinite(o.max))
|
|
924
|
+
f.max = Math.max(1, Math.trunc(o.max));
|
|
925
|
+
if (f.min != null && f.max != null && f.min > f.max)
|
|
926
|
+
throw new BadRequest(`${at} has min ${f.min} above max ${f.max}`);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
else if (type === "select") {
|
|
930
|
+
// `optionsFrom` takes precedence at render time, so requiring options alongside it
|
|
931
|
+
// would reject the live-data case the option exists for.
|
|
932
|
+
if (typeof o.optionsFrom === "string" && o.optionsFrom.trim() !== "") {
|
|
933
|
+
f.optionsFrom = assertHandlerName(o.optionsFrom, `${at}.optionsFrom`);
|
|
934
|
+
}
|
|
935
|
+
else {
|
|
936
|
+
const options = Array.isArray(o.options) ? o.options.map((v) => String(v).trim()).filter((v) => v !== "") : [];
|
|
937
|
+
if (options.length === 0)
|
|
938
|
+
throw new BadRequest(`${at} is a 'select' and needs either \`options\` or an \`optionsFrom\` handler`);
|
|
939
|
+
if (new Set(options).size !== options.length)
|
|
940
|
+
throw new BadRequest(`${at} lists the same option twice`);
|
|
941
|
+
f.options = options;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
else if (type === "slug") {
|
|
945
|
+
// `from` is optional (a slug typed by hand is legitimate), but naming a field that is
|
|
946
|
+
// not there is a control that silently never follows anything.
|
|
947
|
+
if (typeof o.from === "string" && o.from.trim() !== "")
|
|
948
|
+
f.from = o.from.trim();
|
|
949
|
+
}
|
|
950
|
+
else if (type === "reference") {
|
|
951
|
+
if (typeof o.referenceFrom !== "string" || o.referenceFrom.trim() === "") {
|
|
952
|
+
throw new BadRequest(`${at} is a 'reference' and needs a \`referenceFrom\` query handler`);
|
|
953
|
+
}
|
|
954
|
+
f.referenceFrom = assertHandlerName(o.referenceFrom, `${at}.referenceFrom`);
|
|
955
|
+
if (o.multiple === true)
|
|
956
|
+
f.multiple = true;
|
|
957
|
+
}
|
|
958
|
+
return f;
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
/** Resolve `slug` cross-references inside one schema, now that every sibling is known: a
|
|
962
|
+
* `slug` field's `from` must name a field that exists AT THE SAME LEVEL (the editor reads
|
|
963
|
+
* it out of the sibling bag) and holds text. Separate pass because forward references are
|
|
964
|
+
* legitimate — a slug may precede the title it follows. */
|
|
965
|
+
export function checkSlugSources(schema, path = "fieldsSchema") {
|
|
966
|
+
const byName = new Map(schema.map((f) => [f.name, f]));
|
|
967
|
+
schema.forEach((f, i) => {
|
|
968
|
+
if (f.type === "slug" && f.from) {
|
|
969
|
+
const src = byName.get(f.from);
|
|
970
|
+
if (!src)
|
|
971
|
+
throw new BadRequest(`${path}[${i}] derives from '${f.from}', which is not a field alongside it`);
|
|
972
|
+
if (!["text", "textarea", "select", "url"].includes(src.type)) {
|
|
973
|
+
throw new BadRequest(`${path}[${i}] derives from '${f.from}', which is a '${src.type}' — a slug can only follow a text field`);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
if (f.fields)
|
|
977
|
+
checkSlugSources(f.fields, `${path}[${i}].fields`);
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
/** Validate + canonicalize an authored field schema end to end. */
|
|
981
|
+
export function normalizeFieldSchema(raw, path = "fieldsSchema") {
|
|
982
|
+
const schema = validateFieldSchema(raw, path);
|
|
983
|
+
checkSlugSources(schema, path);
|
|
984
|
+
return schema;
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Validate a content type's `regions`.
|
|
988
|
+
*
|
|
989
|
+
* A region NAME is the key `addBlock({ region })` resolves and the key of the assembled
|
|
990
|
+
* `regions` object a front end reads, so it is held to the same shape as a field name. An
|
|
991
|
+
* `allowedTypes` entry is a block-type SLUG; it is not checked against the block types that
|
|
992
|
+
* exist, on purpose — a content type declaring a region for a block type that has not been
|
|
993
|
+
* created yet is an ordinary order of work, and `assertRegionAllows` is what enforces the
|
|
994
|
+
* list at placement time.
|
|
995
|
+
*/
|
|
996
|
+
export function normalizeRegions(raw) {
|
|
997
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
998
|
+
throw new BadRequest("at least one region is required");
|
|
999
|
+
const seen = new Set();
|
|
1000
|
+
return raw.map((entry, i) => {
|
|
1001
|
+
const o = asObj(entry);
|
|
1002
|
+
const name = typeof o.name === "string" ? o.name.trim() : "";
|
|
1003
|
+
if (!REGION_NAME.test(name))
|
|
1004
|
+
throw new BadRequest(`regions[${i}].name must be a region name (a letter or underscore, then letters/digits/hyphens/underscores), got ${JSON.stringify(o.name)}`);
|
|
1005
|
+
if (seen.has(name))
|
|
1006
|
+
throw new BadRequest(`regions declares '${name}' twice — the assembled page is keyed by region name, so one would overwrite the other`);
|
|
1007
|
+
seen.add(name);
|
|
1008
|
+
const region = { name };
|
|
1009
|
+
if (typeof o.label === "string" && o.label.trim() !== "")
|
|
1010
|
+
region.label = o.label.trim();
|
|
1011
|
+
// `null` and omitted both mean "any block type"; an empty ARRAY means "none", which is
|
|
1012
|
+
// a region nothing can ever be placed in. Almost always a half-finished edit, so it is
|
|
1013
|
+
// normalized to "any" rather than stored as a region that silently refuses everything.
|
|
1014
|
+
if (Array.isArray(o.allowedTypes)) {
|
|
1015
|
+
const allowed = o.allowedTypes.map((v) => String(v).trim()).filter((v) => v !== "");
|
|
1016
|
+
region.allowedTypes = allowed.length > 0 ? [...new Set(allowed)] : null;
|
|
1017
|
+
}
|
|
1018
|
+
else {
|
|
1019
|
+
region.allowedTypes = null;
|
|
1020
|
+
}
|
|
1021
|
+
return region;
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
/** Validate a content type's `defaultBlocks` against its own regions. A default block that
|
|
1025
|
+
* names a region the type does not declare is created into nowhere — `createPage` would
|
|
1026
|
+
* place it under a key no renderer reads. */
|
|
1027
|
+
export function normalizeDefaultBlocks(raw, regions) {
|
|
1028
|
+
if (raw == null)
|
|
1029
|
+
return [];
|
|
1030
|
+
if (!Array.isArray(raw))
|
|
1031
|
+
throw new BadRequest("defaultBlocks must be a list");
|
|
1032
|
+
const names = new Set(regions.map((r) => r.name));
|
|
1033
|
+
return raw.map((entry, i) => {
|
|
1034
|
+
const o = asObj(entry);
|
|
1035
|
+
const region = typeof o.region === "string" ? o.region.trim() : "";
|
|
1036
|
+
const blockTypeSlug = typeof o.blockTypeSlug === "string" ? o.blockTypeSlug.trim() : "";
|
|
1037
|
+
if (!names.has(region))
|
|
1038
|
+
throw new BadRequest(`defaultBlocks[${i}] targets region '${region}', which this content type does not declare`);
|
|
1039
|
+
if (!blockTypeSlug)
|
|
1040
|
+
throw new BadRequest(`defaultBlocks[${i}] needs a blockTypeSlug`);
|
|
1041
|
+
const allowed = regions.find((r) => r.name === region)?.allowedTypes;
|
|
1042
|
+
if (allowed && !allowed.includes(blockTypeSlug)) {
|
|
1043
|
+
throw new BadRequest(`defaultBlocks[${i}] places '${blockTypeSlug}' into region '${region}', which does not allow it`);
|
|
1044
|
+
}
|
|
1045
|
+
const out = { region, blockTypeSlug };
|
|
1046
|
+
if (o.fields !== undefined)
|
|
1047
|
+
out.fields = asObj(o.fields);
|
|
1048
|
+
return out;
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
501
1051
|
/** What the shipped editor can actually produce (TipTap StarterKit + Highlight + TaskList,
|
|
502
1052
|
* as configured by @podoba/react's BlockEditor). Pass your own to `normalizeFields` if your
|
|
503
1053
|
* editor adds extensions — a node type absent from the schema is dropped on write. */
|
|
@@ -769,14 +1319,31 @@ function resolveMediaFields(fields, schema, mediaById) {
|
|
|
769
1319
|
const cdb = (ctx) => ctx.db;
|
|
770
1320
|
const notFound = (what) => new PramenError(`${what} not found`, 404, "not_found");
|
|
771
1321
|
const asObj = (v) => (v && typeof v === "object" ? v : {});
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
1322
|
+
/**
|
|
1323
|
+
* An ISO-8601 UTC instant — the ONE format every timestamp this package writes by hand is
|
|
1324
|
+
* in, so it compares correctly against `$now()` and against the `expr.now()` column
|
|
1325
|
+
* defaults beside it.
|
|
1326
|
+
*
|
|
1327
|
+
* There used to be two of these. `expr.now()` emitted the `datetime('now')` space form, so
|
|
1328
|
+
* page-workflow stamps (`updatedAt`, `publishedAt`) matched THAT to stay lexically
|
|
1329
|
+
* comparable with their own column default, while collection managed timestamps minted ISO
|
|
1330
|
+
* to stay comparable with `$now()`. One column could not satisfy both, and the split was
|
|
1331
|
+
* the honest way to live with it — a trap documented at length on the `publish` field.
|
|
1332
|
+
*
|
|
1333
|
+
* `expr.now()` is ISO now, so the two requirements are the same requirement and there is
|
|
1334
|
+
* one helper. Existing rows written in the old shape are rewritten by
|
|
1335
|
+
* `isoTimestampBackfill()` (see `CMS_LEGACY_TIMESTAMP_COLUMNS`).
|
|
1336
|
+
*/
|
|
1337
|
+
const isoStamp = () => new Date().toISOString();
|
|
1338
|
+
/** Alias kept because the page-workflow call sites read as "stamp it now". Same function. */
|
|
1339
|
+
const nowStamp = isoStamp;
|
|
1340
|
+
/** Does the caller hold one of these roles?
|
|
1341
|
+
*
|
|
1342
|
+
* Delegates to `authorizeHandler`, which is what the dispatcher uses to enforce a handler's
|
|
1343
|
+
* own `auth` — so "may call this handler" and "counts as an editor here" cannot answer
|
|
1344
|
+
* differently. The local copy took `roles` OR `role`, where the framework takes the UNION,
|
|
1345
|
+
* so an identity carrying both saw only one of them. */
|
|
1346
|
+
const isEditor = (ctx, roles) => authorizeHandler([...roles], ctx.identity ?? null);
|
|
780
1347
|
/** Assemble a page LIVE from its placements/blocks/types, grouped by region and ordered
|
|
781
1348
|
* by position, merging each shared placement's `overrides` over its block's fields. */
|
|
782
1349
|
/** Resolve a page's content-type slug from its `typeId` (null when the type row is gone). */
|
|
@@ -933,6 +1500,441 @@ async function assertRegionAllows(db, page, region, blockTypeSlug) {
|
|
|
933
1500
|
throw new BadRequest(`block type '${blockTypeSlug}' is not allowed in region '${region}'`);
|
|
934
1501
|
}
|
|
935
1502
|
}
|
|
1503
|
+
// --- handlers ----------------------------------------------------------------
|
|
1504
|
+
// --- page preview links (signed capability urls) -----------------------------
|
|
1505
|
+
//
|
|
1506
|
+
// Preview used to be a ROLE check, so previewing a draft required an editor account —
|
|
1507
|
+
// which excludes the person preview actually exists for: the stakeholder reviewing copy
|
|
1508
|
+
// before it ships. A preview link is instead a signed, self-expiring CAPABILITY: it names
|
|
1509
|
+
// ONE page, carries its own expiry, and is verified in the Worker before any read happens.
|
|
1510
|
+
// Minting stays editor-gated; redeeming needs no account at all.
|
|
1511
|
+
//
|
|
1512
|
+
// Same machinery as signed file urls (`signToken`/`verifyToken` from @pramen/server), and
|
|
1513
|
+
// the same fail-closed rule: without a usable secret we refuse to mint rather than hand out
|
|
1514
|
+
// forgeable links.
|
|
1515
|
+
// --- site furniture: normalization + resolution helpers ----------------------
|
|
1516
|
+
//
|
|
1517
|
+
// All of this runs on the WRITE path. A menu, a term tree and a widget list are documents
|
|
1518
|
+
// the client posts whole, so "the editor wouldn't send that" is not a boundary — every one
|
|
1519
|
+
// of these shapes is reachable with a curl.
|
|
1520
|
+
const MENU_ITEM_KINDS = ["custom", "page", "term", "collection"];
|
|
1521
|
+
/** A stable machine key — the string `getMenu(name)` / `getWidgetArea(name)` resolves, and
|
|
1522
|
+
* a taxonomy's URL segment. Same rule as a page slug, and for the same reason: it lands in
|
|
1523
|
+
* a route. */
|
|
1524
|
+
function assertKey(v, what) {
|
|
1525
|
+
const s = typeof v === "string" ? v.trim() : "";
|
|
1526
|
+
if (!isSlugString(s))
|
|
1527
|
+
throw new BadRequest(`${what} must be a key (lowercase letters, digits and single hyphens), got ${JSON.stringify(v)}`);
|
|
1528
|
+
return s;
|
|
1529
|
+
}
|
|
1530
|
+
/** A REGISTRY key — a block type's slug. Looser than {@link assertKey} by one character:
|
|
1531
|
+
* underscores are admitted, because a block-type slug is not a URL segment. It is the key a
|
|
1532
|
+
* front end maps to a component (`{ rich_text: RichText }`), and `rich_text` is the
|
|
1533
|
+
* convention every existing schema and the shipped example already use. */
|
|
1534
|
+
function assertRegistryKey(v, what) {
|
|
1535
|
+
const str = typeof v === "string" ? v.trim() : "";
|
|
1536
|
+
if (!/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/.test(str) || str.length > 80) {
|
|
1537
|
+
throw new BadRequest(`${what} must be a key (lowercase letters, digits, and single hyphens or underscores), got ${JSON.stringify(v)}`);
|
|
1538
|
+
}
|
|
1539
|
+
return str;
|
|
1540
|
+
}
|
|
1541
|
+
/** Refuse an editor write to a CODE-DEFINED type.
|
|
1542
|
+
*
|
|
1543
|
+
* `cmsBootstrap` reconciles these rows on every boot, so a save here would return 200 and
|
|
1544
|
+
* then be reverted at the next cold start, taking any content authored against the added
|
|
1545
|
+
* field with it. A 409 is the honest answer: the row exists, the edit is well-formed, and
|
|
1546
|
+
* the conflict is with a definition that lives somewhere this request cannot reach.
|
|
1547
|
+
*
|
|
1548
|
+
* The editor renders a managed type read-only, so this is the curl / stale-tab half.
|
|
1549
|
+
*
|
|
1550
|
+
* The caller MUST have read `managedBy` explicitly (`select`), not taken it off a wide read.
|
|
1551
|
+
* Reads are column-projected against the caller's policy, so under a read policy with a
|
|
1552
|
+
* `fields` list the column is simply absent — and a guard written as "absent means editable"
|
|
1553
|
+
* disarms itself for exactly the deployments that restrict fields. `select` fails CLOSED
|
|
1554
|
+
* instead: an unreadable column is a 403 before this runs. */
|
|
1555
|
+
export function assertNotManaged(row, what, defineFn) {
|
|
1556
|
+
if (!("managedBy" in row))
|
|
1557
|
+
throw new Error(`assertNotManaged: 'managedBy' was not selected for ${what} — the guard would fail open`);
|
|
1558
|
+
if (row.managedBy == null)
|
|
1559
|
+
return;
|
|
1560
|
+
throw new Conflict(`${what} '${String(row.slug)}' is defined in code (cmsBootstrap owner '${String(row.managedBy)}') — edit its ` +
|
|
1561
|
+
`${defineFn}(...) declaration and redeploy. A change saved here would be reverted on the next boot.`);
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* An RPC handler name, as an authored field schema may name one (`optionsFrom`,
|
|
1565
|
+
* `referenceFrom`).
|
|
1566
|
+
*
|
|
1567
|
+
* Shape-checked rather than merely non-empty, because the editor interpolates it straight
|
|
1568
|
+
* into a request path — `fetch(\`${base}/rpc/${name}\`)`. `"../admin/data"` normalizes to
|
|
1569
|
+
* `/admin/data`, so a stored string an EDITOR authored became an arbitrary same-origin
|
|
1570
|
+
* authenticated POST fired by whoever opened the block — including an admin, whose token
|
|
1571
|
+
* passes the `/admin/*` gate the editor role cannot. A handler name is an identifier;
|
|
1572
|
+
* nothing that can traverse a path is one.
|
|
1573
|
+
*/
|
|
1574
|
+
function assertHandlerName(v, what) {
|
|
1575
|
+
const str = typeof v === "string" ? v.trim() : "";
|
|
1576
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(str) || str.length > 80) {
|
|
1577
|
+
throw new BadRequest(`${what} must be a handler name (a letter or underscore, then letters/digits/underscores), got ${JSON.stringify(v)}`);
|
|
1578
|
+
}
|
|
1579
|
+
return str;
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* A menu item's `ref` for a non-`custom` kind.
|
|
1583
|
+
*
|
|
1584
|
+
* `menuHref` interpolates this into a path, so a ref starting with `/` produced
|
|
1585
|
+
* `//evil.example/` — protocol-relative, off-origin, in the site's primary nav on every
|
|
1586
|
+
* page — while the sibling `custom` branch three lines away ran the same string through
|
|
1587
|
+
* `isSafeHref`. A reference is an id or a slug: no slashes, no scheme, no dots.
|
|
1588
|
+
*/
|
|
1589
|
+
function assertRef(v, label, kind) {
|
|
1590
|
+
const str = typeof v === "string" ? v.trim() : "";
|
|
1591
|
+
if (!/^[A-Za-z0-9_-]{1,200}$/.test(str)) {
|
|
1592
|
+
throw new BadRequest(`menu item '${label}' is a '${kind}' item, so its \`ref\` must be an id or slug (letters, digits, hyphens, underscores), got ${JSON.stringify(v)}`);
|
|
1593
|
+
}
|
|
1594
|
+
return str;
|
|
1595
|
+
}
|
|
1596
|
+
function assertLabel(v, what) {
|
|
1597
|
+
const s = typeof v === "string" ? v.trim() : "";
|
|
1598
|
+
if (!s)
|
|
1599
|
+
throw new BadRequest(`${what} must not be empty`);
|
|
1600
|
+
return s;
|
|
1601
|
+
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Validate + canonicalize a posted menu tree.
|
|
1604
|
+
*
|
|
1605
|
+
* Every item is rebuilt field by field rather than spread: `items` is a `t.json()` column,
|
|
1606
|
+
* so anything in the posted object would be stored verbatim and handed to a layout that
|
|
1607
|
+
* renders it. Rebuilding is what makes the stored document exactly the declared shape.
|
|
1608
|
+
*/
|
|
1609
|
+
function normalizeMenuItems(raw, depth = 0, budget = { left: MAX_MENU_ITEMS }) {
|
|
1610
|
+
if (!Array.isArray(raw)) {
|
|
1611
|
+
if (raw == null)
|
|
1612
|
+
return [];
|
|
1613
|
+
throw new BadRequest("menu items must be a list");
|
|
1614
|
+
}
|
|
1615
|
+
if (depth >= MAX_MENU_DEPTH)
|
|
1616
|
+
throw new BadRequest(`menu items may nest at most ${MAX_MENU_DEPTH} levels deep`);
|
|
1617
|
+
return raw.map((entry, i) => {
|
|
1618
|
+
// Counted across the WHOLE tree, not per level — the budget is threaded through the
|
|
1619
|
+
// recursion for that reason.
|
|
1620
|
+
if (--budget.left < 0)
|
|
1621
|
+
throw new BadRequest(`a menu may hold at most ${MAX_MENU_ITEMS} items`);
|
|
1622
|
+
const o = asObj(entry);
|
|
1623
|
+
const label = assertLabel(o.label, `menu item [${i}] label`);
|
|
1624
|
+
const kind = (typeof o.kind === "string" ? o.kind : "custom");
|
|
1625
|
+
if (!MENU_ITEM_KINDS.includes(kind)) {
|
|
1626
|
+
throw new BadRequest(`menu item '${label}' has unknown kind '${String(o.kind)}' (known: ${MENU_ITEM_KINDS.join(", ")})`);
|
|
1627
|
+
}
|
|
1628
|
+
// A stable id per item. Minted here when absent so a client that posts a tree without
|
|
1629
|
+
// ids still gets reorderable rows back, rather than a list React can only key by index.
|
|
1630
|
+
const item = { id: typeof o.id === "string" && o.id.trim() !== "" ? o.id.trim() : crypto.randomUUID(), label, kind };
|
|
1631
|
+
if (kind === "custom") {
|
|
1632
|
+
// The SAME allow-list a rich-text link mark goes through. A menu is rendered into an
|
|
1633
|
+
// `<a href>` on every page of the site, so `javascript:` here is exactly the hole
|
|
1634
|
+
// `isSafeHref` exists to close — and the editor is not the only writer.
|
|
1635
|
+
const url = normalizeHref(typeof o.url === "string" ? o.url : "");
|
|
1636
|
+
if (!isSafeHref(url))
|
|
1637
|
+
throw new BadRequest(`menu item '${label}' needs a valid url (http(s), mailto:, tel:, a rooted path, or #anchor)`);
|
|
1638
|
+
item.url = url;
|
|
1639
|
+
}
|
|
1640
|
+
else {
|
|
1641
|
+
item.ref = assertRef(o.ref, label, kind);
|
|
1642
|
+
}
|
|
1643
|
+
// `target` is written into an anchor; anything but the four browsing-context keywords
|
|
1644
|
+
// is a named window, which is a way to reuse a tab the site does not own.
|
|
1645
|
+
if (typeof o.target === "string" && o.target !== "") {
|
|
1646
|
+
if (!["_self", "_blank", "_parent", "_top"].includes(o.target))
|
|
1647
|
+
throw new BadRequest(`menu item '${label}' has an unsupported target '${o.target}'`);
|
|
1648
|
+
item.target = o.target;
|
|
1649
|
+
}
|
|
1650
|
+
if (typeof o.titleAttr === "string" && o.titleAttr !== "")
|
|
1651
|
+
item.titleAttr = o.titleAttr;
|
|
1652
|
+
if (typeof o.cssClasses === "string" && o.cssClasses !== "")
|
|
1653
|
+
item.cssClasses = o.cssClasses;
|
|
1654
|
+
const children = normalizeMenuItems(o.children, depth + 1, budget);
|
|
1655
|
+
if (children.length > 0)
|
|
1656
|
+
item.children = children;
|
|
1657
|
+
return item;
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
/** Every `page`/`term` ref in a tree, so resolution is two queries rather than one per item. */
|
|
1661
|
+
function collectMenuRefs(items, pages, terms) {
|
|
1662
|
+
for (const it of items) {
|
|
1663
|
+
if (it.ref) {
|
|
1664
|
+
if (it.kind === "page")
|
|
1665
|
+
pages.add(it.ref);
|
|
1666
|
+
else if (it.kind === "term")
|
|
1667
|
+
terms.add(it.ref);
|
|
1668
|
+
}
|
|
1669
|
+
if (it.children)
|
|
1670
|
+
collectMenuRefs(it.children, pages, terms);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
/** A URL redirect's status. 301/308 are permanent (cached by browsers, and by search
|
|
1674
|
+
* engines as a canonical move); 302/307 are not. Anything else is not a redirect. */
|
|
1675
|
+
export const REDIRECT_STATUSES = [301, 302, 307, 308];
|
|
1676
|
+
/**
|
|
1677
|
+
* The stored form of a redirect's `fromPath`: a rooted, query-less, fragment-less path.
|
|
1678
|
+
*
|
|
1679
|
+
* Canonicalized rather than merely validated, because matching is an exact string lookup
|
|
1680
|
+
* against a UNIQUE column. `"/old"` and `"/old/"` are the same URL to a visitor and two
|
|
1681
|
+
* rows here, so the second one is dead the moment the first exists — and which one wins is
|
|
1682
|
+
* whichever the editor happened to type. Trailing slash off (except the root), fragment
|
|
1683
|
+
* and query dropped, percent-encoding left exactly as written (the parser's, and the
|
|
1684
|
+
* request's, canonical form).
|
|
1685
|
+
*/
|
|
1686
|
+
export function normalizeRedirectPath(raw) {
|
|
1687
|
+
const s = typeof raw === "string" ? normalizeHref(raw) : "";
|
|
1688
|
+
if (!s.startsWith("/") || s.startsWith("//") || s.startsWith("/\\")) {
|
|
1689
|
+
throw new BadRequest(`redirect path must be a rooted path like /old-url, got ${JSON.stringify(raw)}`);
|
|
1690
|
+
}
|
|
1691
|
+
const path = s.split("#")[0].split("?")[0];
|
|
1692
|
+
const trimmed = path.length > 1 ? path.replace(/\/+$/, "") || "/" : "/";
|
|
1693
|
+
// PERCENT-ENCODED, through the same parser the request goes through. A visitor's path
|
|
1694
|
+
// reaches `resolveRedirect` as `url.pathname`, which the WHATWG parser has already
|
|
1695
|
+
// encoded — so an editor typing `/o-nás` stored a string that the exact-match lookup
|
|
1696
|
+
// could never be handed, and the redirect silently never fired. On precisely the
|
|
1697
|
+
// non-English sites where slug changes are most common. Idempotent: an already-encoded
|
|
1698
|
+
// path parses back to itself.
|
|
1699
|
+
try {
|
|
1700
|
+
return new URL(trimmed, "https://pramen.invalid").pathname;
|
|
1701
|
+
}
|
|
1702
|
+
catch {
|
|
1703
|
+
throw new BadRequest(`redirect path is not a usable path: ${JSON.stringify(raw)}`);
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1707
|
+
* Is this redirect a loop — does its destination resolve back to its own source?
|
|
1708
|
+
*
|
|
1709
|
+
* Compared through `normalizeRedirectPath` on BOTH sides, which a raw `from === to` did
|
|
1710
|
+
* not do: `from: "/old", to: "/old/"` differ as strings, so the guard passed — and then a
|
|
1711
|
+
* visitor hitting `/old` was sent to `/old/`, whose 404 handler canonicalizes the trailing
|
|
1712
|
+
* slash back to `/old` and matches the same row. An infinite redirect, from the one pair
|
|
1713
|
+
* the guard exists to catch. (A test here even asserted this pair was fine, on the reading
|
|
1714
|
+
* that a trailing-slash redirect is a normal canonicalization — true in general, and not
|
|
1715
|
+
* true when the lookup canonicalizes the slash away again.)
|
|
1716
|
+
*
|
|
1717
|
+
* An absolute destination is never a loop with a rooted source: it names an origin, and
|
|
1718
|
+
* `resolveRedirect` is only ever handed a path.
|
|
1719
|
+
*/
|
|
1720
|
+
function isSelfRedirect(fromPath, toPath) {
|
|
1721
|
+
if (/^https?:\/\//i.test(toPath))
|
|
1722
|
+
return false;
|
|
1723
|
+
try {
|
|
1724
|
+
return normalizeRedirectPath(toPath) === fromPath;
|
|
1725
|
+
}
|
|
1726
|
+
catch {
|
|
1727
|
+
return false;
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
/** A redirect's destination: a rooted path or an absolute http(s) url. `mailto:`/`tel:` are
|
|
1731
|
+
* refused — they are not somewhere a `Location` header can send a page request. */
|
|
1732
|
+
function normalizeRedirectTarget(raw) {
|
|
1733
|
+
const s = typeof raw === "string" ? normalizeHref(raw) : "";
|
|
1734
|
+
const ok = /^https?:\/\//i.test(s) || (s.startsWith("/") && !s.startsWith("//") && !s.startsWith("/\\"));
|
|
1735
|
+
if (!ok)
|
|
1736
|
+
throw new BadRequest(`redirect target must be a rooted path or an absolute http(s) url, got ${JSON.stringify(raw)}`);
|
|
1737
|
+
return s;
|
|
1738
|
+
}
|
|
1739
|
+
/** Assemble a flat term list into a forest, ordered by `position` then `label`.
|
|
1740
|
+
*
|
|
1741
|
+
* A term whose `parentId` names a row that is not in `rows` is treated as a ROOT rather
|
|
1742
|
+
* than dropped. That is the case where a parent was deleted mid-read (the FK sets children
|
|
1743
|
+
* to NULL, but a snapshot taken across the two states can see the old value) — and a term
|
|
1744
|
+
* that vanishes from a vocabulary listing is a worse answer than one that shows up a level
|
|
1745
|
+
* too high.
|
|
1746
|
+
*/
|
|
1747
|
+
function buildTermTree(rows) {
|
|
1748
|
+
const byId = new Map();
|
|
1749
|
+
for (const r of rows)
|
|
1750
|
+
byId.set(r.id, { ...r, children: [] });
|
|
1751
|
+
const roots = [];
|
|
1752
|
+
for (const node of byId.values()) {
|
|
1753
|
+
const parent = node.parentId ? byId.get(node.parentId) : undefined;
|
|
1754
|
+
// `parent !== node` guards the one cycle a single row can make on its own; deeper
|
|
1755
|
+
// cycles are refused on write by `assertTermParent`, which is where a cycle is
|
|
1756
|
+
// actually preventable.
|
|
1757
|
+
if (parent && parent !== node)
|
|
1758
|
+
parent.children.push(node);
|
|
1759
|
+
else
|
|
1760
|
+
roots.push(node);
|
|
1761
|
+
}
|
|
1762
|
+
const sort = (list) => {
|
|
1763
|
+
list.sort((a, b) => a.position - b.position || a.label.localeCompare(b.label));
|
|
1764
|
+
for (const t of list)
|
|
1765
|
+
if (t.children)
|
|
1766
|
+
sort(t.children);
|
|
1767
|
+
return list;
|
|
1768
|
+
};
|
|
1769
|
+
return sort(roots);
|
|
1770
|
+
}
|
|
1771
|
+
const WIDGET_TYPES = ["content", "menu", "component"];
|
|
1772
|
+
/** A list limit from client input, clamped to what `listPages` already allows. Absent or
|
|
1773
|
+
* unusable falls back to the default rather than to "unbounded" — a request with no limit
|
|
1774
|
+
* on a store reached over RPC is the shape that made lists hang (GitHub #22). */
|
|
1775
|
+
function clampLimit(v) {
|
|
1776
|
+
const n = typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : PAGE_LIST_LIMIT;
|
|
1777
|
+
return Math.max(1, Math.min(n, PAGE_LIST_MAX_LIMIT));
|
|
1778
|
+
}
|
|
1779
|
+
/** The most terms one vocabulary (or one page) may carry in a single read.
|
|
1780
|
+
*
|
|
1781
|
+
* A vocabulary is read WHOLE by `listTerms`/`getTermTree` — a tree cannot be paged without
|
|
1782
|
+
* either losing branches or fetching ancestors separately — so the cap is what keeps that
|
|
1783
|
+
* read bounded. Tags are the case that grows without anyone deciding to grow it. */
|
|
1784
|
+
const MAX_TERMS = 1000;
|
|
1785
|
+
function redirectPatch(raw, requireEnds) {
|
|
1786
|
+
const o = asObj(raw);
|
|
1787
|
+
const out = {};
|
|
1788
|
+
if (requireEnds || o.fromPath !== undefined)
|
|
1789
|
+
out.fromPath = normalizeRedirectPath(o.fromPath);
|
|
1790
|
+
if (requireEnds || o.toPath !== undefined)
|
|
1791
|
+
out.toPath = normalizeRedirectTarget(o.toPath);
|
|
1792
|
+
if (o.status !== undefined) {
|
|
1793
|
+
const status = typeof o.status === "number" ? Math.trunc(o.status) : NaN;
|
|
1794
|
+
if (!REDIRECT_STATUSES.includes(status))
|
|
1795
|
+
throw new BadRequest(`redirect status must be one of ${REDIRECT_STATUSES.join(", ")}`);
|
|
1796
|
+
out.status = status;
|
|
1797
|
+
}
|
|
1798
|
+
if (o.enabled !== undefined)
|
|
1799
|
+
out.enabled = Boolean(o.enabled);
|
|
1800
|
+
if (o.note !== undefined)
|
|
1801
|
+
out.note = typeof o.note === "string" ? o.note : null;
|
|
1802
|
+
return out;
|
|
1803
|
+
}
|
|
1804
|
+
/** A required row id from client input. */
|
|
1805
|
+
function requireId(raw, what = "id") {
|
|
1806
|
+
const v = asObj(raw)[what];
|
|
1807
|
+
if (typeof v !== "string" || v === "")
|
|
1808
|
+
throw new BadRequest(`${what} is required`);
|
|
1809
|
+
return v;
|
|
1810
|
+
}
|
|
1811
|
+
/** Resolve a taxonomy by its slug. */
|
|
1812
|
+
async function taxonomyBySlug(db, slug) {
|
|
1813
|
+
const rows = await db.find({ from: "cms_taxonomies", where: { slug }, select: ["id", "hierarchical"], limit: 1 });
|
|
1814
|
+
const row = rows[0];
|
|
1815
|
+
return row ? { id: String(row.id), hierarchical: Boolean(row.hierarchical) } : null;
|
|
1816
|
+
}
|
|
1817
|
+
/**
|
|
1818
|
+
* Check a proposed `parentId` for a term: it exists, it is in the SAME vocabulary, the
|
|
1819
|
+
* vocabulary is hierarchical, the tree stays inside {@link MAX_TERM_DEPTH}, and — for an
|
|
1820
|
+
* update — the new parent is not the term itself or one of its own descendants.
|
|
1821
|
+
*
|
|
1822
|
+
* The cycle check is the one that matters. `ON DELETE SET NULL` keeps the FK honest but
|
|
1823
|
+
* says nothing about shape, so `A.parent = B; B.parent = A` is two perfectly legal writes
|
|
1824
|
+
* that together make `buildTermTree` produce a forest missing both, and any recursive
|
|
1825
|
+
* renderer loop forever. It is only preventable on write, which is here.
|
|
1826
|
+
*/
|
|
1827
|
+
async function assertTermParent(db, tax, parentId, termId) {
|
|
1828
|
+
if (parentId === null)
|
|
1829
|
+
return;
|
|
1830
|
+
if (!tax.hierarchical)
|
|
1831
|
+
throw new BadRequest("this vocabulary is flat — its terms cannot have a parent");
|
|
1832
|
+
if (termId !== null && parentId === termId)
|
|
1833
|
+
throw new BadRequest("a term cannot be its own parent");
|
|
1834
|
+
// How many levels the MOVED term itself occupies. A leaf is 1; a term with children takes
|
|
1835
|
+
// its subtree with it, and a cap that ignored that admitted a 5-level tree grafted under a
|
|
1836
|
+
// 4-level parent. Zero cost on the create path, where there is no subtree yet.
|
|
1837
|
+
const moving = termId === null ? 1 : await subtreeHeight(db, tax.id, termId);
|
|
1838
|
+
let cursor = parentId;
|
|
1839
|
+
// `depth` counts ANCESTORS walked. The moved term sits at `ancestors + moving` levels, and
|
|
1840
|
+
// that is what the cap governs — counting ancestors alone admitted one level too many
|
|
1841
|
+
// (a chain of 5 put the new term at level 6 under a cap of 5).
|
|
1842
|
+
for (let depth = 0; cursor !== null; depth++) {
|
|
1843
|
+
// About to walk ancestor number `depth + 1`. The moved term would then sit at
|
|
1844
|
+
// `(depth + 1) + moving` levels, and THAT is what the cap governs.
|
|
1845
|
+
if (depth + 1 + moving > MAX_TERM_DEPTH)
|
|
1846
|
+
throw new BadRequest(`terms may nest at most ${MAX_TERM_DEPTH} levels deep`);
|
|
1847
|
+
const rows = await db.find({ from: "cms_terms", where: { id: cursor }, select: ["id", "taxonomyId", "parentId"], limit: 1 });
|
|
1848
|
+
const row = rows[0];
|
|
1849
|
+
if (!row)
|
|
1850
|
+
throw new BadRequest("parent term not found");
|
|
1851
|
+
if (String(row.taxonomyId) !== tax.id)
|
|
1852
|
+
throw new BadRequest("a term's parent must be in the same vocabulary");
|
|
1853
|
+
// Walking UP from the proposed parent: meeting the term being edited means the parent
|
|
1854
|
+
// is one of its own descendants, which is the cycle.
|
|
1855
|
+
if (termId !== null && String(row.id) === termId)
|
|
1856
|
+
throw new BadRequest("a term cannot be moved under one of its own descendants");
|
|
1857
|
+
cursor = row.parentId == null ? null : String(row.parentId);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
/** How many levels a term's own subtree occupies (a leaf is 1).
|
|
1861
|
+
*
|
|
1862
|
+
* One query for the whole vocabulary rather than a walk per level: a vocabulary is already
|
|
1863
|
+
* read whole by `listTerms`/`getTermTree` and capped at `MAX_TERMS`, so this is the same
|
|
1864
|
+
* bounded read those make, not a new unbounded one. */
|
|
1865
|
+
async function subtreeHeight(db, taxonomyId, rootId) {
|
|
1866
|
+
const rows = await db.find({ from: "cms_terms", where: { taxonomyId }, select: ["id", "parentId"], limit: MAX_TERMS });
|
|
1867
|
+
const children = new Map();
|
|
1868
|
+
for (const r of rows) {
|
|
1869
|
+
const parent = r.parentId == null ? null : String(r.parentId);
|
|
1870
|
+
if (parent)
|
|
1871
|
+
children.set(parent, [...(children.get(parent) ?? []), String(r.id)]);
|
|
1872
|
+
}
|
|
1873
|
+
// Iterative, and bounded by MAX_TERMS: a cycle already in the store (written before the
|
|
1874
|
+
// check that now prevents one) must not spin here.
|
|
1875
|
+
let level = 0;
|
|
1876
|
+
let frontier = [rootId];
|
|
1877
|
+
const seen = new Set();
|
|
1878
|
+
while (frontier.length > 0 && level <= MAX_TERM_DEPTH + 1) {
|
|
1879
|
+
level++;
|
|
1880
|
+
const next = [];
|
|
1881
|
+
for (const id of frontier) {
|
|
1882
|
+
if (seen.has(id))
|
|
1883
|
+
continue;
|
|
1884
|
+
seen.add(id);
|
|
1885
|
+
next.push(...(children.get(id) ?? []));
|
|
1886
|
+
}
|
|
1887
|
+
frontier = next;
|
|
1888
|
+
}
|
|
1889
|
+
return level;
|
|
1890
|
+
}
|
|
1891
|
+
/**
|
|
1892
|
+
* Validate + canonicalize a posted widget list.
|
|
1893
|
+
*
|
|
1894
|
+
* Rebuilt field by field for the same reason a menu tree is: `widgets` is a `t.json()`
|
|
1895
|
+
* column handed straight to a layout, so whatever the client posts is what renders.
|
|
1896
|
+
* A `content` widget's rich text goes through the SAME `normalizeRichText` allow-list every
|
|
1897
|
+
* block field does — this is a second write path into the same renderer, and it must not be
|
|
1898
|
+
* a weaker one.
|
|
1899
|
+
*/
|
|
1900
|
+
function normalizeWidgets(raw, rtSchema) {
|
|
1901
|
+
if (!Array.isArray(raw)) {
|
|
1902
|
+
if (raw == null)
|
|
1903
|
+
return [];
|
|
1904
|
+
throw new BadRequest("widgets must be a list");
|
|
1905
|
+
}
|
|
1906
|
+
return raw.map((entry, i) => {
|
|
1907
|
+
const o = asObj(entry);
|
|
1908
|
+
const type = (typeof o.type === "string" ? o.type : "");
|
|
1909
|
+
if (!WIDGET_TYPES.includes(type))
|
|
1910
|
+
throw new BadRequest(`widget [${i}] has unknown type '${String(o.type)}' (known: ${WIDGET_TYPES.join(", ")})`);
|
|
1911
|
+
const w = { id: typeof o.id === "string" && o.id.trim() !== "" ? o.id.trim() : crypto.randomUUID(), type };
|
|
1912
|
+
if (typeof o.title === "string" && o.title !== "")
|
|
1913
|
+
w.title = o.title;
|
|
1914
|
+
if (type === "content") {
|
|
1915
|
+
w.content = normalizeRichText(o.content, rtSchema);
|
|
1916
|
+
}
|
|
1917
|
+
else if (type === "menu") {
|
|
1918
|
+
w.menuName = assertKey(o.menuName, `widget [${i}] menuName`);
|
|
1919
|
+
}
|
|
1920
|
+
else {
|
|
1921
|
+
const id = typeof o.componentId === "string" ? o.componentId.trim() : "";
|
|
1922
|
+
if (!id)
|
|
1923
|
+
throw new BadRequest(`widget [${i}] is a component widget and needs a componentId`);
|
|
1924
|
+
w.componentId = id;
|
|
1925
|
+
// Props are opaque to the CMS — the front end's component owns their meaning — but
|
|
1926
|
+
// they must be a JSON OBJECT, not a bare array or scalar that a spread would silently
|
|
1927
|
+
// turn into indexed props.
|
|
1928
|
+
if (o.componentProps !== undefined) {
|
|
1929
|
+
if (o.componentProps === null || typeof o.componentProps !== "object" || Array.isArray(o.componentProps)) {
|
|
1930
|
+
throw new BadRequest(`widget [${i}] componentProps must be an object`);
|
|
1931
|
+
}
|
|
1932
|
+
w.componentProps = o.componentProps;
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
return w;
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
936
1938
|
/** Secret preference order. `PREVIEW_SECRET` lets an operator rotate preview links without
|
|
937
1939
|
* invalidating every signed file url, but falling back keeps the common case zero-config. */
|
|
938
1940
|
export const PREVIEW_SECRET_NAMES = ["PREVIEW_SECRET", "FILES_SECRET", "AUTH_SECRET"];
|
|
@@ -977,6 +1979,16 @@ export function createCmsHandlers(opts = {}) {
|
|
|
977
1979
|
const reviewer = { auth: reviewerRoles };
|
|
978
1980
|
const previewTtl = opts.previewTtlSeconds ?? DEFAULT_PREVIEW_TTL_SECONDS;
|
|
979
1981
|
const rtSchema = opts.richTextSchema ?? DEFAULT_RICH_TEXT_SCHEMA;
|
|
1982
|
+
const menuHref = opts.menuHref ?? ((target) => {
|
|
1983
|
+
switch (target.kind) {
|
|
1984
|
+
// The locale segment appears only where there is a choice to make. A monolingual site
|
|
1985
|
+
// getting `/en/about` is the sitemap default's known wart, and a menu is the one place
|
|
1986
|
+
// it would be visible in the site's own chrome.
|
|
1987
|
+
case "page": return locales.length > 1 ? `/${target.locale}/${target.slug}` : `/${target.slug}`;
|
|
1988
|
+
case "term": return `/${target.taxonomy}/${target.slug}`;
|
|
1989
|
+
case "collection": return `/${target.slug}/`;
|
|
1990
|
+
}
|
|
1991
|
+
});
|
|
980
1992
|
// Anyone who edits OR reviews may VIEW content (a reviewer must preview a page + load its
|
|
981
1993
|
// content type/blocks before approving). Read/preview handlers use this; writes stay editor.
|
|
982
1994
|
const viewerRoles = [...new Set([...editorRoles, ...reviewerRoles])];
|
|
@@ -1071,13 +2083,95 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1071
2083
|
throw new BadRequest(`slug '${slug}' already exists for locale '${locale}'${under} — slugs are unique across all content types`);
|
|
1072
2084
|
}
|
|
1073
2085
|
};
|
|
2086
|
+
/**
|
|
2087
|
+
* One stored menu row, with its references resolved to hrefs.
|
|
2088
|
+
*
|
|
2089
|
+
* Shared by `getMenu` and `getWidgetArea` rather than inlined in the first: a `menu`
|
|
2090
|
+
* widget embeds a menu, and returning its RAW items there skipped every rule this
|
|
2091
|
+
* function exists to apply — a `page` item came back with no `url` at all (the layout
|
|
2092
|
+
* renders `href=undefined`) and an UNPUBLISHED page's label and id were served to
|
|
2093
|
+
* anonymous callers, which is precisely what the drop below prevents on the other path.
|
|
2094
|
+
*/
|
|
2095
|
+
const resolveMenuRow = async (db, row) => {
|
|
2096
|
+
const items = Array.isArray(row.items) ? row.items : [];
|
|
2097
|
+
// Two lookups for the whole tree, not one per item. Both go through `ctx.db`, so the
|
|
2098
|
+
// caller's own read scope applies: for an anonymous visitor that is the public policy
|
|
2099
|
+
// (published, not trashed), which is precisely the filter a menu needs — a link to a
|
|
2100
|
+
// page that has been unpublished must not render.
|
|
2101
|
+
const pageIds = new Set();
|
|
2102
|
+
const termIds = new Set();
|
|
2103
|
+
collectMenuRefs(items, pageIds, termIds);
|
|
2104
|
+
const pages = new Map();
|
|
2105
|
+
if (pageIds.size > 0) {
|
|
2106
|
+
const found = await db.find({ from: "cms_pages", where: { id: { in: [...pageIds] } }, select: ["id", "slug", "locale"], limit: pageIds.size });
|
|
2107
|
+
for (const p of found)
|
|
2108
|
+
pages.set(String(p.id), { slug: String(p.slug), locale: String(p.locale ?? defaultLocale) });
|
|
2109
|
+
}
|
|
2110
|
+
const terms = new Map();
|
|
2111
|
+
if (termIds.size > 0) {
|
|
2112
|
+
const found = await db.find({ from: "cms_terms", where: { id: { in: [...termIds] } }, select: ["id", "slug", "taxonomyId"], limit: termIds.size });
|
|
2113
|
+
const taxIds = [...new Set(found.map((t) => String(t.taxonomyId)))];
|
|
2114
|
+
const taxa = taxIds.length > 0 ? await db.find({ from: "cms_taxonomies", where: { id: { in: taxIds } }, select: ["id", "slug"], limit: taxIds.length }) : [];
|
|
2115
|
+
const taxSlug = new Map(taxa.map((t) => [String(t.id), String(t.slug)]));
|
|
2116
|
+
for (const t of found) {
|
|
2117
|
+
const tax = taxSlug.get(String(t.taxonomyId));
|
|
2118
|
+
if (tax)
|
|
2119
|
+
terms.set(String(t.id), { slug: String(t.slug), taxonomy: tax });
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
// An item whose target no longer resolves is DROPPED, together with its subtree. A
|
|
2123
|
+
// nav entry that renders no href is a dead link on every page of the site, and
|
|
2124
|
+
// hoisting orphaned children would silently promote a third-level item into the top
|
|
2125
|
+
// bar. The editor's own `listMenus` returns the raw tree, so nothing is lost there.
|
|
2126
|
+
const resolve = (list) => {
|
|
2127
|
+
const out = [];
|
|
2128
|
+
for (const item of list) {
|
|
2129
|
+
let url = item.url;
|
|
2130
|
+
if (item.kind === "page") {
|
|
2131
|
+
const page = item.ref ? pages.get(item.ref) : undefined;
|
|
2132
|
+
if (!page)
|
|
2133
|
+
continue;
|
|
2134
|
+
url = menuHref({ kind: "page", slug: page.slug, locale: page.locale });
|
|
2135
|
+
}
|
|
2136
|
+
else if (item.kind === "term") {
|
|
2137
|
+
const term = item.ref ? terms.get(item.ref) : undefined;
|
|
2138
|
+
if (!term)
|
|
2139
|
+
continue;
|
|
2140
|
+
url = menuHref({ kind: "term", taxonomy: term.taxonomy, slug: term.slug });
|
|
2141
|
+
}
|
|
2142
|
+
else if (item.kind === "collection") {
|
|
2143
|
+
if (!item.ref)
|
|
2144
|
+
continue;
|
|
2145
|
+
url = menuHref({ kind: "collection", slug: item.ref });
|
|
2146
|
+
}
|
|
2147
|
+
// The MINTED url goes through the same allow-list the `custom` branch enforces on
|
|
2148
|
+
// write. A reference is interpolated into a path (`/${slug}/`), and a `ref` that
|
|
2149
|
+
// began with a slash produced `//evil.example/` — protocol-relative, off-origin,
|
|
2150
|
+
// in the site's primary nav on every page. `assertRef` refuses that shape on
|
|
2151
|
+
// write; this is the second half, because `menuHref` is host-supplied and a
|
|
2152
|
+
// deployment's own mapping can build an unsafe href out of a safe ref.
|
|
2153
|
+
if (!url || !isSafeHref(url))
|
|
2154
|
+
continue;
|
|
2155
|
+
const children = item.children ? resolve(item.children) : [];
|
|
2156
|
+
out.push({ ...item, url, ...(children.length > 0 ? { children } : { children: undefined }) });
|
|
2157
|
+
}
|
|
2158
|
+
return out;
|
|
2159
|
+
};
|
|
2160
|
+
return { id: String(row.id), name: String(row.name), label: String(row.label), items: resolve(items) };
|
|
2161
|
+
};
|
|
1074
2162
|
const TASK_PUBLISH = "cms:publish";
|
|
1075
2163
|
const TASK_UNPUBLISH = "cms:unpublish";
|
|
1076
2164
|
return {
|
|
1077
2165
|
// ---- block types & content types (data-driven definitions) ----
|
|
1078
2166
|
listBlockTypes: query((ctx) => cdb(ctx).find({ from: "cms_block_types", orderBy: { column: "name" } })),
|
|
1079
2167
|
createBlockType: mutation(async (ctx, input) => {
|
|
1080
|
-
|
|
2168
|
+
const db = cdb(ctx);
|
|
2169
|
+
// A clean 409 before the UNIQUE constraint fires. The editor authors these now, and a
|
|
2170
|
+
// raw constraint error reads as "the CMS broke" rather than "that slug is taken".
|
|
2171
|
+
const clash = await db.find({ from: "cms_block_types", where: { slug: input.slug }, select: ["id"], limit: 1 });
|
|
2172
|
+
if (clash[0])
|
|
2173
|
+
throw new Conflict(`block type '${input.slug}' already exists`);
|
|
2174
|
+
return db.insert("cms_block_types", {
|
|
1081
2175
|
name: input.name,
|
|
1082
2176
|
slug: input.slug,
|
|
1083
2177
|
description: input.description ?? null,
|
|
@@ -1091,11 +2185,31 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1091
2185
|
const o = asObj(raw);
|
|
1092
2186
|
if (typeof o.name !== "string" || typeof o.slug !== "string")
|
|
1093
2187
|
throw new BadRequest("name and slug are required");
|
|
1094
|
-
|
|
2188
|
+
if (o.name.trim() === "")
|
|
2189
|
+
throw new BadRequest("name must not be empty");
|
|
2190
|
+
return {
|
|
2191
|
+
name: o.name.trim(),
|
|
2192
|
+
// A block type's slug is a registry key: the editor's inserter, `assertRegionAllows`
|
|
2193
|
+
// and `@pramen/cms/react`'s component map all resolve it. Held to the same shape as
|
|
2194
|
+
// any other key rather than accepted as free text.
|
|
2195
|
+
slug: assertRegistryKey(o.slug, "block type slug"),
|
|
2196
|
+
description: typeof o.description === "string" ? o.description : undefined,
|
|
2197
|
+
icon: typeof o.icon === "string" ? o.icon : undefined,
|
|
2198
|
+
category: typeof o.category === "string" ? o.category : undefined,
|
|
2199
|
+
fieldsSchema: normalizeFieldSchema(o.fieldsSchema),
|
|
2200
|
+
};
|
|
1095
2201
|
},
|
|
1096
2202
|
}),
|
|
1097
2203
|
createContentType: mutation(async (ctx, input) => {
|
|
1098
|
-
|
|
2204
|
+
const db = cdb(ctx);
|
|
2205
|
+
// The same pre-check `createBlockType` and every `create*` in this file already do.
|
|
2206
|
+
// It was the one create handler without it, so a duplicate slug surfaced as a raw
|
|
2207
|
+
// `UNIQUE constraint failed` with no status — a 500. Newly likely: an editor just told
|
|
2208
|
+
// a content type is code-defined and read-only will try to recreate it under that slug.
|
|
2209
|
+
const clash = await db.find({ from: "cms_content_types", where: { slug: input.slug }, select: ["id"], limit: 1 });
|
|
2210
|
+
if (clash[0])
|
|
2211
|
+
throw new Conflict(`content type '${input.slug}' already exists`);
|
|
2212
|
+
return db.insert("cms_content_types", {
|
|
1099
2213
|
name: input.name,
|
|
1100
2214
|
slug: input.slug,
|
|
1101
2215
|
regions: input.regions ?? [],
|
|
@@ -1112,11 +2226,20 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1112
2226
|
// editor (`/types/:slug`) and the key `listPages({ contentType })` resolves. An
|
|
1113
2227
|
// empty one builds `/types/` — a path the router drops the empty segment from, so
|
|
1114
2228
|
// the type gets a tab that cannot be reached and a list that cannot be addressed.
|
|
1115
|
-
if (o.name.trim() === ""
|
|
1116
|
-
throw new BadRequest("name
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
2229
|
+
if (o.name.trim() === "")
|
|
2230
|
+
throw new BadRequest("name must not be empty");
|
|
2231
|
+
const regions = normalizeRegions(o.regions);
|
|
2232
|
+
return {
|
|
2233
|
+
name: o.name.trim(),
|
|
2234
|
+
// The SAME rule a block-type slug follows. They were split — block types admitted
|
|
2235
|
+
// `_`, content types did not — for no reason that survives inspection: both are
|
|
2236
|
+
// registry keys, and an underscore is as legal in the `/types/:slug` segment as it
|
|
2237
|
+
// is anywhere else in a URL. The example itself ships `seeded_doc`.
|
|
2238
|
+
slug: assertRegistryKey(o.slug, "content type slug"),
|
|
2239
|
+
regions,
|
|
2240
|
+
fieldsSchema: normalizeFieldSchema(o.fieldsSchema),
|
|
2241
|
+
defaultBlocks: normalizeDefaultBlocks(o.defaultBlocks, regions),
|
|
2242
|
+
};
|
|
1120
2243
|
},
|
|
1121
2244
|
}),
|
|
1122
2245
|
/** Update a block type (found by `id` or `slug`). `slug` is the stable key and is
|
|
@@ -1124,10 +2247,14 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1124
2247
|
* what lets a schema evolve (e.g. a field text → date) without recreating the type. */
|
|
1125
2248
|
updateBlockType: mutation(async (ctx, input) => {
|
|
1126
2249
|
const db = cdb(ctx);
|
|
1127
|
-
|
|
2250
|
+
// `select` for BOTH reasons the projection exists: the guard needs `managedBy` to be
|
|
2251
|
+
// present rather than projected away, and the lookup has no use for the wide
|
|
2252
|
+
// `fieldsSchema` blob it used to fetch and JSON-parse to read three columns.
|
|
2253
|
+
const rows = await db.find({ from: "cms_block_types", where: input.id ? { id: input.id } : { slug: input.slug }, select: ["id", "slug", "managedBy"], limit: 1 });
|
|
1128
2254
|
const row = rows[0];
|
|
1129
2255
|
if (!row)
|
|
1130
2256
|
throw notFound("block type");
|
|
2257
|
+
assertNotManaged(row, "block type", "defineBlockType");
|
|
1131
2258
|
const patch = {};
|
|
1132
2259
|
for (const k of ["name", "fieldsSchema", "icon", "category", "description"]) {
|
|
1133
2260
|
if (k in input)
|
|
@@ -1140,7 +2267,14 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1140
2267
|
const o = asObj(raw);
|
|
1141
2268
|
if (typeof o.id !== "string" && typeof o.slug !== "string")
|
|
1142
2269
|
throw new BadRequest("id or slug is required");
|
|
1143
|
-
|
|
2270
|
+
const out = { ...o };
|
|
2271
|
+
// `name` is the label in the editor's inserter; blanking it leaves an unnamed entry
|
|
2272
|
+
// nobody can identify.
|
|
2273
|
+
if (o.name !== undefined)
|
|
2274
|
+
out.name = assertLabel(o.name, "block type name");
|
|
2275
|
+
if (o.fieldsSchema !== undefined)
|
|
2276
|
+
out.fieldsSchema = normalizeFieldSchema(o.fieldsSchema);
|
|
2277
|
+
return out;
|
|
1144
2278
|
},
|
|
1145
2279
|
}),
|
|
1146
2280
|
/** Update a content type (found by `id` or `slug`). `slug` is the stable key and is
|
|
@@ -1149,15 +2283,24 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1149
2283
|
const db = cdb(ctx);
|
|
1150
2284
|
if ("regions" in input && (!Array.isArray(input.regions) || input.regions.length === 0))
|
|
1151
2285
|
throw new BadRequest("at least one region is required");
|
|
1152
|
-
|
|
2286
|
+
// `regions` as well, because the `defaultBlocks`-only patch below checks against the
|
|
2287
|
+
// STORED regions. See the block-type lookup for why this is a `select` at all.
|
|
2288
|
+
const rows = await db.find({ from: "cms_content_types", where: input.id ? { id: input.id } : { slug: input.slug }, select: ["id", "slug", "managedBy", "regions"], limit: 1 });
|
|
1153
2289
|
const row = rows[0];
|
|
1154
2290
|
if (!row)
|
|
1155
2291
|
throw notFound("content type");
|
|
2292
|
+
assertNotManaged(row, "content type", "defineContentType");
|
|
1156
2293
|
const patch = {};
|
|
1157
2294
|
for (const k of ["name", "regions", "fieldsSchema", "defaultBlocks"]) {
|
|
1158
2295
|
if (k in input)
|
|
1159
2296
|
patch[k] = input[k];
|
|
1160
2297
|
}
|
|
2298
|
+
// A `defaultBlocks` patch that does NOT also send regions is checked here against the
|
|
2299
|
+
// STORED ones — the input parser has no row to read, and a default block placed into a
|
|
2300
|
+
// region the type does not declare is created into a key no renderer looks at.
|
|
2301
|
+
if (patch.defaultBlocks !== undefined && patch.regions === undefined) {
|
|
2302
|
+
patch.defaultBlocks = normalizeDefaultBlocks(patch.defaultBlocks, (Array.isArray(row.regions) ? row.regions : []));
|
|
2303
|
+
}
|
|
1161
2304
|
return db.update("cms_content_types", String(row.id), patch);
|
|
1162
2305
|
}, {
|
|
1163
2306
|
...editor,
|
|
@@ -1165,10 +2308,619 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1165
2308
|
const o = asObj(raw);
|
|
1166
2309
|
if (typeof o.id !== "string" && typeof o.slug !== "string")
|
|
1167
2310
|
throw new BadRequest("id or slug is required");
|
|
2311
|
+
const out = { ...o };
|
|
1168
2312
|
// `name` is the editor's tab label; blanking it leaves an unlabelled tab.
|
|
1169
|
-
if (
|
|
1170
|
-
|
|
1171
|
-
|
|
2313
|
+
if (o.name !== undefined)
|
|
2314
|
+
out.name = assertLabel(o.name, "content type name");
|
|
2315
|
+
if (o.regions !== undefined)
|
|
2316
|
+
out.regions = normalizeRegions(o.regions);
|
|
2317
|
+
if (o.fieldsSchema !== undefined)
|
|
2318
|
+
out.fieldsSchema = normalizeFieldSchema(o.fieldsSchema);
|
|
2319
|
+
// Checked against the regions being SAVED where the same call sends both — patching
|
|
2320
|
+
// only `defaultBlocks` cannot see the stored regions from an input parser, and the
|
|
2321
|
+
// handler re-checks below.
|
|
2322
|
+
if (out.defaultBlocks !== undefined && out.regions !== undefined) {
|
|
2323
|
+
out.defaultBlocks = normalizeDefaultBlocks(out.defaultBlocks, out.regions);
|
|
2324
|
+
}
|
|
2325
|
+
return out;
|
|
2326
|
+
},
|
|
2327
|
+
}),
|
|
2328
|
+
// ---- site furniture: menus ----------------------------------------------
|
|
2329
|
+
//
|
|
2330
|
+
// A menu is read WHOLE (`getMenu("primary")` on every page render) and written whole.
|
|
2331
|
+
// The public read RESOLVES references — that is what makes a `page` item follow its
|
|
2332
|
+
// page's slug instead of freezing the href an editor typed once.
|
|
2333
|
+
/** One menu, references resolved to hrefs. PUBLIC — a menu is site chrome.
|
|
2334
|
+
*
|
|
2335
|
+
* Returns `null` for an unknown name rather than 404ing, because a layout asking for a
|
|
2336
|
+
* menu it has not created yet is the normal state of a site being built, and a thrown
|
|
2337
|
+
* error there takes down every page instead of rendering no nav. */
|
|
2338
|
+
getMenu: query(async (ctx, input) => {
|
|
2339
|
+
const rows = await cdb(ctx).find({ from: "cms_menus", where: { name: input.name }, limit: 1 });
|
|
2340
|
+
const row = rows[0];
|
|
2341
|
+
if (!row)
|
|
2342
|
+
return null;
|
|
2343
|
+
return resolveMenuRow(cdb(ctx), row);
|
|
2344
|
+
}, {
|
|
2345
|
+
input: (raw) => ({ name: assertKey(asObj(raw).name, "menu name") }),
|
|
2346
|
+
}),
|
|
2347
|
+
/** Every menu, RAW (references unresolved) — the editor's list.
|
|
2348
|
+
*
|
|
2349
|
+
* Capped like every other list here. A site-furniture table is small by nature, which is
|
|
2350
|
+
* an argument for the cap being generous, not for its absence: an unbounded SELECT of a
|
|
2351
|
+
* wide row over RPC is the D1 failure shape from GitHub #22, and "small by nature" is
|
|
2352
|
+
* not a property the query planner knows about. */
|
|
2353
|
+
listMenus: query((ctx) => cdb(ctx).find({ from: "cms_menus", orderBy: { column: "label" }, limit: PAGE_LIST_MAX_LIMIT }), viewer),
|
|
2354
|
+
createMenu: mutation(async (ctx, input) => {
|
|
2355
|
+
const db = cdb(ctx);
|
|
2356
|
+
// A clean 409, as every sibling create handler gives. Without it a taken key surfaced
|
|
2357
|
+
// as the UNIQUE constraint's own 500 — and the e2e only asserts "not 200", so it
|
|
2358
|
+
// could not tell the two apart.
|
|
2359
|
+
const clash = await db.find({ from: "cms_menus", where: { name: input.name }, select: ["id"], limit: 1 });
|
|
2360
|
+
if (clash[0])
|
|
2361
|
+
throw new Conflict(`menu '${input.name}' already exists`);
|
|
2362
|
+
return db.insert("cms_menus", { name: input.name, label: input.label, items: input.items ?? [] });
|
|
2363
|
+
}, {
|
|
2364
|
+
...editor,
|
|
2365
|
+
input: (raw) => {
|
|
2366
|
+
const o = asObj(raw);
|
|
2367
|
+
return { name: assertKey(o.name, "menu name"), label: assertLabel(o.label, "menu label"), items: normalizeMenuItems(o.items) };
|
|
2368
|
+
},
|
|
2369
|
+
}),
|
|
2370
|
+
/** Patch a menu found by `id` or `name`. `name` is the key layout code resolves and is
|
|
2371
|
+
* NOT mutable — retitling is what `label` is for. */
|
|
2372
|
+
updateMenu: mutation(async (ctx, input) => {
|
|
2373
|
+
const db = cdb(ctx);
|
|
2374
|
+
const rows = await db.find({ from: "cms_menus", where: input.id ? { id: input.id } : { name: input.name }, limit: 1 });
|
|
2375
|
+
const row = rows[0];
|
|
2376
|
+
if (!row)
|
|
2377
|
+
throw notFound("menu");
|
|
2378
|
+
const patch = { updatedAt: nowStamp(), version: nextVersion(row, input.expectedVersion, "this menu") };
|
|
2379
|
+
if (input.label !== undefined)
|
|
2380
|
+
patch.label = input.label;
|
|
2381
|
+
if (input.items !== undefined)
|
|
2382
|
+
patch.items = input.items;
|
|
2383
|
+
return db.update("cms_menus", String(row.id), patch);
|
|
2384
|
+
}, {
|
|
2385
|
+
...editor,
|
|
2386
|
+
input: (raw) => {
|
|
2387
|
+
const o = asObj(raw);
|
|
2388
|
+
const out = {};
|
|
2389
|
+
if (typeof o.id === "string")
|
|
2390
|
+
out.id = o.id;
|
|
2391
|
+
else if (typeof o.name === "string")
|
|
2392
|
+
out.name = assertKey(o.name, "menu name");
|
|
2393
|
+
else
|
|
2394
|
+
throw new BadRequest("id or name is required");
|
|
2395
|
+
if (o.label !== undefined)
|
|
2396
|
+
out.label = assertLabel(o.label, "menu label");
|
|
2397
|
+
if (o.items !== undefined)
|
|
2398
|
+
out.items = normalizeMenuItems(o.items);
|
|
2399
|
+
if (typeof o.expectedVersion === "number")
|
|
2400
|
+
out.expectedVersion = o.expectedVersion;
|
|
2401
|
+
return out;
|
|
2402
|
+
},
|
|
2403
|
+
}),
|
|
2404
|
+
deleteMenu: mutation(async (ctx, input) => {
|
|
2405
|
+
const ok = await cdb(ctx).delete("cms_menus", input.id);
|
|
2406
|
+
if (!ok)
|
|
2407
|
+
throw notFound("menu");
|
|
2408
|
+
return { ok: true };
|
|
2409
|
+
}, {
|
|
2410
|
+
...editor,
|
|
2411
|
+
input: (raw) => {
|
|
2412
|
+
const id = asObj(raw).id;
|
|
2413
|
+
if (typeof id !== "string" || id === "")
|
|
2414
|
+
throw new BadRequest("id is required");
|
|
2415
|
+
return { id };
|
|
2416
|
+
},
|
|
2417
|
+
}),
|
|
2418
|
+
// ---- site furniture: redirects -------------------------------------------
|
|
2419
|
+
/** Resolve a request path to its redirect, or `null`. PUBLIC and READ-ONLY: this is
|
|
2420
|
+
* what a front end calls on a 404, so it is anonymous traffic on the hot path.
|
|
2421
|
+
*
|
|
2422
|
+
* `enabled` is enforced by the public read POLICY, not by a `where` here, so a disabled
|
|
2423
|
+
* redirect is invisible to every anonymous read (this handler, a future listing, a
|
|
2424
|
+
* relation traversal) rather than to the one call that remembered to filter. */
|
|
2425
|
+
resolveRedirect: query(async (ctx, input) => {
|
|
2426
|
+
const rows = await cdb(ctx).find({ from: "cms_redirects", where: { fromPath: input.path, enabled: true }, select: ["toPath", "status"], limit: 1 });
|
|
2427
|
+
const row = rows[0];
|
|
2428
|
+
return row ? { to: String(row.toPath), status: Number(row.status ?? 301) } : null;
|
|
2429
|
+
}, {
|
|
2430
|
+
input: (raw) => ({ path: normalizeRedirectPath(asObj(raw).path) }),
|
|
2431
|
+
}),
|
|
2432
|
+
listRedirects: query((ctx, input) => {
|
|
2433
|
+
return cdb(ctx).find({ from: "cms_redirects", orderBy: { column: "fromPath" }, limit: input.limit ?? PAGE_LIST_LIMIT, offset: input.offset ?? 0 });
|
|
2434
|
+
}, {
|
|
2435
|
+
...viewer,
|
|
2436
|
+
input: (raw) => {
|
|
2437
|
+
const o = asObj(raw);
|
|
2438
|
+
return { limit: clampLimit(o.limit), offset: typeof o.offset === "number" && o.offset > 0 ? Math.floor(o.offset) : 0 };
|
|
2439
|
+
},
|
|
2440
|
+
}),
|
|
2441
|
+
createRedirect: mutation(async (ctx, input) => {
|
|
2442
|
+
// A redirect to itself is an infinite loop the moment it is enabled, and the browser
|
|
2443
|
+
// is what discovers it. Cheap to refuse here; impossible to diagnose from the outside.
|
|
2444
|
+
if (isSelfRedirect(input.fromPath, input.toPath))
|
|
2445
|
+
throw new BadRequest("a redirect cannot point at itself");
|
|
2446
|
+
const db = cdb(ctx);
|
|
2447
|
+
const clash = await db.find({ from: "cms_redirects", where: { fromPath: input.fromPath }, select: ["id"], limit: 1 });
|
|
2448
|
+
if (clash[0])
|
|
2449
|
+
throw new Conflict(`a redirect from '${input.fromPath}' already exists`);
|
|
2450
|
+
return db.insert("cms_redirects", {
|
|
2451
|
+
fromPath: input.fromPath,
|
|
2452
|
+
toPath: input.toPath,
|
|
2453
|
+
status: input.status ?? 301,
|
|
2454
|
+
enabled: input.enabled ?? true,
|
|
2455
|
+
note: input.note ?? null,
|
|
2456
|
+
});
|
|
2457
|
+
}, {
|
|
2458
|
+
...editor,
|
|
2459
|
+
input: (raw) => redirectPatch(raw, true),
|
|
2460
|
+
}),
|
|
2461
|
+
updateRedirect: mutation(async (ctx, input) => {
|
|
2462
|
+
const db = cdb(ctx);
|
|
2463
|
+
const rows = await db.find({ from: "cms_redirects", where: { id: input.id }, limit: 1 });
|
|
2464
|
+
const row = rows[0];
|
|
2465
|
+
if (!row)
|
|
2466
|
+
throw notFound("redirect");
|
|
2467
|
+
const from = input.fromPath ?? String(row.fromPath);
|
|
2468
|
+
const to = input.toPath ?? String(row.toPath);
|
|
2469
|
+
if (isSelfRedirect(from, to))
|
|
2470
|
+
throw new BadRequest("a redirect cannot point at itself");
|
|
2471
|
+
if (input.fromPath && input.fromPath !== row.fromPath) {
|
|
2472
|
+
const clash = await db.find({ from: "cms_redirects", where: { fromPath: input.fromPath }, select: ["id"], limit: 1 });
|
|
2473
|
+
if (clash[0])
|
|
2474
|
+
throw new Conflict(`a redirect from '${input.fromPath}' already exists`);
|
|
2475
|
+
}
|
|
2476
|
+
const patch = { updatedAt: nowStamp() };
|
|
2477
|
+
for (const k of ["fromPath", "toPath", "status", "enabled", "note"]) {
|
|
2478
|
+
if (input[k] !== undefined)
|
|
2479
|
+
patch[k] = input[k];
|
|
2480
|
+
}
|
|
2481
|
+
return db.update("cms_redirects", input.id, patch);
|
|
2482
|
+
}, {
|
|
2483
|
+
...editor,
|
|
2484
|
+
input: (raw) => ({ id: requireId(raw), ...redirectPatch(raw, false) }),
|
|
2485
|
+
}),
|
|
2486
|
+
deleteRedirect: mutation(async (ctx, input) => {
|
|
2487
|
+
const ok = await cdb(ctx).delete("cms_redirects", input.id);
|
|
2488
|
+
if (!ok)
|
|
2489
|
+
throw notFound("redirect");
|
|
2490
|
+
return { ok: true };
|
|
2491
|
+
}, {
|
|
2492
|
+
...editor,
|
|
2493
|
+
input: (raw) => {
|
|
2494
|
+
const id = asObj(raw).id;
|
|
2495
|
+
if (typeof id !== "string" || id === "")
|
|
2496
|
+
throw new BadRequest("id is required");
|
|
2497
|
+
return { id };
|
|
2498
|
+
},
|
|
2499
|
+
}),
|
|
2500
|
+
// ---- site furniture: taxonomies ------------------------------------------
|
|
2501
|
+
//
|
|
2502
|
+
// `category` and `tag` are not built in: they are two rows a deployment creates, the
|
|
2503
|
+
// same way it creates its content types. A vocabulary that is hierarchical allows
|
|
2504
|
+
// `parentId` on its terms; a flat one refuses it rather than storing something no
|
|
2505
|
+
// listing renders.
|
|
2506
|
+
/** Every vocabulary. PUBLIC — like content types, a taxonomy's slug is structural (it
|
|
2507
|
+
* is a URL segment) and a front end routes on it. */
|
|
2508
|
+
listTaxonomies: query((ctx) => cdb(ctx).find({ from: "cms_taxonomies", orderBy: { column: "label" }, limit: PAGE_LIST_MAX_LIMIT })),
|
|
2509
|
+
createTaxonomy: mutation(async (ctx, input) => {
|
|
2510
|
+
const db = cdb(ctx);
|
|
2511
|
+
const clash = await db.find({ from: "cms_taxonomies", where: { slug: input.slug }, select: ["id"], limit: 1 });
|
|
2512
|
+
if (clash[0])
|
|
2513
|
+
throw new Conflict(`taxonomy '${input.slug}' already exists`);
|
|
2514
|
+
return db.insert("cms_taxonomies", {
|
|
2515
|
+
slug: input.slug,
|
|
2516
|
+
label: input.label,
|
|
2517
|
+
pluralLabel: input.pluralLabel ?? null,
|
|
2518
|
+
description: input.description ?? null,
|
|
2519
|
+
hierarchical: input.hierarchical ?? false,
|
|
2520
|
+
});
|
|
2521
|
+
}, {
|
|
2522
|
+
...editor,
|
|
2523
|
+
input: (raw) => {
|
|
2524
|
+
const o = asObj(raw);
|
|
2525
|
+
return {
|
|
2526
|
+
slug: assertKey(o.slug, "taxonomy slug"),
|
|
2527
|
+
label: assertLabel(o.label, "taxonomy label"),
|
|
2528
|
+
pluralLabel: typeof o.pluralLabel === "string" ? o.pluralLabel : undefined,
|
|
2529
|
+
description: typeof o.description === "string" ? o.description : undefined,
|
|
2530
|
+
hierarchical: typeof o.hierarchical === "boolean" ? o.hierarchical : undefined,
|
|
2531
|
+
};
|
|
2532
|
+
},
|
|
2533
|
+
}),
|
|
2534
|
+
/** Patch a vocabulary. `slug` is a URL segment and the key `listTerms` resolves, so it
|
|
2535
|
+
* is not mutable — the same rule content types and block types already follow.
|
|
2536
|
+
*
|
|
2537
|
+
* Turning `hierarchical` OFF is refused while any term still has a parent. Allowing it
|
|
2538
|
+
* would leave a stored hierarchy that no reader renders and no writer can clear, and
|
|
2539
|
+
* flattening the terms silently is a destructive edit behind a checkbox. */
|
|
2540
|
+
updateTaxonomy: mutation(async (ctx, input) => {
|
|
2541
|
+
const db = cdb(ctx);
|
|
2542
|
+
const rows = await db.find({ from: "cms_taxonomies", where: { id: input.id }, limit: 1 });
|
|
2543
|
+
const row = rows[0];
|
|
2544
|
+
if (!row)
|
|
2545
|
+
throw notFound("taxonomy");
|
|
2546
|
+
if (input.hierarchical === false && row.hierarchical) {
|
|
2547
|
+
const nested = await db.find({ from: "cms_terms", where: { taxonomyId: input.id, parentId: { isNull: false } }, select: ["id"], limit: 1 });
|
|
2548
|
+
if (nested[0])
|
|
2549
|
+
throw new BadRequest("this vocabulary still has nested terms — move them to the top level before making it flat");
|
|
2550
|
+
}
|
|
2551
|
+
const patch = {};
|
|
2552
|
+
for (const k of ["label", "pluralLabel", "description", "hierarchical"]) {
|
|
2553
|
+
if (input[k] !== undefined)
|
|
2554
|
+
patch[k] = input[k];
|
|
2555
|
+
}
|
|
2556
|
+
return db.update("cms_taxonomies", input.id, patch);
|
|
2557
|
+
}, {
|
|
2558
|
+
...editor,
|
|
2559
|
+
input: (raw) => {
|
|
2560
|
+
const o = asObj(raw);
|
|
2561
|
+
if (typeof o.id !== "string" || o.id === "")
|
|
2562
|
+
throw new BadRequest("id is required");
|
|
2563
|
+
const out = { id: o.id };
|
|
2564
|
+
if (o.label !== undefined)
|
|
2565
|
+
out.label = assertLabel(o.label, "taxonomy label");
|
|
2566
|
+
if (o.pluralLabel !== undefined)
|
|
2567
|
+
out.pluralLabel = typeof o.pluralLabel === "string" ? o.pluralLabel : null;
|
|
2568
|
+
if (o.description !== undefined)
|
|
2569
|
+
out.description = typeof o.description === "string" ? o.description : null;
|
|
2570
|
+
if (typeof o.hierarchical === "boolean")
|
|
2571
|
+
out.hierarchical = o.hierarchical;
|
|
2572
|
+
return out;
|
|
2573
|
+
},
|
|
2574
|
+
}),
|
|
2575
|
+
/** Delete a vocabulary. Its terms go with it, and their page assignments with those —
|
|
2576
|
+
* both by real `ON DELETE CASCADE`, so the cleanup is the DB's and cannot be half-done
|
|
2577
|
+
* by a handler that threw between two writes. */
|
|
2578
|
+
deleteTaxonomy: mutation(async (ctx, input) => {
|
|
2579
|
+
const ok = await cdb(ctx).delete("cms_taxonomies", input.id);
|
|
2580
|
+
if (!ok)
|
|
2581
|
+
throw notFound("taxonomy");
|
|
2582
|
+
return { ok: true };
|
|
2583
|
+
}, {
|
|
2584
|
+
...editor,
|
|
2585
|
+
input: (raw) => {
|
|
2586
|
+
const id = asObj(raw).id;
|
|
2587
|
+
if (typeof id !== "string" || id === "")
|
|
2588
|
+
throw new BadRequest("id is required");
|
|
2589
|
+
return { id };
|
|
2590
|
+
},
|
|
2591
|
+
}),
|
|
2592
|
+
/** One vocabulary's terms, flat, ordered. PUBLIC. */
|
|
2593
|
+
listTerms: query(async (ctx, input) => {
|
|
2594
|
+
const db = cdb(ctx);
|
|
2595
|
+
const tax = await taxonomyBySlug(db, input.taxonomy);
|
|
2596
|
+
if (!tax)
|
|
2597
|
+
return [];
|
|
2598
|
+
const rows = await db.find({ from: "cms_terms", where: { taxonomyId: tax.id }, orderBy: [{ column: "position" }, { column: "label" }], limit: MAX_TERMS });
|
|
2599
|
+
return rows;
|
|
2600
|
+
}, {
|
|
2601
|
+
input: (raw) => ({ taxonomy: assertKey(asObj(raw).taxonomy, "taxonomy slug") }),
|
|
2602
|
+
}),
|
|
2603
|
+
/** One vocabulary's terms as a TREE. PUBLIC. Assembled here rather than by the caller
|
|
2604
|
+
* because a hierarchy is what the nav renders and every consumer would otherwise write
|
|
2605
|
+
* the same fold. */
|
|
2606
|
+
getTermTree: query(async (ctx, input) => {
|
|
2607
|
+
const db = cdb(ctx);
|
|
2608
|
+
const tax = await taxonomyBySlug(db, input.taxonomy);
|
|
2609
|
+
if (!tax)
|
|
2610
|
+
return [];
|
|
2611
|
+
const rows = await db.find({ from: "cms_terms", where: { taxonomyId: tax.id }, limit: MAX_TERMS });
|
|
2612
|
+
return buildTermTree(rows);
|
|
2613
|
+
}, {
|
|
2614
|
+
input: (raw) => ({ taxonomy: assertKey(asObj(raw).taxonomy, "taxonomy slug") }),
|
|
2615
|
+
}),
|
|
2616
|
+
createTerm: mutation(async (ctx, input) => {
|
|
2617
|
+
const db = cdb(ctx);
|
|
2618
|
+
const tax = await taxonomyBySlug(db, input.taxonomy);
|
|
2619
|
+
if (!tax)
|
|
2620
|
+
throw notFound("taxonomy");
|
|
2621
|
+
await assertTermParent(db, tax, input.parentId ?? null, null);
|
|
2622
|
+
const clash = await db.find({ from: "cms_terms", where: { taxonomyId: tax.id, slug: input.slug }, select: ["id"], limit: 1 });
|
|
2623
|
+
if (clash[0])
|
|
2624
|
+
throw new Conflict(`term '${input.slug}' already exists in '${input.taxonomy}'`);
|
|
2625
|
+
return db.insert("cms_terms", {
|
|
2626
|
+
taxonomyId: tax.id,
|
|
2627
|
+
slug: input.slug,
|
|
2628
|
+
label: input.label,
|
|
2629
|
+
description: input.description ?? null,
|
|
2630
|
+
parentId: input.parentId ?? null,
|
|
2631
|
+
position: input.position ?? 0,
|
|
2632
|
+
});
|
|
2633
|
+
}, {
|
|
2634
|
+
...editor,
|
|
2635
|
+
input: (raw) => {
|
|
2636
|
+
const o = asObj(raw);
|
|
2637
|
+
return {
|
|
2638
|
+
taxonomy: assertKey(o.taxonomy, "taxonomy slug"),
|
|
2639
|
+
slug: assertKey(o.slug, "term slug"),
|
|
2640
|
+
label: assertLabel(o.label, "term label"),
|
|
2641
|
+
description: typeof o.description === "string" ? o.description : undefined,
|
|
2642
|
+
parentId: typeof o.parentId === "string" && o.parentId !== "" ? o.parentId : null,
|
|
2643
|
+
position: typeof o.position === "number" ? Math.trunc(o.position) : undefined,
|
|
2644
|
+
};
|
|
2645
|
+
},
|
|
2646
|
+
}),
|
|
2647
|
+
updateTerm: mutation(async (ctx, input) => {
|
|
2648
|
+
const db = cdb(ctx);
|
|
2649
|
+
const rows = await db.find({ from: "cms_terms", where: { id: input.id }, limit: 1 });
|
|
2650
|
+
const row = rows[0];
|
|
2651
|
+
if (!row)
|
|
2652
|
+
throw notFound("term");
|
|
2653
|
+
const taxRows = await db.find({ from: "cms_taxonomies", where: { id: row.taxonomyId }, limit: 1 });
|
|
2654
|
+
const tax = taxRows[0];
|
|
2655
|
+
if (!tax)
|
|
2656
|
+
throw notFound("taxonomy");
|
|
2657
|
+
if (input.parentId !== undefined) {
|
|
2658
|
+
await assertTermParent(db, { id: String(tax.id), hierarchical: Boolean(tax.hierarchical) }, input.parentId, input.id);
|
|
2659
|
+
}
|
|
2660
|
+
// A term's slug IS mutable, unlike a taxonomy's: it is the leaf of a URL, an editor
|
|
2661
|
+
// fixing a typo in one is routine, and the uniqueness that matters is enforced below
|
|
2662
|
+
// and by the composite index behind it.
|
|
2663
|
+
if (input.slug && input.slug !== row.slug) {
|
|
2664
|
+
const clash = await db.find({ from: "cms_terms", where: { taxonomyId: row.taxonomyId, slug: input.slug }, select: ["id"], limit: 1 });
|
|
2665
|
+
if (clash[0])
|
|
2666
|
+
throw new Conflict(`term '${input.slug}' already exists in this vocabulary`);
|
|
2667
|
+
}
|
|
2668
|
+
const patch = {};
|
|
2669
|
+
for (const k of ["slug", "label", "description", "parentId", "position"]) {
|
|
2670
|
+
if (input[k] !== undefined)
|
|
2671
|
+
patch[k] = input[k];
|
|
2672
|
+
}
|
|
2673
|
+
return db.update("cms_terms", input.id, patch);
|
|
2674
|
+
}, {
|
|
2675
|
+
...editor,
|
|
2676
|
+
input: (raw) => {
|
|
2677
|
+
const o = asObj(raw);
|
|
2678
|
+
if (typeof o.id !== "string" || o.id === "")
|
|
2679
|
+
throw new BadRequest("id is required");
|
|
2680
|
+
const out = { id: o.id };
|
|
2681
|
+
if (o.slug !== undefined)
|
|
2682
|
+
out.slug = assertKey(o.slug, "term slug");
|
|
2683
|
+
if (o.label !== undefined)
|
|
2684
|
+
out.label = assertLabel(o.label, "term label");
|
|
2685
|
+
if (o.description !== undefined)
|
|
2686
|
+
out.description = typeof o.description === "string" ? o.description : null;
|
|
2687
|
+
if (o.parentId !== undefined)
|
|
2688
|
+
out.parentId = typeof o.parentId === "string" && o.parentId !== "" ? o.parentId : null;
|
|
2689
|
+
if (typeof o.position === "number")
|
|
2690
|
+
out.position = Math.trunc(o.position);
|
|
2691
|
+
return out;
|
|
2692
|
+
},
|
|
2693
|
+
}),
|
|
2694
|
+
/** Delete a term. Children are promoted to the top level (`ON DELETE SET NULL`) and
|
|
2695
|
+
* page assignments are removed (`ON DELETE CASCADE`) — see `cms_terms.parentId`. */
|
|
2696
|
+
deleteTerm: mutation(async (ctx, input) => {
|
|
2697
|
+
const ok = await cdb(ctx).delete("cms_terms", input.id);
|
|
2698
|
+
if (!ok)
|
|
2699
|
+
throw notFound("term");
|
|
2700
|
+
return { ok: true };
|
|
2701
|
+
}, {
|
|
2702
|
+
...editor,
|
|
2703
|
+
input: (raw) => {
|
|
2704
|
+
const id = asObj(raw).id;
|
|
2705
|
+
if (typeof id !== "string" || id === "")
|
|
2706
|
+
throw new BadRequest("id is required");
|
|
2707
|
+
return { id };
|
|
2708
|
+
},
|
|
2709
|
+
}),
|
|
2710
|
+
/** A page's assigned terms. PUBLIC (a published page's classification is public). */
|
|
2711
|
+
listPageTerms: query(async (ctx, input) => {
|
|
2712
|
+
const db = cdb(ctx);
|
|
2713
|
+
// Read the PAGE first, through `ctx.db`, so the caller's own page scope decides
|
|
2714
|
+
// whether this answers at all. Without it the handler never touched `cms_pages` — and
|
|
2715
|
+
// the public grants on the junction and on terms are unscoped `allow()` — so anyone
|
|
2716
|
+
// holding a page id could read a draft or trashed page's classification, and the
|
|
2717
|
+
// non-empty answer confirmed the page exists. `listPagesByTerm` was already safe for
|
|
2718
|
+
// the opposite reason: it traverses `where: { terms: … }`, so the page scope
|
|
2719
|
+
// AND-merges. This is the same rule, applied from the other end.
|
|
2720
|
+
const page = await db.find({ from: "cms_pages", where: { id: input.pageId }, select: ["id"], limit: 1 });
|
|
2721
|
+
if (!page[0])
|
|
2722
|
+
return [];
|
|
2723
|
+
const links = await db.find({ from: "cms_page_terms", where: { pageId: input.pageId }, select: ["termId"], limit: MAX_TERMS });
|
|
2724
|
+
const ids = links.map((l) => String(l.termId));
|
|
2725
|
+
if (ids.length === 0)
|
|
2726
|
+
return [];
|
|
2727
|
+
const rows = await db.find({ from: "cms_terms", where: { id: { in: ids } }, orderBy: [{ column: "position" }, { column: "label" }], limit: ids.length });
|
|
2728
|
+
return rows;
|
|
2729
|
+
}, {
|
|
2730
|
+
input: (raw) => {
|
|
2731
|
+
const id = asObj(raw).pageId;
|
|
2732
|
+
if (typeof id !== "string" || id === "")
|
|
2733
|
+
throw new BadRequest("pageId is required");
|
|
2734
|
+
return { pageId: id };
|
|
2735
|
+
},
|
|
2736
|
+
}),
|
|
2737
|
+
/** Replace a page's term assignments wholesale.
|
|
2738
|
+
*
|
|
2739
|
+
* Set semantics, not add/remove: the editor's panel holds the whole selection, and two
|
|
2740
|
+
* calls that each patch one end of it race into a state neither asked for. Existing
|
|
2741
|
+
* links that survive are LEFT ALONE rather than deleted and reinserted, so the junction
|
|
2742
|
+
* rows (and any future column on them) are stable across a save that changed nothing. */
|
|
2743
|
+
setPageTerms: mutation(async (ctx, input) => {
|
|
2744
|
+
const db = cdb(ctx);
|
|
2745
|
+
const pages = await db.find({ from: "cms_pages", where: { id: input.pageId }, select: ["id"], limit: 1 });
|
|
2746
|
+
if (!pages[0])
|
|
2747
|
+
throw notFound("page");
|
|
2748
|
+
const wanted = new Set(input.termIds);
|
|
2749
|
+
if (wanted.size > 0) {
|
|
2750
|
+
// Every id must be a real term. Without this the junction happily stores a dangling
|
|
2751
|
+
// uuid — the FK would catch it, but as a driver error with no HTTP status.
|
|
2752
|
+
const found = await db.find({ from: "cms_terms", where: { id: { in: [...wanted] } }, select: ["id"], limit: wanted.size });
|
|
2753
|
+
if (found.length !== wanted.size)
|
|
2754
|
+
throw new BadRequest("one or more termIds are not terms");
|
|
2755
|
+
}
|
|
2756
|
+
const existing = await db.find({ from: "cms_page_terms", where: { pageId: input.pageId }, select: ["id", "termId"], limit: MAX_TERMS });
|
|
2757
|
+
const have = new Map(existing.map((l) => [String(l.termId), String(l.id)]));
|
|
2758
|
+
for (const [termId, linkId] of have)
|
|
2759
|
+
if (!wanted.has(termId))
|
|
2760
|
+
await db.delete("cms_page_terms", linkId);
|
|
2761
|
+
for (const termId of wanted)
|
|
2762
|
+
if (!have.has(termId))
|
|
2763
|
+
await db.insert("cms_page_terms", { pageId: input.pageId, termId });
|
|
2764
|
+
return { ok: true, count: wanted.size };
|
|
2765
|
+
}, {
|
|
2766
|
+
...editor,
|
|
2767
|
+
input: (raw) => {
|
|
2768
|
+
const o = asObj(raw);
|
|
2769
|
+
if (typeof o.pageId !== "string" || o.pageId === "")
|
|
2770
|
+
throw new BadRequest("pageId is required");
|
|
2771
|
+
if (!Array.isArray(o.termIds))
|
|
2772
|
+
throw new BadRequest("termIds must be a list");
|
|
2773
|
+
const ids = o.termIds.map((v) => {
|
|
2774
|
+
if (typeof v !== "string" || v === "")
|
|
2775
|
+
throw new BadRequest("termIds must be a list of ids");
|
|
2776
|
+
return v;
|
|
2777
|
+
});
|
|
2778
|
+
if (ids.length > MAX_TERMS)
|
|
2779
|
+
throw new BadRequest(`a page may carry at most ${MAX_TERMS} terms`);
|
|
2780
|
+
return { pageId: o.pageId, termIds: ids };
|
|
2781
|
+
},
|
|
2782
|
+
}),
|
|
2783
|
+
/** Published pages carrying a term. PUBLIC.
|
|
2784
|
+
*
|
|
2785
|
+
* A relation traversal (`where: { terms: { id } }`), so the page read scope is
|
|
2786
|
+
* AND-merged as it is anywhere else — traversal cannot widen access, and an anonymous
|
|
2787
|
+
* caller sees published pages only. */
|
|
2788
|
+
listPagesByTerm: query(async (ctx, input) => {
|
|
2789
|
+
const db = cdb(ctx);
|
|
2790
|
+
const tax = await taxonomyBySlug(db, input.taxonomy);
|
|
2791
|
+
if (!tax)
|
|
2792
|
+
return [];
|
|
2793
|
+
const terms = await db.find({ from: "cms_terms", where: { taxonomyId: tax.id, slug: input.term }, select: ["id"], limit: 1 });
|
|
2794
|
+
const term = terms[0];
|
|
2795
|
+
if (!term)
|
|
2796
|
+
return [];
|
|
2797
|
+
return db.find({
|
|
2798
|
+
from: "cms_pages",
|
|
2799
|
+
where: { terms: { id: String(term.id) } },
|
|
2800
|
+
orderBy: { column: "createdAt", dir: "desc" },
|
|
2801
|
+
select: ["id", "typeId", "title", "slug", "locale", "status", "publishedAt"],
|
|
2802
|
+
limit: input.limit ?? PAGE_LIST_LIMIT,
|
|
2803
|
+
offset: input.offset ?? 0,
|
|
2804
|
+
});
|
|
2805
|
+
}, {
|
|
2806
|
+
input: (raw) => {
|
|
2807
|
+
const o = asObj(raw);
|
|
2808
|
+
return {
|
|
2809
|
+
taxonomy: assertKey(o.taxonomy, "taxonomy slug"),
|
|
2810
|
+
term: assertKey(o.term, "term slug"),
|
|
2811
|
+
limit: clampLimit(o.limit),
|
|
2812
|
+
offset: typeof o.offset === "number" && o.offset > 0 ? Math.floor(o.offset) : 0,
|
|
2813
|
+
};
|
|
2814
|
+
},
|
|
2815
|
+
}),
|
|
2816
|
+
// ---- site furniture: widget areas ----------------------------------------
|
|
2817
|
+
/** One widget area, with `menu` widgets resolved to their menus. PUBLIC.
|
|
2818
|
+
*
|
|
2819
|
+
* `null` for an unknown name, for the same reason `getMenu` returns null: a layout
|
|
2820
|
+
* asking for a sidebar nobody has filled in yet is the normal state of a site under
|
|
2821
|
+
* construction, and throwing there takes down the page. */
|
|
2822
|
+
getWidgetArea: query(async (ctx, input) => {
|
|
2823
|
+
const db = cdb(ctx);
|
|
2824
|
+
const rows = await db.find({ from: "cms_widget_areas", where: { name: input.name }, limit: 1 });
|
|
2825
|
+
const row = rows[0];
|
|
2826
|
+
if (!row)
|
|
2827
|
+
return null;
|
|
2828
|
+
const widgets = Array.isArray(row.widgets) ? row.widgets : [];
|
|
2829
|
+
// Resolved inline so a layout renders a whole sidebar from ONE call. A menu widget
|
|
2830
|
+
// that names a menu which no longer exists keeps `menu: null` rather than being
|
|
2831
|
+
// dropped — unlike a menu ITEM, an empty widget is a visible hole an editor can see
|
|
2832
|
+
// and fix, where a silently missing one is not.
|
|
2833
|
+
const names = [...new Set(widgets.filter((w) => w.type === "menu" && w.menuName).map((w) => w.menuName))];
|
|
2834
|
+
const menus = new Map();
|
|
2835
|
+
if (names.length > 0) {
|
|
2836
|
+
const found = await db.find({ from: "cms_menus", where: { name: { in: names } }, limit: names.length });
|
|
2837
|
+
// RESOLVED, exactly as `getMenu` returns it. Embedding the raw row here served an
|
|
2838
|
+
// unpublished page's label and id to anonymous callers and handed the layout an
|
|
2839
|
+
// item with no `url` — the two things the resolver exists to prevent, skipped
|
|
2840
|
+
// because this path had its own one-line copy of "read the menu".
|
|
2841
|
+
for (const m of found)
|
|
2842
|
+
menus.set(String(m.name), await resolveMenuRow(db, m));
|
|
2843
|
+
}
|
|
2844
|
+
return {
|
|
2845
|
+
id: String(row.id),
|
|
2846
|
+
name: String(row.name),
|
|
2847
|
+
label: String(row.label),
|
|
2848
|
+
description: row.description == null ? null : String(row.description),
|
|
2849
|
+
widgets: widgets.map((w) => (w.type === "menu" && w.menuName ? { ...w, menu: menus.get(w.menuName) ?? null } : w)),
|
|
2850
|
+
};
|
|
2851
|
+
}, {
|
|
2852
|
+
input: (raw) => ({ name: assertKey(asObj(raw).name, "widget area name") }),
|
|
2853
|
+
}),
|
|
2854
|
+
listWidgetAreas: query((ctx) => cdb(ctx).find({ from: "cms_widget_areas", orderBy: { column: "label" }, limit: PAGE_LIST_MAX_LIMIT }), viewer),
|
|
2855
|
+
createWidgetArea: mutation(async (ctx, input) => {
|
|
2856
|
+
const db = cdb(ctx);
|
|
2857
|
+
const clash = await db.find({ from: "cms_widget_areas", where: { name: input.name }, select: ["id"], limit: 1 });
|
|
2858
|
+
if (clash[0])
|
|
2859
|
+
throw new Conflict(`widget area '${input.name}' already exists`);
|
|
2860
|
+
return db.insert("cms_widget_areas", {
|
|
2861
|
+
name: input.name,
|
|
2862
|
+
label: input.label,
|
|
2863
|
+
description: input.description ?? null,
|
|
2864
|
+
widgets: input.widgets ?? [],
|
|
2865
|
+
});
|
|
2866
|
+
}, {
|
|
2867
|
+
...editor,
|
|
2868
|
+
input: (raw) => {
|
|
2869
|
+
const o = asObj(raw);
|
|
2870
|
+
return {
|
|
2871
|
+
name: assertKey(o.name, "widget area name"),
|
|
2872
|
+
label: assertLabel(o.label, "widget area label"),
|
|
2873
|
+
description: typeof o.description === "string" ? o.description : undefined,
|
|
2874
|
+
widgets: normalizeWidgets(o.widgets, rtSchema),
|
|
2875
|
+
};
|
|
2876
|
+
},
|
|
2877
|
+
}),
|
|
2878
|
+
updateWidgetArea: mutation(async (ctx, input) => {
|
|
2879
|
+
const db = cdb(ctx);
|
|
2880
|
+
const rows = await db.find({ from: "cms_widget_areas", where: input.id ? { id: input.id } : { name: input.name }, limit: 1 });
|
|
2881
|
+
const row = rows[0];
|
|
2882
|
+
if (!row)
|
|
2883
|
+
throw notFound("widget area");
|
|
2884
|
+
const patch = { updatedAt: nowStamp(), version: nextVersion(row, input.expectedVersion, "this widget area") };
|
|
2885
|
+
for (const k of ["label", "description", "widgets"]) {
|
|
2886
|
+
if (input[k] !== undefined)
|
|
2887
|
+
patch[k] = input[k];
|
|
2888
|
+
}
|
|
2889
|
+
return db.update("cms_widget_areas", String(row.id), patch);
|
|
2890
|
+
}, {
|
|
2891
|
+
...editor,
|
|
2892
|
+
input: (raw) => {
|
|
2893
|
+
const o = asObj(raw);
|
|
2894
|
+
const out = {};
|
|
2895
|
+
if (typeof o.id === "string")
|
|
2896
|
+
out.id = o.id;
|
|
2897
|
+
else if (typeof o.name === "string")
|
|
2898
|
+
out.name = assertKey(o.name, "widget area name");
|
|
2899
|
+
else
|
|
2900
|
+
throw new BadRequest("id or name is required");
|
|
2901
|
+
if (o.label !== undefined)
|
|
2902
|
+
out.label = assertLabel(o.label, "widget area label");
|
|
2903
|
+
if (o.description !== undefined)
|
|
2904
|
+
out.description = typeof o.description === "string" ? o.description : null;
|
|
2905
|
+
if (o.widgets !== undefined)
|
|
2906
|
+
out.widgets = normalizeWidgets(o.widgets, rtSchema);
|
|
2907
|
+
if (typeof o.expectedVersion === "number")
|
|
2908
|
+
out.expectedVersion = o.expectedVersion;
|
|
2909
|
+
return out;
|
|
2910
|
+
},
|
|
2911
|
+
}),
|
|
2912
|
+
deleteWidgetArea: mutation(async (ctx, input) => {
|
|
2913
|
+
const ok = await cdb(ctx).delete("cms_widget_areas", input.id);
|
|
2914
|
+
if (!ok)
|
|
2915
|
+
throw notFound("widget area");
|
|
2916
|
+
return { ok: true };
|
|
2917
|
+
}, {
|
|
2918
|
+
...editor,
|
|
2919
|
+
input: (raw) => {
|
|
2920
|
+
const id = asObj(raw).id;
|
|
2921
|
+
if (typeof id !== "string" || id === "")
|
|
2922
|
+
throw new BadRequest("id is required");
|
|
2923
|
+
return { id };
|
|
1172
2924
|
},
|
|
1173
2925
|
}),
|
|
1174
2926
|
// ---- media library ----
|
|
@@ -1652,7 +3404,30 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1652
3404
|
* feature would render N tabs all showing every type's pages under a heading claiming
|
|
1653
3405
|
* otherwise — and "New page" from any of them would stamp that tab's type. Declared, not
|
|
1654
3406
|
* inferred: fail closed on the pooled list rather than open on N lying ones. */
|
|
1655
|
-
listCmsCapabilities: query(() => ({
|
|
3407
|
+
listCmsCapabilities: query((ctx) => ({
|
|
3408
|
+
locales,
|
|
3409
|
+
defaultLocale,
|
|
3410
|
+
multilingual: locales.length > 1,
|
|
3411
|
+
pagesByType: true,
|
|
3412
|
+
// Same kind of declaration as `pagesByType`, for the same reason: an OLDER server has
|
|
3413
|
+
// no menu/redirect/taxonomy/widget handlers at all, and a nav section whose every
|
|
3414
|
+
// screen 404s is worse than one that is absent. Fails closed by being absent there.
|
|
3415
|
+
siteFurniture: true,
|
|
3416
|
+
// Whether `managedBy` means anything on this server. Declared for the same reason as
|
|
3417
|
+
// its neighbours: `@pramen/cms-editor` is a separate package with no dependency on
|
|
3418
|
+
// `@pramen/cms`, so a newer editor CAN run against an older server — where every row
|
|
3419
|
+
// reports no owner, nothing renders read-only, the save succeeds, and it is reverted at
|
|
3420
|
+
// the next cold start. That is GitHub #48 in the deployment that upgraded the editor to
|
|
3421
|
+
// fix it. Absent ⇒ the editor treats no type as code-defined, which is correct there.
|
|
3422
|
+
codeDefinedTypes: true,
|
|
3423
|
+
// PER-CALLER, unlike everything else here. `viewer` is `editorRoles ∪ reviewerRoles`,
|
|
3424
|
+
// so a reviewer-only session reaches this handler and every read handler — but every
|
|
3425
|
+
// WRITE is `editorRoles`. Without this the editor renders the authoring surfaces
|
|
3426
|
+
// (Types, Menus, Redirects, …) for a reviewer and each one 403s on its first save.
|
|
3427
|
+
// The editor cannot work it out for itself: it knows the caller's roles from `me` but
|
|
3428
|
+
// not which roles this deployment configured as `editorRoles`.
|
|
3429
|
+
canEdit: isEditor(ctx, editorRoles),
|
|
3430
|
+
}), viewer),
|
|
1656
3431
|
/** Distinct locales present across all pages. NOTE: a DATA query — what is in the
|
|
1657
3432
|
* store — not configuration. `listCmsCapabilities().locales` is what the deployment
|
|
1658
3433
|
* declares; these two differ while a locale is declared but not yet authored. */
|
|
@@ -2024,10 +3799,11 @@ export function createCmsHandlers(opts = {}) {
|
|
|
2024
3799
|
const secret = previewSecret(ctx.env);
|
|
2025
3800
|
if (!secret)
|
|
2026
3801
|
throw previewUnconfigured(); // fail closed — never mint a forgeable link
|
|
2027
|
-
//
|
|
2028
|
-
//
|
|
2029
|
-
//
|
|
2030
|
-
//
|
|
3802
|
+
// This used to refuse on the D1 store: redemption goes through `ctx.callPrivileged`,
|
|
3803
|
+
// which only forwarded to a DO, so a link minted on D1 would have 404'd forever while
|
|
3804
|
+
// the editor reported success. `callPrivileged` now dispatches locally in the Worker
|
|
3805
|
+
// on D1, so both stores mint. The redeem route is a BROWSER request carrying no
|
|
3806
|
+
// `x-pramen-store`, so a D1 deployment still needs `PRAMEN_STORE=d1` to route it.
|
|
2031
3807
|
const db = cdb(ctx);
|
|
2032
3808
|
// Read the page through the ACL first: minting a link is granting access to it, so a
|
|
2033
3809
|
// caller who cannot read the page must not be able to mint a link that can.
|
|
@@ -2255,7 +4031,12 @@ export function cmsPolicies(opts = {}) {
|
|
|
2255
4031
|
// any app handler, silently overriding the read+create grant `collectionPolicies` emits —
|
|
2256
4032
|
// duplicate policies on the same (role, entity, action) OR-merge, so the wider one wins.
|
|
2257
4033
|
// The collection half owns that table's grant; see `collectionPolicies`.
|
|
2258
|
-
const tables = [
|
|
4034
|
+
const tables = [
|
|
4035
|
+
"cms_content_types", "cms_block_types", "cms_blocks", "cms_pages", "cms_page_blocks", "cms_page_revisions", "cms_media", "cms_audit",
|
|
4036
|
+
// Site furniture. Full CRUD for an editor, like every other cms_ table — the per-handler
|
|
4037
|
+
// `auth` gate is what separates editor from reviewer; this is the row scope.
|
|
4038
|
+
"cms_menus", "cms_redirects", "cms_taxonomies", "cms_terms", "cms_page_terms", "cms_widget_areas",
|
|
4039
|
+
];
|
|
2259
4040
|
// Soft-deleted rows are filtered in the ACL, not in each handler. A read scope is
|
|
2260
4041
|
// AND-merged into every `ctx.db` read, so one policy hides a trashed row from the public
|
|
2261
4042
|
// API, the editor, relation traversals and eager-loads at once — where a per-handler
|
|
@@ -2295,10 +4076,47 @@ export function cmsPolicies(opts = {}) {
|
|
|
2295
4076
|
policy(`${p}:public:revisions:read`, "cms_page_revisions", "read", { where: { page: { status: "published", deletedAt: { isNull: true } } } }),
|
|
2296
4077
|
// Media metadata is public (the bytes are separately gated by signed urls).
|
|
2297
4078
|
policy(`${p}:public:media:read`, "cms_media", "read", { where: { deletedAt: { isNull: true } } }),
|
|
4079
|
+
// --- site furniture ---
|
|
4080
|
+
// A menu is site chrome, rendered on every page. `getMenu` resolves its page
|
|
4081
|
+
// references THROUGH `ctx.db`, so the public page scope above is what decides whether
|
|
4082
|
+
// a link to an unpublished page renders — the menu grant does not widen it.
|
|
4083
|
+
policy(`${p}:public:menus:read`, "cms_menus", "read", allow()),
|
|
4084
|
+
// Only ENABLED redirects. `resolveRedirect` also filters, but the scope is the real
|
|
4085
|
+
// boundary: a disabled redirect is one an editor has deliberately taken out of
|
|
4086
|
+
// service, and it must stay invisible to every anonymous read, not just that one.
|
|
4087
|
+
policy(`${p}:public:redirects:read`, "cms_redirects", "read", { where: { enabled: true } }),
|
|
4088
|
+
// Taxonomies and terms are structural (they are URL segments a front end routes on),
|
|
4089
|
+
// exactly like content-type slugs. The junction is granted too, because
|
|
4090
|
+
// `where: { terms: … }` on a page compiles to a subquery THROUGH it — without the
|
|
4091
|
+
// grant the traversal matches nothing and "pages in this category" is silently empty.
|
|
4092
|
+
policy(`${p}:public:taxonomies:read`, "cms_taxonomies", "read", allow()),
|
|
4093
|
+
policy(`${p}:public:terms:read`, "cms_terms", "read", allow()),
|
|
4094
|
+
policy(`${p}:public:page-terms:read`, "cms_page_terms", "read", allow()),
|
|
4095
|
+
policy(`${p}:public:widget-areas:read`, "cms_widget_areas", "read", allow()),
|
|
2298
4096
|
],
|
|
2299
4097
|
editor: editorPolicies,
|
|
2300
4098
|
};
|
|
2301
4099
|
}
|
|
4100
|
+
// --- collections: edit arbitrary pramen entities in the CMS editor -----------
|
|
4101
|
+
//
|
|
4102
|
+
// The block/page model is one opinionated shape (a routable page with a mandatory slug +
|
|
4103
|
+
// regions of blocks). A COLLECTION is the generic escape hatch: it points the editor at
|
|
4104
|
+
// one of YOUR OWN pramen entities (spread into defineSchema alongside cmsSchema) and
|
|
4105
|
+
// describes how to edit it with the SAME field DSL that blocks use. So "Lectures" is a
|
|
4106
|
+
// first-class, queryable entity — real columns, relations, cell-ACL — that also gets a
|
|
4107
|
+
// list + form UI, without being bent into a cms_pages row.
|
|
4108
|
+
//
|
|
4109
|
+
// Column-mapped: each scalar FieldDefinition.name is a real column on the entity; a
|
|
4110
|
+
// repeater/group/richtext field maps to a t.json() column (the object↔JSON codec at the
|
|
4111
|
+
// Db chokepoint stores it transparently). `richtext` belongs with the latter group — its
|
|
4112
|
+
// value is a document tree, and a TEXT column would bind the object raw and be rejected. The generic handlers dispatch through a registry
|
|
4113
|
+
// keyed by `slug`, so `collection`/`entity` can never be spoofed to reach an arbitrary
|
|
4114
|
+
// table, and writes are whitelisted to declared fields — the client can't set columns the
|
|
4115
|
+
// collection didn't declare (e.g. a `roles` or `passwordHash` column on the entity).
|
|
4116
|
+
/** Nav positions for the editor's built-in sections — see `./nav`. Re-exported here so a
|
|
4117
|
+
* host writing `app.ts` imports it from the same place as `collection()`. It LIVES in a leaf
|
|
4118
|
+
* module because `blockkit.ts` needs it too and must not depend on this one. */
|
|
4119
|
+
export { NAV_ORDER } from "./nav";
|
|
2302
4120
|
/** Declare a collection. Spread the results into `createCollectionHandlers` +
|
|
2303
4121
|
* `collectionPolicies`:
|
|
2304
4122
|
*
|
|
@@ -2322,6 +4140,7 @@ function collectionMeta(c) {
|
|
|
2322
4140
|
label: c.label,
|
|
2323
4141
|
pluralLabel: c.pluralLabel ?? `${c.label}s`,
|
|
2324
4142
|
icon: c.icon,
|
|
4143
|
+
navOrder: c.navOrder ?? NAV_ORDER.collections,
|
|
2325
4144
|
fields: c.fields,
|
|
2326
4145
|
list: c.list ?? [titleField],
|
|
2327
4146
|
titleField,
|
|
@@ -2355,10 +4174,6 @@ export const COLLECTION_PUBLISHED = "published";
|
|
|
2355
4174
|
* (GitHub #22), and `LIMIT -1` is SQLite for "no limit". */
|
|
2356
4175
|
const DEFAULT_COLLECTION_LIST_LIMIT = 100;
|
|
2357
4176
|
const MAX_COLLECTION_LIST_LIMIT = 500;
|
|
2358
|
-
/** An ISO-8601 UTC instant — the one format every managed collection timestamp is written
|
|
2359
|
-
* in, so it compares correctly against `$now()`. See the note above on why this is not
|
|
2360
|
-
* `nowStamp()`. */
|
|
2361
|
-
const isoStamp = () => new Date().toISOString();
|
|
2362
4177
|
/** The epoch-ms range a schedule may name: 1970-01-01 up to (not including) year 10000.
|
|
2363
4178
|
*
|
|
2364
4179
|
* `Number.isFinite` is NOT a sufficient bound, in two directions. Above `8.64e15` (the max
|
|
@@ -2398,9 +4213,21 @@ const COLLECTION_FIELD_COLUMN_TYPES = {
|
|
|
2398
4213
|
slug: ["text"],
|
|
2399
4214
|
media: ["text", "uuid"],
|
|
2400
4215
|
select: ["text"],
|
|
4216
|
+
// A SINGLE reference is one opaque id in a TEXT column. `multiple: true` stores an array
|
|
4217
|
+
// and needs `t.json()` — `referenceColumnTypes` below is what actually decides, because
|
|
4218
|
+
// this table is keyed by type alone and a reference is the one type whose storage depends
|
|
4219
|
+
// on a second flag.
|
|
4220
|
+
reference: ["text", "uuid"],
|
|
2401
4221
|
repeater: ["json"],
|
|
2402
4222
|
group: ["json"],
|
|
2403
4223
|
};
|
|
4224
|
+
/** The column types a field can be stored in, including the one case the type alone does
|
|
4225
|
+
* not settle (`reference` + `multiple`). */
|
|
4226
|
+
function fieldColumnTypes(f) {
|
|
4227
|
+
if (f.type === "reference" && f.multiple)
|
|
4228
|
+
return ["json"];
|
|
4229
|
+
return COLLECTION_FIELD_COLUMN_TYPES[f.type];
|
|
4230
|
+
}
|
|
2404
4231
|
/** Check a collection registry at BOOT: slugs and entities are unique, features are known
|
|
2405
4232
|
* and have their prerequisites, every declared field maps to a column that can hold it, and
|
|
2406
4233
|
* every managed column exists, has the shape the CMS writes, and is not also an editable
|
|
@@ -2485,7 +4312,7 @@ export function validateCollections(collections, schema) {
|
|
|
2485
4312
|
if (!col) {
|
|
2486
4313
|
throw new Error(`pramen/cms: collection '${c.slug}' declares a field '${f.name}', which is not a column on '${c.entity}' — a collection field is column-mapped, so every declared field needs its own column`);
|
|
2487
4314
|
}
|
|
2488
|
-
const allowed =
|
|
4315
|
+
const allowed = fieldColumnTypes(f);
|
|
2489
4316
|
if (allowed && !allowed.includes(col.type)) {
|
|
2490
4317
|
const want = allowed.map((t) => `t.${t === "integer" ? "int" : t === "boolean" ? "bool" : t}()`).join(" or ");
|
|
2491
4318
|
throw new Error(`pramen/cms: collection '${c.slug}' declares '${f.name}' as '${f.type}', which is stored as ${allowed.join("/")}, but '${c.entity}.${f.name}' is ${col.type} — declare it as ${want}`);
|
|
@@ -3093,7 +4920,7 @@ export function createCollectionHandlers(collections, opts = {}) {
|
|
|
3093
4920
|
}),
|
|
3094
4921
|
// ---- preview ------------------------------------------------------------
|
|
3095
4922
|
/** Mint a signed link that shows ONE row's unpublished state, to whoever holds it.
|
|
3096
|
-
* Mirrors `signPagePreview` — same secret, same TTL clamp,
|
|
4923
|
+
* Mirrors `signPagePreview` — same secret, same TTL clamp, works on both stores, and the
|
|
3097
4924
|
* same rule that the row is read through the ACL FIRST: minting a link is granting
|
|
3098
4925
|
* access to the row, so a caller who cannot read it must not be able to mint one. */
|
|
3099
4926
|
signCollectionPreview: query(async (ctx, input) => {
|