@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/src/index.ts
CHANGED
|
@@ -48,10 +48,12 @@ import {
|
|
|
48
48
|
signToken,
|
|
49
49
|
verifyToken,
|
|
50
50
|
resolveSecret,
|
|
51
|
+
authorizeHandler,
|
|
51
52
|
} from "@pramen/server";
|
|
52
53
|
import type { HandlerContext, Policy, FileRef, BootstrapFn, JsonValue, SchemaDef, FieldDef, FieldType } from "@pramen/server";
|
|
53
54
|
import type { EnvBag } from "@pramen/server";
|
|
54
55
|
import { isSafeHref, normalizeHref } from "./href";
|
|
56
|
+
import { NAV_ORDER } from "./nav";
|
|
55
57
|
|
|
56
58
|
// --- field schema DSL (the block-editor field language) ---------------------
|
|
57
59
|
|
|
@@ -109,6 +111,28 @@ export interface FieldDefinition {
|
|
|
109
111
|
| "slug"
|
|
110
112
|
| "media"
|
|
111
113
|
| "select"
|
|
114
|
+
/**
|
|
115
|
+
* A pointer to a record that is NOT this row — another pramen row, or a record in a
|
|
116
|
+
* system the CMS does not own. Stored as an OPAQUE id (a string), so the same field
|
|
117
|
+
* links a `cms_pages` row, a collection row and an external CRM record alike.
|
|
118
|
+
*
|
|
119
|
+
* `optionsFrom` on a `select` is most of this already, and stays the ergonomic case for
|
|
120
|
+
* a short closed list: it fetches `{ value, label }[]` ONCE and renders a `<select>`.
|
|
121
|
+
* What it cannot do is scale — no search term, no paging, and no way to render the
|
|
122
|
+
* label of a value whose record is not in the first (only) page. Twenty campaigns are
|
|
123
|
+
* fine; a thousand records are a dropdown nobody can use and a stored id that renders
|
|
124
|
+
* as a uuid.
|
|
125
|
+
*
|
|
126
|
+
* So a reference declares `referenceFrom`, a query handler called with BOTH shapes:
|
|
127
|
+
*
|
|
128
|
+
* { search?: string; limit: number; offset: number } -> browse / search
|
|
129
|
+
* { ids: string[] } -> resolve stored values
|
|
130
|
+
*
|
|
131
|
+
* and returning `{ items: ReferenceOption[]; hasMore?: boolean }` either way. The
|
|
132
|
+
* second shape is what makes an already-stored value renderable without fetching the
|
|
133
|
+
* whole set — the reason this is a field type and not a wider `select`.
|
|
134
|
+
*/
|
|
135
|
+
| "reference"
|
|
112
136
|
| "repeater"
|
|
113
137
|
| "group";
|
|
114
138
|
required?: boolean;
|
|
@@ -125,6 +149,30 @@ export interface FieldDefinition {
|
|
|
125
149
|
optionsFrom?: string;
|
|
126
150
|
/** slug only — the sibling field this one is derived from (e.g. `"title"`). */
|
|
127
151
|
from?: string;
|
|
152
|
+
/** reference only — the query handler that resolves this reference. See the `reference`
|
|
153
|
+
* type above for the two request shapes it must answer. */
|
|
154
|
+
referenceFrom?: string;
|
|
155
|
+
/** reference only — store a LIST of ids rather than one. A multiple reference is a
|
|
156
|
+
* `t.json()` column (an array), a single one is `t.text()`; `validateCollections`
|
|
157
|
+
* enforces the difference, because storing an array in a TEXT column is a raw driver
|
|
158
|
+
* error on the first write and nothing earlier. */
|
|
159
|
+
multiple?: boolean;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** One option a `reference` field's `referenceFrom` handler returns. `hint` is secondary
|
|
163
|
+
* text (a date, an owner, a status) shown under the label — a picker over a thousand
|
|
164
|
+
* records usually needs more than a name to tell two rows apart. */
|
|
165
|
+
export interface ReferenceOption {
|
|
166
|
+
value: string;
|
|
167
|
+
label: string;
|
|
168
|
+
hint?: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** What a `reference` field's `referenceFrom` handler returns, for BOTH request shapes.
|
|
172
|
+
* `hasMore` drives the picker's "Load more"; omitting it means "this is everything". */
|
|
173
|
+
export interface ReferenceResult {
|
|
174
|
+
items: ReferenceOption[];
|
|
175
|
+
hasMore?: boolean;
|
|
128
176
|
}
|
|
129
177
|
|
|
130
178
|
/** A named region on a content type; `allowedTypes` (block-type slugs) restricts what
|
|
@@ -195,6 +243,8 @@ export type FieldTsType<D extends FieldDefinition> = D["type"] extends "text" |
|
|
|
195
243
|
? boolean
|
|
196
244
|
: D["type"] extends "media"
|
|
197
245
|
? ResolvedMedia | null
|
|
246
|
+
: D["type"] extends "reference"
|
|
247
|
+
? (D extends { multiple: true } ? string[] : string)
|
|
198
248
|
: D["type"] extends "group"
|
|
199
249
|
? InferBlockFields<NonNullable<D["fields"]>>
|
|
200
250
|
: D["type"] extends "repeater"
|
|
@@ -234,6 +284,15 @@ export function defineBlockType<S extends string, F extends readonly FieldDefini
|
|
|
234
284
|
fields: F,
|
|
235
285
|
opts: { name?: string; description?: string; icon?: string; category?: string } = {},
|
|
236
286
|
): BlockTypeDef<S, F> {
|
|
287
|
+
// A PURE constructor: it returns exactly what it was given, so `fieldsSchema` really is
|
|
288
|
+
// `F` and `BlockFieldsOf<typeof def>` describes the array that will be stored. Validation
|
|
289
|
+
// deliberately does NOT live here — it lives in `cmsBootstrap`, the thing that writes.
|
|
290
|
+
// `BlockTypeDef` is a structural interface, so an object literal, a `.map` or a codegen
|
|
291
|
+
// step reaches the store without passing through this function at all; checking here would
|
|
292
|
+
// have guarded the convenient path and left the sink open. Canonicalizing here was worse
|
|
293
|
+
// still: `validateFieldSchema` REBUILDS every entry (trimming names, dropping type-inert
|
|
294
|
+
// keys), so the returned array stopped matching the const literal `F` is inferred from and
|
|
295
|
+
// the cast became a lie a component would follow into `fields[" title "] === undefined`.
|
|
237
296
|
return { slug, name: opts.name ?? slug, fieldsSchema: fields, description: opts.description, icon: opts.icon, category: opts.category };
|
|
238
297
|
}
|
|
239
298
|
|
|
@@ -278,6 +337,7 @@ export function defineContentType(
|
|
|
278
337
|
defaultBlocks?: readonly DefaultBlockDefinition[];
|
|
279
338
|
},
|
|
280
339
|
): ContentTypeDef {
|
|
340
|
+
// Pure, for the same reason as `defineBlockType` — `cmsBootstrap` validates.
|
|
281
341
|
return { slug, name: opts.name ?? slug, description: opts.description, fields: opts.fields, regions: opts.regions, defaultBlocks: opts.defaultBlocks };
|
|
282
342
|
}
|
|
283
343
|
|
|
@@ -285,27 +345,141 @@ export function defineContentType(
|
|
|
285
345
|
* Db (ACL bypassed), so these calls are unrestricted; kept loose to avoid threading the
|
|
286
346
|
* host app's schema generic through a library helper. */
|
|
287
347
|
interface ReconcileDb {
|
|
288
|
-
find(q: { from: string; where?: Record<string, unknown>; limit?: number }): Promise<Record<string, unknown>[]>;
|
|
348
|
+
find(q: { from: string; where?: Record<string, unknown>; limit?: number; select?: readonly string[] }): Promise<Record<string, unknown>[]>;
|
|
289
349
|
insert(table: string, values: Record<string, unknown>): Promise<unknown>;
|
|
290
350
|
update(table: string, id: string, patch: Record<string, unknown>): Promise<unknown>;
|
|
291
351
|
}
|
|
292
352
|
|
|
293
353
|
const sameJson = (a: unknown, b: unknown): boolean => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
294
354
|
|
|
355
|
+
/** The default `owner` — see {@link cmsBootstrap}. */
|
|
356
|
+
export const CMS_BOOTSTRAP_OWNER = "cms";
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Validate + canonicalize what a `cmsBootstrap` will write, at FACTORY-call time.
|
|
360
|
+
*
|
|
361
|
+
* This lives here rather than in `defineBlockType`/`defineContentType` because those are
|
|
362
|
+
* optional conveniences: `BlockTypeDef`/`ContentTypeDef` are exported STRUCTURAL interfaces,
|
|
363
|
+
* so an object literal, a `.map` over a config file or a codegen step reaches `upsertBySlug`
|
|
364
|
+
* without passing through either helper. Checking in the helper guarded the convenient path
|
|
365
|
+
* and left the sink open — and the row it wrote was then locked `managedBy`, so an invalid
|
|
366
|
+
* schema could never be repaired through the product. `cmsBootstrap(defs)` is the one call
|
|
367
|
+
* every code-defined type goes through.
|
|
368
|
+
*
|
|
369
|
+
* Held to the SAME rules the editor's `createBlockType`/`createContentType` enforce
|
|
370
|
+
* (`normalizeFieldSchema`, `normalizeRegions`, `normalizeDefaultBlocks`), because the
|
|
371
|
+
* alternative is a code-declared type storing a schema the builder then refuses to save —
|
|
372
|
+
* the only surface reporting the problem being the one that cannot fix it.
|
|
373
|
+
*
|
|
374
|
+
* EVERY problem is reported together, not just the first: this throws at app construction
|
|
375
|
+
* (`app.ts` module scope, like `validateCollections` in `createCollectionHandlers` and
|
|
376
|
+
* `validateMigrations` in `createPramen`), where fixing them one deploy at a time is the
|
|
377
|
+
* difference between one round trip and six.
|
|
378
|
+
*/
|
|
379
|
+
function validateCmsDefinitions(
|
|
380
|
+
defs: { blockTypes?: readonly BlockTypeDef[]; contentTypes?: readonly ContentTypeDef[] },
|
|
381
|
+
): { blockTypes: Record<string, unknown>[]; contentTypes: Record<string, unknown>[] } {
|
|
382
|
+
const problems: string[] = [];
|
|
383
|
+
const at = (what: string, slug: unknown, e: unknown): void => {
|
|
384
|
+
problems.push(` ${what} ${JSON.stringify(slug)}: ${e instanceof Error ? e.message : String(e)}`);
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const blockTypes: Record<string, unknown>[] = [];
|
|
388
|
+
const btSeen = new Set<string>();
|
|
389
|
+
for (const bt of defs.blockTypes ?? []) {
|
|
390
|
+
try {
|
|
391
|
+
const slug = assertRegistryKey(bt.slug, "block type slug");
|
|
392
|
+
// Last-wins on a repeated slug is how two feature modules both exporting a `cta` block
|
|
393
|
+
// type converge to whichever import order won, with nothing said about it.
|
|
394
|
+
if (btSeen.has(slug)) throw new BadRequest(`declared twice — the second declaration would silently overwrite the first`);
|
|
395
|
+
btSeen.add(slug);
|
|
396
|
+
blockTypes.push({
|
|
397
|
+
name: assertLabel(bt.name ?? slug, "block type name"),
|
|
398
|
+
slug,
|
|
399
|
+
description: bt.description ?? null,
|
|
400
|
+
fieldsSchema: normalizeFieldSchema(bt.fieldsSchema, "fieldsSchema"),
|
|
401
|
+
icon: bt.icon ?? null,
|
|
402
|
+
category: bt.category ?? null,
|
|
403
|
+
});
|
|
404
|
+
} catch (e) {
|
|
405
|
+
at("block type", bt.slug, e);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const contentTypes: Record<string, unknown>[] = [];
|
|
410
|
+
const ctSeen = new Set<string>();
|
|
411
|
+
for (const ct of defs.contentTypes ?? []) {
|
|
412
|
+
try {
|
|
413
|
+
// The slug FIRST: it is what every other message names, and validating regions ahead
|
|
414
|
+
// of it produced an error that never mentioned the type whose slug was also wrong —
|
|
415
|
+
// fix the regions, redeploy, meet the second failure.
|
|
416
|
+
const slug = assertRegistryKey(ct.slug, "content type slug");
|
|
417
|
+
if (ctSeen.has(slug)) throw new BadRequest(`declared twice — the second declaration would silently overwrite the first`);
|
|
418
|
+
ctSeen.add(slug);
|
|
419
|
+
const regions = normalizeRegions(ct.regions);
|
|
420
|
+
contentTypes.push({
|
|
421
|
+
name: assertLabel(ct.name ?? slug, "content type name"),
|
|
422
|
+
slug,
|
|
423
|
+
description: ct.description ?? null,
|
|
424
|
+
fieldsSchema: normalizeFieldSchema(ct.fields, "fields"),
|
|
425
|
+
regions,
|
|
426
|
+
defaultBlocks: normalizeDefaultBlocks(ct.defaultBlocks, regions),
|
|
427
|
+
});
|
|
428
|
+
} catch (e) {
|
|
429
|
+
at("content type", ct.slug, e);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (problems.length > 0) {
|
|
434
|
+
throw new Error(`cmsBootstrap: ${problems.length} invalid type definition(s)\n${problems.join("\n")}`);
|
|
435
|
+
}
|
|
436
|
+
return { blockTypes, contentTypes };
|
|
437
|
+
}
|
|
438
|
+
|
|
295
439
|
/** Insert `values` if no row has this `slug`, else patch only the columns that drifted
|
|
296
|
-
* (never `id`/`slug`/`createdAt`). Idempotent — an identical definition is a no-op.
|
|
297
|
-
|
|
440
|
+
* (never `id`/`slug`/`createdAt`). Idempotent — an identical definition is a no-op.
|
|
441
|
+
*
|
|
442
|
+
* Returns false, having written NOTHING, when the existing row belongs to someone else —
|
|
443
|
+
* an editor-authored type (`managedBy` null) or another reconciler's. Adopting it was a
|
|
444
|
+
* silent takeover: name and schema replaced by the code literal, and then the row locked, so
|
|
445
|
+
* the editor could not even put back what it had just lost. `createBlockType` refuses this
|
|
446
|
+
* exact slug collision at the RPC edge; the reconciler used to win it without a word. */
|
|
447
|
+
async function upsertBySlug(db: ReconcileDb, table: string, owner: string, values: Record<string, unknown>): Promise<boolean> {
|
|
448
|
+
const slug = String(values.slug);
|
|
298
449
|
const existing = (await db.find({ from: table, where: { slug }, limit: 1 }))[0];
|
|
299
450
|
if (!existing) {
|
|
300
|
-
await db.insert(table, values);
|
|
301
|
-
return;
|
|
451
|
+
await db.insert(table, { ...values, managedBy: owner });
|
|
452
|
+
return true;
|
|
302
453
|
}
|
|
454
|
+
if (existing.managedBy !== owner) return false;
|
|
303
455
|
const patch: Record<string, unknown> = {};
|
|
304
456
|
for (const [k, v] of Object.entries(values)) {
|
|
305
457
|
if (k === "slug") continue;
|
|
306
458
|
if (!sameJson(existing[k], v)) patch[k] = v;
|
|
307
459
|
}
|
|
308
460
|
if (Object.keys(patch).length) await db.update(table, String(existing.id), patch);
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Release the rows THIS owner wrote and no longer declares.
|
|
465
|
+
*
|
|
466
|
+
* A type dropped from the repo keeps its row — pages are still built out of it — but nothing
|
|
467
|
+
* converges it any more, so leaving it read-only in the builder would be a lock with nothing
|
|
468
|
+
* behind it, next to a note pointing at code that no longer mentions it.
|
|
469
|
+
*
|
|
470
|
+
* Scoped to `managedBy = owner`, which is what makes `cmsBootstrap` composable: a sweep
|
|
471
|
+
* cannot otherwise tell "not mine" from "no longer declared", so two reconcilers in one
|
|
472
|
+
* `app.bootstrap` released each other's rows on every boot and half the types silently fell
|
|
473
|
+
* back to editable. A table the call says nothing about (`blockTypes` absent — the KEY, not
|
|
474
|
+
* an empty array) is left alone rather than swept. */
|
|
475
|
+
async function releaseUndeclared(db: ReconcileDb, table: string, owner: string, declared: ReadonlySet<string>): Promise<void> {
|
|
476
|
+
// `select` because this runs on the boot critical path — inside `blockConcurrencyWhile` on
|
|
477
|
+
// a DO's first fetch, and at every isolate init on D1 where each statement is a round trip.
|
|
478
|
+
// Without it the read pulls and JSON-parses every row's whole field schema to look at two
|
|
479
|
+
// columns (the GitHub #22 shape).
|
|
480
|
+
for (const row of await db.find({ from: table, where: { managedBy: owner }, select: ["id", "slug"] })) {
|
|
481
|
+
if (!declared.has(String(row.slug))) await db.update(table, String(row.id), { managedBy: null });
|
|
482
|
+
}
|
|
309
483
|
}
|
|
310
484
|
|
|
311
485
|
/** Build a pramen `bootstrap` reconciler that upserts code-defined block + content types by
|
|
@@ -316,30 +490,51 @@ async function upsertBySlug(db: ReconcileDb, table: string, slug: string, values
|
|
|
316
490
|
* bootstrap: [ cmsBootstrap({ blockTypes: [...], contentTypes: [...] }) ] };
|
|
317
491
|
*
|
|
318
492
|
* Runs with a privileged system Db, so a fresh/reprovisioned database converges to the
|
|
319
|
-
* code-declared types with no manual createContentType/createBlockType call.
|
|
320
|
-
|
|
493
|
+
* code-declared types with no manual createContentType/createBlockType call.
|
|
494
|
+
*
|
|
495
|
+
* Every row it writes is stamped `managedBy: owner`, which makes the editor show it
|
|
496
|
+
* read-only — convergence and an editor pointed at the same rows are otherwise a silent
|
|
497
|
+
* data-loss pair (GitHub #48). A type that drops out of the declaration is released back to
|
|
498
|
+
* the editor; a row this owner did not write is never touched, so a second reconciler (a
|
|
499
|
+
* package shipping its own block types, say) composes as long as it passes its own `owner`.
|
|
500
|
+
*
|
|
501
|
+
* The definitions are validated HERE, when the app is constructed, and every problem is
|
|
502
|
+
* reported at once — see {@link validateCmsDefinitions}. */
|
|
503
|
+
export function cmsBootstrap(
|
|
504
|
+
defs: { blockTypes?: readonly BlockTypeDef[]; contentTypes?: readonly ContentTypeDef[] },
|
|
505
|
+
opts: { owner?: string } = {},
|
|
506
|
+
): BootstrapFn {
|
|
507
|
+
const owner = opts.owner ?? CMS_BOOTSTRAP_OWNER;
|
|
508
|
+
const { blockTypes, contentTypes } = validateCmsDefinitions(defs);
|
|
509
|
+
// Presence of the KEY, not truthiness of the array: `blockTypes: []` is "I declare none",
|
|
510
|
+
// which must sweep, while an absent key is "I say nothing about block types", which must
|
|
511
|
+
// not. Truthiness read `[]` as the latter four lines after `?? []` read it as the former,
|
|
512
|
+
// and the difference was invisible at the call site — `features.flatMap(f => f.blockTypes)`
|
|
513
|
+
// on an empty list silently unlocked every code-defined type in every tenant.
|
|
514
|
+
const sweepBlockTypes = "blockTypes" in defs;
|
|
515
|
+
const sweepContentTypes = "contentTypes" in defs;
|
|
516
|
+
|
|
321
517
|
return async ({ db }) => {
|
|
322
518
|
const sys = db as unknown as ReconcileDb;
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}
|
|
519
|
+
// Per-definition, so one failure does not skip every later type AND both sweeps. The
|
|
520
|
+
// boot runner only logs a throwing reconciler, so an aborted pass leaves the store half
|
|
521
|
+
// converged for that isolate's whole lifetime with nothing to retry it. A UNIQUE
|
|
522
|
+
// violation is the expected instance: `app.bootstrap` has no lease, and on D1 two cold
|
|
523
|
+
// isolates can both find a slug missing and both insert it.
|
|
524
|
+
const reconcile = async (table: string, values: Record<string, unknown>): Promise<void> => {
|
|
525
|
+
try {
|
|
526
|
+
if (!(await upsertBySlug(sys, table, owner, values))) {
|
|
527
|
+
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)`);
|
|
528
|
+
}
|
|
529
|
+
} catch (e) {
|
|
530
|
+
console.error(`@pramen/cms: failed to reconcile ${table}.${String(values.slug)}:`, e);
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
for (const bt of blockTypes) await reconcile("cms_block_types", bt);
|
|
535
|
+
if (sweepBlockTypes) await releaseUndeclared(sys, "cms_block_types", owner, new Set(blockTypes.map((bt) => String(bt.slug))));
|
|
536
|
+
for (const ct of contentTypes) await reconcile("cms_content_types", ct);
|
|
537
|
+
if (sweepContentTypes) await releaseUndeclared(sys, "cms_content_types", owner, new Set(contentTypes.map((ct) => String(ct.slug))));
|
|
343
538
|
};
|
|
344
539
|
}
|
|
345
540
|
|
|
@@ -371,6 +566,9 @@ function tsTypeOf(f: FieldDefinition): string {
|
|
|
371
566
|
return "boolean";
|
|
372
567
|
case "media":
|
|
373
568
|
return "ResolvedMedia | null";
|
|
569
|
+
// An opaque id — the record it points at may not be ours to type.
|
|
570
|
+
case "reference":
|
|
571
|
+
return f.multiple ? "string[]" : "string";
|
|
374
572
|
case "group":
|
|
375
573
|
return `{ ${(f.fields ?? []).map(tsFieldLine).join(" ")} }`;
|
|
376
574
|
case "repeater":
|
|
@@ -442,6 +640,123 @@ export function generateBlockTypes(blockTypes: Array<{ slug: string; fieldsSchem
|
|
|
442
640
|
|
|
443
641
|
/** The block/page builder tables. All in the default partition (relations can't cross
|
|
444
642
|
* partitions). Prefixed `cms_` to avoid colliding with your own entities. */
|
|
643
|
+
// --- site furniture: the shapes ----------------------------------------------
|
|
644
|
+
//
|
|
645
|
+
// Menus, taxonomies and widget areas are the site-level furniture a client expects to edit
|
|
646
|
+
// without a deploy. They are deliberately NOT modelled on pages: none of them has a slug, a
|
|
647
|
+
// status, a revision or a workflow, and bending them into `cms_pages` would put a null
|
|
648
|
+
// branch through every page read to serve something that shares no field with a page.
|
|
649
|
+
|
|
650
|
+
/** What a menu item points at.
|
|
651
|
+
*
|
|
652
|
+
* `custom` is a literal href. The rest are REFERENCES resolved at read time, which is the
|
|
653
|
+
* whole reason the kind exists: a menu that stored `/about` verbatim breaks silently the
|
|
654
|
+
* day the page's slug changes, and the redirect that covers it is a second thing to
|
|
655
|
+
* remember. A `page` item follows the page. */
|
|
656
|
+
export type MenuItemKind = "custom" | "page" | "term" | "collection";
|
|
657
|
+
|
|
658
|
+
/** One entry in a menu tree. */
|
|
659
|
+
export interface MenuItem {
|
|
660
|
+
/** Stable within the menu — the editor's list key and the only handle a reorder has. */
|
|
661
|
+
id: string;
|
|
662
|
+
label: string;
|
|
663
|
+
/** Defaults to `"custom"` (a literal `url`). */
|
|
664
|
+
kind?: MenuItemKind;
|
|
665
|
+
/** `page`: a `cms_pages` id · `term`: a `cms_terms` id · `collection`: a collection slug. */
|
|
666
|
+
ref?: string | null;
|
|
667
|
+
/** `custom`: the href, stored verbatim (and `isSafeHref`-checked on write). For the
|
|
668
|
+
* resolved kinds this is FILLED IN by `getMenu` and ignored on write. */
|
|
669
|
+
url?: string;
|
|
670
|
+
target?: string;
|
|
671
|
+
titleAttr?: string;
|
|
672
|
+
cssClasses?: string;
|
|
673
|
+
children?: MenuItem[];
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** A named navigation menu. Read whole with `getMenu(name)`. */
|
|
677
|
+
export interface Menu {
|
|
678
|
+
id: string;
|
|
679
|
+
name: string;
|
|
680
|
+
label: string;
|
|
681
|
+
items: MenuItem[];
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** How deep a menu tree may nest. Menus are stored as one document, so without a cap a
|
|
685
|
+
* client could post a tree deep enough to blow the stack in the resolver — and no real
|
|
686
|
+
* navigation is more than three levels anyway. */
|
|
687
|
+
export const MAX_MENU_DEPTH = 5;
|
|
688
|
+
|
|
689
|
+
/** How many items one menu may hold, at every level combined.
|
|
690
|
+
*
|
|
691
|
+
* Depth alone is not a bound: a FLAT list of 800 `page` items is legal under
|
|
692
|
+
* `MAX_MENU_DEPTH` and turns every anonymous `getMenu` — the read on every page render of
|
|
693
|
+
* the site — into a single `WHERE id IN (?×800)`, which the read engine emits with no
|
|
694
|
+
* chunking. Every other read added alongside this one is bounded (`MAX_TERMS`,
|
|
695
|
+
* `clampLimit`); this one was not, and it is the one on the hot path. */
|
|
696
|
+
export const MAX_MENU_ITEMS = 200;
|
|
697
|
+
|
|
698
|
+
/** A classification vocabulary — `category`, `tag`, `region`, whatever the site sorts by. */
|
|
699
|
+
export interface Taxonomy {
|
|
700
|
+
id: string;
|
|
701
|
+
slug: string;
|
|
702
|
+
label: string;
|
|
703
|
+
pluralLabel?: string | null;
|
|
704
|
+
description?: string | null;
|
|
705
|
+
/** Terms may declare a `parentId`. A flat vocabulary REJECTS one on write, rather than
|
|
706
|
+
* accepting it and rendering it nowhere. */
|
|
707
|
+
hierarchical: boolean;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** One term in a vocabulary. `children` is present only on the tree read (`getTermTree`). */
|
|
711
|
+
export interface Term {
|
|
712
|
+
id: string;
|
|
713
|
+
taxonomyId: string;
|
|
714
|
+
slug: string;
|
|
715
|
+
label: string;
|
|
716
|
+
description?: string | null;
|
|
717
|
+
parentId?: string | null;
|
|
718
|
+
position: number;
|
|
719
|
+
children?: Term[];
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** How deep a term hierarchy may nest — the same argument as {@link MAX_MENU_DEPTH}, except
|
|
723
|
+
* here the tree is rows and the risk is a parent CYCLE, which `assertTermParent` refuses. */
|
|
724
|
+
export const MAX_TERM_DEPTH = 5;
|
|
725
|
+
|
|
726
|
+
/** What a widget renders. `component` is the escape hatch: the CMS stores an id + props and
|
|
727
|
+
* the front end maps the id to one of its own components, exactly as `BlockRenderer` maps a
|
|
728
|
+
* block type slug — so a widget area can hold something the CMS has no idea how to draw. */
|
|
729
|
+
export type WidgetType = "content" | "menu" | "component";
|
|
730
|
+
|
|
731
|
+
/** One widget in a widget area. */
|
|
732
|
+
export interface Widget {
|
|
733
|
+
id: string;
|
|
734
|
+
type: WidgetType;
|
|
735
|
+
title?: string | null;
|
|
736
|
+
/** `content`: a rich-text document (normalized on write like any other richtext value). */
|
|
737
|
+
content?: RichTextDoc;
|
|
738
|
+
/** `menu`: a `cms_menus.name`. `getWidgetArea` resolves it to `menu` alongside. */
|
|
739
|
+
menuName?: string;
|
|
740
|
+
/** `component`: the front end's own component id + its props. */
|
|
741
|
+
componentId?: string;
|
|
742
|
+
componentProps?: FieldValues;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** A widget as READ back: a `menu` widget carries its resolved menu, so a layout renders a
|
|
746
|
+
* whole sidebar from one call rather than one call per widget. */
|
|
747
|
+
export interface ResolvedWidget extends Widget {
|
|
748
|
+
menu?: Menu | null;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** A named template region an admin fills without touching code. */
|
|
752
|
+
export interface WidgetArea {
|
|
753
|
+
id: string;
|
|
754
|
+
name: string;
|
|
755
|
+
label: string;
|
|
756
|
+
description?: string | null;
|
|
757
|
+
widgets: ResolvedWidget[];
|
|
758
|
+
}
|
|
759
|
+
|
|
445
760
|
export const cmsSchema = {
|
|
446
761
|
cms_content_types: Entity((t) => ({
|
|
447
762
|
id: primaryKey(generated(t.uuid())),
|
|
@@ -451,6 +766,8 @@ export const cmsSchema = {
|
|
|
451
766
|
fieldsSchema: t.json(), // FieldDefinition[] for page-level fields
|
|
452
767
|
regions: t.json(), // RegionDefinition[]
|
|
453
768
|
defaultBlocks: t.json(), // DefaultBlockDefinition[]
|
|
769
|
+
// Which reconciler owns this row. See cms_block_types.managedBy.
|
|
770
|
+
managedBy: t.text(),
|
|
454
771
|
createdAt: defaultTo(t.text(), expr.now()),
|
|
455
772
|
})),
|
|
456
773
|
|
|
@@ -462,6 +779,21 @@ export const cmsSchema = {
|
|
|
462
779
|
fieldsSchema: t.json(), // FieldDefinition[]
|
|
463
780
|
icon: t.text(),
|
|
464
781
|
category: t.text(),
|
|
782
|
+
// NON-NULL while this row is CODE-DEFINED, holding the OWNER id of the `cmsBootstrap`
|
|
783
|
+
// that declares it. The editor authors these rows too (GitHub #9), and the two were
|
|
784
|
+
// otherwise indistinguishable: an editor would add a field, get a 200, and lose it
|
|
785
|
+
// silently at the next cold start when `upsertBySlug` patched the column back to the
|
|
786
|
+
// literal in `app.ts`. So it is set by the reconciler, refused by `updateBlockType` /
|
|
787
|
+
// `updateContentType`, and rendered read-only in the builder. Cleared again when the
|
|
788
|
+
// definition leaves the repo — a lock with nothing behind it is worse than no lock.
|
|
789
|
+
//
|
|
790
|
+
// An OWNER id rather than a boolean because `app.bootstrap` is a composable array. With
|
|
791
|
+
// a flag, two `cmsBootstrap` calls each released the other's rows on every boot: the
|
|
792
|
+
// sweep cannot tell "this row is not mine" from "this row is no longer declared", so
|
|
793
|
+
// half the types silently fell back to editable and #48 came straight back for them. A
|
|
794
|
+
// reconciler now only releases what IT wrote, which is also what lets a package ship its
|
|
795
|
+
// own block types beside the app's.
|
|
796
|
+
managedBy: t.text(),
|
|
465
797
|
createdAt: defaultTo(t.text(), expr.now()),
|
|
466
798
|
})),
|
|
467
799
|
|
|
@@ -508,7 +840,8 @@ export const cmsSchema = {
|
|
|
508
840
|
unpublishAt: t.text(),
|
|
509
841
|
// The revision the public content API serves — set on publish. A direct pointer
|
|
510
842
|
// (not "latest by timestamp") so selection is deterministic even when two publishes
|
|
511
|
-
// land in the same
|
|
843
|
+
// land in the same instant. `expr.now()` carries milliseconds now, which narrows the
|
|
844
|
+
// window without closing it — a pointer has no window at all.
|
|
512
845
|
currentRevisionId: t.uuid(),
|
|
513
846
|
// Soft delete: the epoch-ISO instant the page was trashed, NULL while it is live.
|
|
514
847
|
// Every read scope AND-merges `deletedAt IS NULL` (see cmsPolicies), so a trashed
|
|
@@ -532,6 +865,10 @@ export const cmsSchema = {
|
|
|
532
865
|
(r) => ({
|
|
533
866
|
type: r.belongsTo("cms_content_types", "typeId"),
|
|
534
867
|
placements: r.hasMany("cms_page_blocks", "pageId"),
|
|
868
|
+
// Taxonomy terms, through the explicit junction. `where: { terms: { slug: "news" } }`
|
|
869
|
+
// compiles to a nested subquery, so "pages in this category" is an ordinary query and
|
|
870
|
+
// not a second handler.
|
|
871
|
+
terms: r.manyToMany("cms_terms", { through: "cms_page_terms", sourceColumn: "pageId", targetColumn: "termId" }),
|
|
535
872
|
}),
|
|
536
873
|
{ unique: [["slug", "locale"]] }, // a slug is unique per locale (DB-enforced)
|
|
537
874
|
),
|
|
@@ -597,11 +934,11 @@ export const cmsSchema = {
|
|
|
597
934
|
collection: indexed(notNull(t.text())),
|
|
598
935
|
rowId: indexed(notNull(t.text())),
|
|
599
936
|
// A monotonic per-row counter, and the ONLY ordering key. Timestamps cannot do this
|
|
600
|
-
// job
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
937
|
+
// job even at millisecond resolution: a collection revision is written on EVERY edit,
|
|
938
|
+
// and two writes land in the same millisecond often enough to be reproducible. Ordering
|
|
939
|
+
// then falls to a uuid tiebreak, which is deterministic but NOT insertion order — so
|
|
940
|
+
// "restore the previous version" could pick the wrong snapshot. (`expr.now()` was also
|
|
941
|
+
// second-resolution when this was written, which made the same point louder.)
|
|
605
942
|
//
|
|
606
943
|
// The read-then-increment in `snapshotRow` is serialized by the DO's single writer. On
|
|
607
944
|
// the D1 store it is NOT — `D1Driver.transaction` is a no-op (D1 has no interactive
|
|
@@ -612,14 +949,147 @@ export const cmsSchema = {
|
|
|
612
949
|
snapshot: t.json(),
|
|
613
950
|
note: t.text(),
|
|
614
951
|
actor: t.text(),
|
|
615
|
-
// NO expr.now() default
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
// would make "the previous version" an arbitrary pick between them.
|
|
952
|
+
// NO expr.now() default: `snapshotRow` is the only writer and stamps it. That is now a
|
|
953
|
+
// consistency choice rather than a precision one — `expr.now()` carries milliseconds
|
|
954
|
+
// too — but `revision` above is what actually orders these rows, and a column no
|
|
955
|
+
// writer but `snapshotRow` touches cannot drift from it.
|
|
620
956
|
createdAt: t.text(),
|
|
621
957
|
}), undefined, { unique: [["collection", "rowId", "revision"]] }),
|
|
622
958
|
|
|
959
|
+
// --- site furniture: menus, redirects, taxonomies, widget areas ------------------
|
|
960
|
+
//
|
|
961
|
+
// The WordPress-parity furniture every client project reinvents by hand. All four are
|
|
962
|
+
// SITE-level, not page-level: they exist once per deployment and are read by the layout,
|
|
963
|
+
// not by a page's regions.
|
|
964
|
+
|
|
965
|
+
// A named navigation menu. `items` is a nested `MenuItem[]` document rather than a rows
|
|
966
|
+
// table, because a menu is edited and read WHOLE — every read is `getMenu("primary")`,
|
|
967
|
+
// and every write is "here is the new tree". Rows would buy per-item queries nobody makes
|
|
968
|
+
// and cost a recursive assemble on the one read that matters. The tree is depth-capped on
|
|
969
|
+
// write (`MAX_MENU_DEPTH`), which is the constraint a rows table would have got for free.
|
|
970
|
+
cms_menus: Entity((t) => ({
|
|
971
|
+
id: primaryKey(generated(t.uuid())),
|
|
972
|
+
// The key `getMenu(name)` resolves — stable, referenced from layout code, and so NOT
|
|
973
|
+
// renameable through `updateMenu` (the label is what an editor retitles).
|
|
974
|
+
name: unique(notNull(t.text())),
|
|
975
|
+
label: notNull(t.text()),
|
|
976
|
+
items: t.json(), // MenuItem[]
|
|
977
|
+
// Optimistic concurrency, as on cms_pages/cms_blocks. It matters MORE here, not less:
|
|
978
|
+
// `updateMenu` writes the whole `items` document, so two editors on one menu meant the
|
|
979
|
+
// second silently replaced the first's entire tree — where a page edit at least
|
|
980
|
+
// conflicts per field.
|
|
981
|
+
version: defaultTo(t.int(), 1),
|
|
982
|
+
createdAt: defaultTo(t.text(), expr.now()),
|
|
983
|
+
updatedAt: defaultTo(t.text(), expr.now()),
|
|
984
|
+
})),
|
|
985
|
+
|
|
986
|
+
// A URL redirect. Needed the moment a slug changes on a live site — which the `slug`
|
|
987
|
+
// field's own docs already flag ("silently rewriting a slug changes a live URL and breaks
|
|
988
|
+
// every link to it").
|
|
989
|
+
//
|
|
990
|
+
// `fromPath`/`toPath`, not `from`/`to`: `from` is a SQL keyword, and while the dialect
|
|
991
|
+
// quotes every identifier, a column named `from` also collides with the `find({ from })`
|
|
992
|
+
// query key — a `where: { from: ... }` reads as a table reference to anyone skimming.
|
|
993
|
+
cms_redirects: Entity((t) => ({
|
|
994
|
+
id: primaryKey(generated(t.uuid())),
|
|
995
|
+
// Unique because resolution is an exact lookup: two rows for one path is a coin flip
|
|
996
|
+
// over which redirect a visitor gets, and the DB is the only place that can refuse it.
|
|
997
|
+
fromPath: unique(notNull(t.text())),
|
|
998
|
+
toPath: notNull(t.text()),
|
|
999
|
+
// 301 (permanent) or 302 (temporary). INT, and constrained on write — a redirect status
|
|
1000
|
+
// is not free-form, and a typo here is a broken response, not a broken page.
|
|
1001
|
+
status: defaultTo(t.int(), 301),
|
|
1002
|
+
// Off-switch that keeps the row. A redirect is usually disabled to TEST whether it is
|
|
1003
|
+
// still needed; deleting it loses the record of what the old URL was.
|
|
1004
|
+
enabled: defaultTo(t.bool(), true),
|
|
1005
|
+
note: t.text(),
|
|
1006
|
+
createdAt: defaultTo(t.text(), expr.now()),
|
|
1007
|
+
updatedAt: defaultTo(t.text(), expr.now()),
|
|
1008
|
+
})),
|
|
1009
|
+
// NOTE: deliberately no hit counter. Counting would make `resolveRedirect` — the one
|
|
1010
|
+
// handler anonymous traffic calls on every 404 — a WRITE, which is an unauthenticated
|
|
1011
|
+
// row mutation on the hot path and, on the DO, a transaction per miss. Redirect usage
|
|
1012
|
+
// belongs in the edge's own logs.
|
|
1013
|
+
|
|
1014
|
+
// A classification vocabulary: `category` (hierarchical) and `tag` (flat) are just two
|
|
1015
|
+
// rows here, which is why there is no built-in of either — a deployment declares what it
|
|
1016
|
+
// classifies by, the same way it declares its content types.
|
|
1017
|
+
cms_taxonomies: Entity((t) => ({
|
|
1018
|
+
id: primaryKey(generated(t.uuid())),
|
|
1019
|
+
slug: unique(notNull(t.text())),
|
|
1020
|
+
label: notNull(t.text()),
|
|
1021
|
+
pluralLabel: t.text(),
|
|
1022
|
+
description: t.text(),
|
|
1023
|
+
// Hierarchical vocabularies allow `parentId` on their terms; flat ones reject it on
|
|
1024
|
+
// write. Enforced in the handler, not the schema — one term table serves both.
|
|
1025
|
+
hierarchical: defaultTo(t.bool(), false),
|
|
1026
|
+
createdAt: defaultTo(t.text(), expr.now()),
|
|
1027
|
+
})),
|
|
1028
|
+
|
|
1029
|
+
cms_terms: Entity(
|
|
1030
|
+
(t) => ({
|
|
1031
|
+
id: primaryKey(generated(t.uuid())),
|
|
1032
|
+
taxonomyId: indexed(notNull(t.uuid())),
|
|
1033
|
+
slug: notNull(t.text()),
|
|
1034
|
+
label: notNull(t.text()),
|
|
1035
|
+
description: t.text(),
|
|
1036
|
+
// Self-referential, and a REAL FK: deleting a parent term must not leave children
|
|
1037
|
+
// pointing at a row that is gone (the front end would render an orphan branch that
|
|
1038
|
+
// no listing can reach). `setNull` promotes them to the top level instead, which is
|
|
1039
|
+
// the only non-destructive answer — `cascade` would silently delete a subtree.
|
|
1040
|
+
parentId: t.uuid(),
|
|
1041
|
+
position: defaultTo(t.int(), 0),
|
|
1042
|
+
createdAt: defaultTo(t.text(), expr.now()),
|
|
1043
|
+
}),
|
|
1044
|
+
(r) => ({
|
|
1045
|
+
taxonomy: r.belongsTo("cms_taxonomies", "taxonomyId", { onDelete: "cascade" }),
|
|
1046
|
+
parent: r.belongsTo("cms_terms", "parentId", { onDelete: "setNull" }),
|
|
1047
|
+
pages: r.hasMany("cms_page_terms", "termId"),
|
|
1048
|
+
}),
|
|
1049
|
+
// A slug identifies a term WITHIN its vocabulary — `/category/news` and `/tag/news`
|
|
1050
|
+
// are two different terms, and both are legitimate.
|
|
1051
|
+
{ unique: [["taxonomyId", "slug"]] },
|
|
1052
|
+
),
|
|
1053
|
+
|
|
1054
|
+
// The term-assignment junction — an EXPLICIT entity, which is what `manyToMany` means
|
|
1055
|
+
// here: `ctx.db.insert("cms_page_terms", …)` links, `delete` unlinks, and `where`
|
|
1056
|
+
// traverses it as a nested subquery. No synthetic table, no write API to learn.
|
|
1057
|
+
cms_page_terms: Entity(
|
|
1058
|
+
(t) => ({
|
|
1059
|
+
id: primaryKey(generated(t.uuid())),
|
|
1060
|
+
pageId: indexed(notNull(t.uuid())),
|
|
1061
|
+
termId: indexed(notNull(t.uuid())),
|
|
1062
|
+
}),
|
|
1063
|
+
(r) => ({
|
|
1064
|
+
page: r.belongsTo("cms_pages", "pageId", { onDelete: "cascade" }),
|
|
1065
|
+
term: r.belongsTo("cms_terms", "termId", { onDelete: "cascade" }),
|
|
1066
|
+
}),
|
|
1067
|
+
// One assignment per (page, term). Without it a double-submit leaves a page tagged
|
|
1068
|
+
// twice and every `with: { terms: true }` renders the term twice.
|
|
1069
|
+
{ unique: [["pageId", "termId"]] },
|
|
1070
|
+
),
|
|
1071
|
+
|
|
1072
|
+
// A named template region an admin fills without touching code — the sidebar, the footer
|
|
1073
|
+
// column, the pre-footer strip.
|
|
1074
|
+
//
|
|
1075
|
+
// Kept as its own entity rather than a page-less `cms_blocks` region, which was the
|
|
1076
|
+
// tempting reuse. A block placement is `(pageId, region, position)` with `pageId` NOT
|
|
1077
|
+
// NULL: making it nullable to model "belongs to no page" would put a null branch through
|
|
1078
|
+
// every placement read, every region assemble and every page-scoped ACL clause, to model
|
|
1079
|
+
// something that shares no field with a page (no slug, no status, no revisions, no
|
|
1080
|
+
// workflow). A widget area is a small ordered document, and that is what it is stored as.
|
|
1081
|
+
cms_widget_areas: Entity((t) => ({
|
|
1082
|
+
id: primaryKey(generated(t.uuid())),
|
|
1083
|
+
name: unique(notNull(t.text())),
|
|
1084
|
+
label: notNull(t.text()),
|
|
1085
|
+
description: t.text(),
|
|
1086
|
+
widgets: t.json(), // Widget[]
|
|
1087
|
+
// Same argument as cms_menus.version — `updateWidgetArea` replaces the whole list.
|
|
1088
|
+
version: defaultTo(t.int(), 1),
|
|
1089
|
+
createdAt: defaultTo(t.text(), expr.now()),
|
|
1090
|
+
updatedAt: defaultTo(t.text(), expr.now()),
|
|
1091
|
+
})),
|
|
1092
|
+
|
|
623
1093
|
// Media: a fileRef column holds only R2 metadata; bytes live in R2, uploaded via
|
|
624
1094
|
// ctx.files + the Worker /files/* route. Block `fields` reference a media id.
|
|
625
1095
|
cms_media: Entity((t) => ({
|
|
@@ -633,6 +1103,50 @@ export const cmsSchema = {
|
|
|
633
1103
|
})),
|
|
634
1104
|
};
|
|
635
1105
|
|
|
1106
|
+
/** Block Kit — custom admin pages, described as JSON and rendered by the editor. See
|
|
1107
|
+
* `./blockkit`. Re-exported so a host imports `adminPage` beside `collection`. */
|
|
1108
|
+
export {
|
|
1109
|
+
adminPage,
|
|
1110
|
+
createAdminPageHandlers,
|
|
1111
|
+
normalizeAdminResponse,
|
|
1112
|
+
validateAdminPages,
|
|
1113
|
+
MAX_ADMIN_BLOCK_DEPTH,
|
|
1114
|
+
} from "./blockkit";
|
|
1115
|
+
export type {
|
|
1116
|
+
AdminBlock,
|
|
1117
|
+
AdminButton,
|
|
1118
|
+
AdminElement,
|
|
1119
|
+
AdminInput,
|
|
1120
|
+
AdminInteractionType,
|
|
1121
|
+
AdminPageDef,
|
|
1122
|
+
AdminPageHandlerOpts,
|
|
1123
|
+
AdminPageInteraction,
|
|
1124
|
+
AdminPageMeta,
|
|
1125
|
+
AdminPageResponse,
|
|
1126
|
+
AdminText,
|
|
1127
|
+
} from "./blockkit";
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Columns this package wrote in the pre-ISO space form that the SCHEMA cannot identify.
|
|
1131
|
+
*
|
|
1132
|
+
* `isoTimestampBackfill()` finds every column whose DEFAULT is `expr.now()` on its own.
|
|
1133
|
+
* `cms_pages.publishedAt` has no default at all — `doPublish` stamped it from handler code,
|
|
1134
|
+
* in the same space form, to stay comparable with the `updatedAt` written beside it. There
|
|
1135
|
+
* is nothing on that column to find, so it is named here.
|
|
1136
|
+
*
|
|
1137
|
+
* Spread it into the migration if your store was ever written by a build older than this
|
|
1138
|
+
* one:
|
|
1139
|
+
*
|
|
1140
|
+
* ```ts
|
|
1141
|
+
* migrations: [isoTimestampBackfill({ extraColumns: CMS_LEGACY_TIMESTAMP_COLUMNS })]
|
|
1142
|
+
* ```
|
|
1143
|
+
*
|
|
1144
|
+
* Costs nothing on a store that was not — the UPDATE matches no rows.
|
|
1145
|
+
*/
|
|
1146
|
+
export const CMS_LEGACY_TIMESTAMP_COLUMNS: Readonly<Record<string, readonly string[]>> = {
|
|
1147
|
+
cms_pages: ["publishedAt"],
|
|
1148
|
+
};
|
|
1149
|
+
|
|
636
1150
|
// --- field validation --------------------------------------------------------
|
|
637
1151
|
|
|
638
1152
|
export interface ValidateOpts {
|
|
@@ -730,6 +1244,19 @@ export function validateFields(schema: FieldDefinition[] | undefined | null, val
|
|
|
730
1244
|
// (collectMediaIds/resolveMediaFields only handle string ids).
|
|
731
1245
|
if (typeof v !== "string") throw new BadRequest(`field '${at}' must be a media id (string)`);
|
|
732
1246
|
break;
|
|
1247
|
+
// An OPAQUE id: the record may live in another table, or in a system we do not own,
|
|
1248
|
+
// so there is nothing to check it against here beyond its shape. The picker's
|
|
1249
|
+
// `referenceFrom` handler is the authority on which ids exist, and it runs under the
|
|
1250
|
+
// caller's own ACL — so validating against a list fetched here would be both a second
|
|
1251
|
+
// round trip and a weaker check than the one the storing handler already makes.
|
|
1252
|
+
case "reference":
|
|
1253
|
+
if (def.multiple) {
|
|
1254
|
+
if (!Array.isArray(v)) throw new BadRequest(`field '${at}' must be a list of ids`);
|
|
1255
|
+
if (v.some((id) => typeof id !== "string")) throw new BadRequest(`field '${at}' must be a list of ids (strings)`);
|
|
1256
|
+
} else if (typeof v !== "string") {
|
|
1257
|
+
throw new BadRequest(`field '${at}' must be an id (string)`);
|
|
1258
|
+
}
|
|
1259
|
+
break;
|
|
733
1260
|
case "group": {
|
|
734
1261
|
// The baseline MUST descend. Stopping at the top level meant a pre-migration
|
|
735
1262
|
// richtext value nested in a group was rejected on every write that echoed the
|
|
@@ -767,6 +1294,197 @@ export function validateFields(schema: FieldDefinition[] | undefined | null, val
|
|
|
767
1294
|
}
|
|
768
1295
|
}
|
|
769
1296
|
|
|
1297
|
+
// --- validating an AUTHORED field schema -------------------------------------
|
|
1298
|
+
//
|
|
1299
|
+
// `validateFields` above checks a VALUE against a schema. This checks the SCHEMA itself.
|
|
1300
|
+
//
|
|
1301
|
+
// It did not exist while the only way to create a block type was a developer writing
|
|
1302
|
+
// `defineBlockType(...)` in the repo, where tsc is the check. Now that the editor authors
|
|
1303
|
+
// types (GitHub #9), `fieldsSchema` arrives from a browser as free JSON into a `t.json()`
|
|
1304
|
+
// column — and a malformed one is not caught anywhere downstream: `FieldForm` renders
|
|
1305
|
+
// `null` for an unknown type, `validateFields` skips it ("lenient on unknown field types"),
|
|
1306
|
+
// and the block silently loses that field's content on every save. A duplicate `name` is
|
|
1307
|
+
// worse: two controls write the same key, so one of them can never be saved at all.
|
|
1308
|
+
|
|
1309
|
+
/** Every field type the runtime knows. Exported because the editor's type-builder offers
|
|
1310
|
+
* exactly this list — one definition, so a type added here appears there without a second
|
|
1311
|
+
* edit, and a type removed here cannot be authored. */
|
|
1312
|
+
export const FIELD_TYPES: readonly FieldDefinition["type"][] = [
|
|
1313
|
+
"text", "textarea", "richtext", "url", "number", "boolean", "date", "datetime",
|
|
1314
|
+
"publish", "slug", "media", "select", "reference", "repeater", "group",
|
|
1315
|
+
];
|
|
1316
|
+
|
|
1317
|
+
/** Types whose `fields` nest a further schema. */
|
|
1318
|
+
const NESTING_TYPES: readonly FieldDefinition["type"][] = ["group", "repeater"];
|
|
1319
|
+
|
|
1320
|
+
/** How deep an authored field schema may nest. A schema is rendered by a recursive
|
|
1321
|
+
* component and validated by a recursive function, so the cap is what keeps both bounded
|
|
1322
|
+
* against a hand-posted document; nothing real nests past two or three. */
|
|
1323
|
+
export const MAX_FIELD_DEPTH = 5;
|
|
1324
|
+
|
|
1325
|
+
/** A field NAME is an object key in a `fields` bag and a property name in generated TS
|
|
1326
|
+
* (`generateBlockTypes`), so it is held to what can be both. */
|
|
1327
|
+
const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1328
|
+
|
|
1329
|
+
/** A REGION name. Looser than a field name by one character, because the two are not the
|
|
1330
|
+
* same kind of thing: a field name is emitted as a TS property by `generateBlockTypes`, so
|
|
1331
|
+
* it must be an identifier, while a region name is only ever an object key on the assembled
|
|
1332
|
+
* page (`regions["main-content"]`). Held to `FIELD_NAME` it rejected hyphenated names that
|
|
1333
|
+
* pre-date this validation and are stored today — which would have made every future save
|
|
1334
|
+
* of such a content type fail, with the only fix being a rename that orphans its
|
|
1335
|
+
* placements. */
|
|
1336
|
+
const REGION_NAME = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* Validate and canonicalize an authored `FieldDefinition[]`, throwing a 400 on the first
|
|
1340
|
+
* problem. Returns the CLEANED schema — each field rebuilt from the keys its type actually
|
|
1341
|
+
* uses, so a `select`'s stale `options` cannot ride along on a field someone switched to
|
|
1342
|
+
* `text` and reappear if they switch back.
|
|
1343
|
+
*/
|
|
1344
|
+
export function validateFieldSchema(raw: unknown, path = "fieldsSchema", depth = 0): FieldDefinition[] {
|
|
1345
|
+
if (raw == null) return [];
|
|
1346
|
+
if (!Array.isArray(raw)) throw new BadRequest(`${path} must be a list of field definitions`);
|
|
1347
|
+
if (depth >= MAX_FIELD_DEPTH) throw new BadRequest(`${path} nests deeper than ${MAX_FIELD_DEPTH} levels`);
|
|
1348
|
+
const seen = new Set<string>();
|
|
1349
|
+
return raw.map((entry, i) => {
|
|
1350
|
+
const o = asObj(entry) as Record<string, unknown>;
|
|
1351
|
+
const at = `${path}[${i}]`;
|
|
1352
|
+
const name = typeof o.name === "string" ? o.name.trim() : "";
|
|
1353
|
+
if (!FIELD_NAME.test(name)) {
|
|
1354
|
+
throw new BadRequest(`${at}.name must be a field name (a letter or underscore, then letters/digits/underscores), got ${JSON.stringify(o.name)}`);
|
|
1355
|
+
}
|
|
1356
|
+
// Siblings only — a nested `group` legitimately reuses a name from the outer level,
|
|
1357
|
+
// because it writes into its own bag.
|
|
1358
|
+
if (seen.has(name)) throw new BadRequest(`${path} declares '${name}' twice — two controls would write the same key and one could never be saved`);
|
|
1359
|
+
seen.add(name);
|
|
1360
|
+
const type = o.type as FieldDefinition["type"];
|
|
1361
|
+
if (!FIELD_TYPES.includes(type)) {
|
|
1362
|
+
throw new BadRequest(`${at}.type is '${String(o.type)}', which is not a field type (known: ${FIELD_TYPES.join(", ")})`);
|
|
1363
|
+
}
|
|
1364
|
+
const f: FieldDefinition = { name, type };
|
|
1365
|
+
if (typeof o.label === "string" && o.label.trim() !== "") f.label = o.label.trim();
|
|
1366
|
+
if (o.required === true) f.required = true;
|
|
1367
|
+
if (o.default !== undefined) f.default = o.default;
|
|
1368
|
+
|
|
1369
|
+
if (NESTING_TYPES.includes(type)) {
|
|
1370
|
+
f.fields = validateFieldSchema(o.fields, `${at}.fields`, depth + 1);
|
|
1371
|
+
// A `group` with no fields renders an empty box; a `repeater` with none renders rows
|
|
1372
|
+
// of nothing and an Add button. Both are the shape of a half-finished edit, and both
|
|
1373
|
+
// are silently useless rather than visibly wrong, so they are refused here.
|
|
1374
|
+
if (f.fields.length === 0) throw new BadRequest(`${at} is a '${type}' and needs at least one nested field`);
|
|
1375
|
+
if (type === "repeater") {
|
|
1376
|
+
if (typeof o.min === "number" && Number.isFinite(o.min)) f.min = Math.max(0, Math.trunc(o.min));
|
|
1377
|
+
if (typeof o.max === "number" && Number.isFinite(o.max)) f.max = Math.max(1, Math.trunc(o.max));
|
|
1378
|
+
if (f.min != null && f.max != null && f.min > f.max) throw new BadRequest(`${at} has min ${f.min} above max ${f.max}`);
|
|
1379
|
+
}
|
|
1380
|
+
} else if (type === "select") {
|
|
1381
|
+
// `optionsFrom` takes precedence at render time, so requiring options alongside it
|
|
1382
|
+
// would reject the live-data case the option exists for.
|
|
1383
|
+
if (typeof o.optionsFrom === "string" && o.optionsFrom.trim() !== "") {
|
|
1384
|
+
f.optionsFrom = assertHandlerName(o.optionsFrom, `${at}.optionsFrom`);
|
|
1385
|
+
} else {
|
|
1386
|
+
const options = Array.isArray(o.options) ? o.options.map((v) => String(v).trim()).filter((v) => v !== "") : [];
|
|
1387
|
+
if (options.length === 0) throw new BadRequest(`${at} is a 'select' and needs either \`options\` or an \`optionsFrom\` handler`);
|
|
1388
|
+
if (new Set(options).size !== options.length) throw new BadRequest(`${at} lists the same option twice`);
|
|
1389
|
+
f.options = options;
|
|
1390
|
+
}
|
|
1391
|
+
} else if (type === "slug") {
|
|
1392
|
+
// `from` is optional (a slug typed by hand is legitimate), but naming a field that is
|
|
1393
|
+
// not there is a control that silently never follows anything.
|
|
1394
|
+
if (typeof o.from === "string" && o.from.trim() !== "") f.from = o.from.trim();
|
|
1395
|
+
} else if (type === "reference") {
|
|
1396
|
+
if (typeof o.referenceFrom !== "string" || o.referenceFrom.trim() === "") {
|
|
1397
|
+
throw new BadRequest(`${at} is a 'reference' and needs a \`referenceFrom\` query handler`);
|
|
1398
|
+
}
|
|
1399
|
+
f.referenceFrom = assertHandlerName(o.referenceFrom, `${at}.referenceFrom`);
|
|
1400
|
+
if (o.multiple === true) f.multiple = true;
|
|
1401
|
+
}
|
|
1402
|
+
return f;
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/** Resolve `slug` cross-references inside one schema, now that every sibling is known: a
|
|
1407
|
+
* `slug` field's `from` must name a field that exists AT THE SAME LEVEL (the editor reads
|
|
1408
|
+
* it out of the sibling bag) and holds text. Separate pass because forward references are
|
|
1409
|
+
* legitimate — a slug may precede the title it follows. */
|
|
1410
|
+
export function checkSlugSources(schema: readonly FieldDefinition[], path = "fieldsSchema"): void {
|
|
1411
|
+
const byName = new Map(schema.map((f) => [f.name, f]));
|
|
1412
|
+
schema.forEach((f, i) => {
|
|
1413
|
+
if (f.type === "slug" && f.from) {
|
|
1414
|
+
const src = byName.get(f.from);
|
|
1415
|
+
if (!src) throw new BadRequest(`${path}[${i}] derives from '${f.from}', which is not a field alongside it`);
|
|
1416
|
+
if (!["text", "textarea", "select", "url"].includes(src.type)) {
|
|
1417
|
+
throw new BadRequest(`${path}[${i}] derives from '${f.from}', which is a '${src.type}' — a slug can only follow a text field`);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
if (f.fields) checkSlugSources(f.fields, `${path}[${i}].fields`);
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/** Validate + canonicalize an authored field schema end to end. */
|
|
1425
|
+
export function normalizeFieldSchema(raw: unknown, path = "fieldsSchema"): FieldDefinition[] {
|
|
1426
|
+
const schema = validateFieldSchema(raw, path);
|
|
1427
|
+
checkSlugSources(schema, path);
|
|
1428
|
+
return schema;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* Validate a content type's `regions`.
|
|
1433
|
+
*
|
|
1434
|
+
* A region NAME is the key `addBlock({ region })` resolves and the key of the assembled
|
|
1435
|
+
* `regions` object a front end reads, so it is held to the same shape as a field name. An
|
|
1436
|
+
* `allowedTypes` entry is a block-type SLUG; it is not checked against the block types that
|
|
1437
|
+
* exist, on purpose — a content type declaring a region for a block type that has not been
|
|
1438
|
+
* created yet is an ordinary order of work, and `assertRegionAllows` is what enforces the
|
|
1439
|
+
* list at placement time.
|
|
1440
|
+
*/
|
|
1441
|
+
export function normalizeRegions(raw: unknown): RegionDefinition[] {
|
|
1442
|
+
if (!Array.isArray(raw) || raw.length === 0) throw new BadRequest("at least one region is required");
|
|
1443
|
+
const seen = new Set<string>();
|
|
1444
|
+
return raw.map((entry, i) => {
|
|
1445
|
+
const o = asObj(entry) as Record<string, unknown>;
|
|
1446
|
+
const name = typeof o.name === "string" ? o.name.trim() : "";
|
|
1447
|
+
if (!REGION_NAME.test(name)) 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)}`);
|
|
1448
|
+
if (seen.has(name)) throw new BadRequest(`regions declares '${name}' twice — the assembled page is keyed by region name, so one would overwrite the other`);
|
|
1449
|
+
seen.add(name);
|
|
1450
|
+
const region: RegionDefinition = { name };
|
|
1451
|
+
if (typeof o.label === "string" && o.label.trim() !== "") region.label = o.label.trim();
|
|
1452
|
+
// `null` and omitted both mean "any block type"; an empty ARRAY means "none", which is
|
|
1453
|
+
// a region nothing can ever be placed in. Almost always a half-finished edit, so it is
|
|
1454
|
+
// normalized to "any" rather than stored as a region that silently refuses everything.
|
|
1455
|
+
if (Array.isArray(o.allowedTypes)) {
|
|
1456
|
+
const allowed = o.allowedTypes.map((v) => String(v).trim()).filter((v) => v !== "");
|
|
1457
|
+
region.allowedTypes = allowed.length > 0 ? [...new Set(allowed)] : null;
|
|
1458
|
+
} else {
|
|
1459
|
+
region.allowedTypes = null;
|
|
1460
|
+
}
|
|
1461
|
+
return region;
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/** Validate a content type's `defaultBlocks` against its own regions. A default block that
|
|
1466
|
+
* names a region the type does not declare is created into nowhere — `createPage` would
|
|
1467
|
+
* place it under a key no renderer reads. */
|
|
1468
|
+
export function normalizeDefaultBlocks(raw: unknown, regions: readonly RegionDefinition[]): DefaultBlockDefinition[] {
|
|
1469
|
+
if (raw == null) return [];
|
|
1470
|
+
if (!Array.isArray(raw)) throw new BadRequest("defaultBlocks must be a list");
|
|
1471
|
+
const names = new Set(regions.map((r) => r.name));
|
|
1472
|
+
return raw.map((entry, i) => {
|
|
1473
|
+
const o = asObj(entry) as Record<string, unknown>;
|
|
1474
|
+
const region = typeof o.region === "string" ? o.region.trim() : "";
|
|
1475
|
+
const blockTypeSlug = typeof o.blockTypeSlug === "string" ? o.blockTypeSlug.trim() : "";
|
|
1476
|
+
if (!names.has(region)) throw new BadRequest(`defaultBlocks[${i}] targets region '${region}', which this content type does not declare`);
|
|
1477
|
+
if (!blockTypeSlug) throw new BadRequest(`defaultBlocks[${i}] needs a blockTypeSlug`);
|
|
1478
|
+
const allowed = regions.find((r) => r.name === region)?.allowedTypes;
|
|
1479
|
+
if (allowed && !allowed.includes(blockTypeSlug)) {
|
|
1480
|
+
throw new BadRequest(`defaultBlocks[${i}] places '${blockTypeSlug}' into region '${region}', which does not allow it`);
|
|
1481
|
+
}
|
|
1482
|
+
const out: DefaultBlockDefinition = { region, blockTypeSlug };
|
|
1483
|
+
if (o.fields !== undefined) out.fields = asObj(o.fields);
|
|
1484
|
+
return out;
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
|
|
770
1488
|
// --- rich text: the structural allow-list (server-side — the real XSS boundary) ---
|
|
771
1489
|
//
|
|
772
1490
|
// A `richtext` value is a document TREE, so there is no HTML to scrub — the boundary is
|
|
@@ -1155,14 +1873,34 @@ const cdb = (ctx: HandlerContext): CmsDb => ctx.db as unknown as CmsDb;
|
|
|
1155
1873
|
|
|
1156
1874
|
const notFound = (what: string) => new PramenError(`${what} not found`, 404, "not_found");
|
|
1157
1875
|
const asObj = (v: unknown): FieldValues => (v && typeof v === "object" ? (v as FieldValues) : {});
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1876
|
+
/**
|
|
1877
|
+
* An ISO-8601 UTC instant — the ONE format every timestamp this package writes by hand is
|
|
1878
|
+
* in, so it compares correctly against `$now()` and against the `expr.now()` column
|
|
1879
|
+
* defaults beside it.
|
|
1880
|
+
*
|
|
1881
|
+
* There used to be two of these. `expr.now()` emitted the `datetime('now')` space form, so
|
|
1882
|
+
* page-workflow stamps (`updatedAt`, `publishedAt`) matched THAT to stay lexically
|
|
1883
|
+
* comparable with their own column default, while collection managed timestamps minted ISO
|
|
1884
|
+
* to stay comparable with `$now()`. One column could not satisfy both, and the split was
|
|
1885
|
+
* the honest way to live with it — a trap documented at length on the `publish` field.
|
|
1886
|
+
*
|
|
1887
|
+
* `expr.now()` is ISO now, so the two requirements are the same requirement and there is
|
|
1888
|
+
* one helper. Existing rows written in the old shape are rewritten by
|
|
1889
|
+
* `isoTimestampBackfill()` (see `CMS_LEGACY_TIMESTAMP_COLUMNS`).
|
|
1890
|
+
*/
|
|
1891
|
+
const isoStamp = (): string => new Date().toISOString();
|
|
1892
|
+
|
|
1893
|
+
/** Alias kept because the page-workflow call sites read as "stamp it now". Same function. */
|
|
1894
|
+
const nowStamp = isoStamp;
|
|
1895
|
+
|
|
1896
|
+
/** Does the caller hold one of these roles?
|
|
1897
|
+
*
|
|
1898
|
+
* Delegates to `authorizeHandler`, which is what the dispatcher uses to enforce a handler's
|
|
1899
|
+
* own `auth` — so "may call this handler" and "counts as an editor here" cannot answer
|
|
1900
|
+
* differently. The local copy took `roles` OR `role`, where the framework takes the UNION,
|
|
1901
|
+
* so an identity carrying both saw only one of them. */
|
|
1902
|
+
const isEditor = (ctx: HandlerContext, roles: readonly string[]): boolean =>
|
|
1903
|
+
authorizeHandler([...roles], ctx.identity ?? null);
|
|
1166
1904
|
|
|
1167
1905
|
/** Assemble a page LIVE from its placements/blocks/types, grouped by region and ordered
|
|
1168
1906
|
* by position, merging each shared placement's `overrides` over its block's fields. */
|
|
@@ -1327,20 +2065,440 @@ async function assertRegionAllows(db: CmsDb, page: Record<string, unknown>, regi
|
|
|
1327
2065
|
}
|
|
1328
2066
|
}
|
|
1329
2067
|
|
|
1330
|
-
// --- handlers ----------------------------------------------------------------
|
|
1331
|
-
|
|
1332
|
-
// --- page preview links (signed capability urls) -----------------------------
|
|
1333
|
-
//
|
|
1334
|
-
// Preview used to be a ROLE check, so previewing a draft required an editor account —
|
|
1335
|
-
// which excludes the person preview actually exists for: the stakeholder reviewing copy
|
|
1336
|
-
// before it ships. A preview link is instead a signed, self-expiring CAPABILITY: it names
|
|
1337
|
-
// ONE page, carries its own expiry, and is verified in the Worker before any read happens.
|
|
1338
|
-
// Minting stays editor-gated; redeeming needs no account at all.
|
|
1339
|
-
//
|
|
1340
|
-
// Same machinery as signed file urls (`signToken`/`verifyToken` from @pramen/server), and
|
|
1341
|
-
// the same fail-closed rule: without a usable secret we refuse to mint rather than hand out
|
|
1342
|
-
// forgeable links.
|
|
1343
|
-
|
|
2068
|
+
// --- handlers ----------------------------------------------------------------
|
|
2069
|
+
|
|
2070
|
+
// --- page preview links (signed capability urls) -----------------------------
|
|
2071
|
+
//
|
|
2072
|
+
// Preview used to be a ROLE check, so previewing a draft required an editor account —
|
|
2073
|
+
// which excludes the person preview actually exists for: the stakeholder reviewing copy
|
|
2074
|
+
// before it ships. A preview link is instead a signed, self-expiring CAPABILITY: it names
|
|
2075
|
+
// ONE page, carries its own expiry, and is verified in the Worker before any read happens.
|
|
2076
|
+
// Minting stays editor-gated; redeeming needs no account at all.
|
|
2077
|
+
//
|
|
2078
|
+
// Same machinery as signed file urls (`signToken`/`verifyToken` from @pramen/server), and
|
|
2079
|
+
// the same fail-closed rule: without a usable secret we refuse to mint rather than hand out
|
|
2080
|
+
// forgeable links.
|
|
2081
|
+
|
|
2082
|
+
// --- site furniture: normalization + resolution helpers ----------------------
|
|
2083
|
+
//
|
|
2084
|
+
// All of this runs on the WRITE path. A menu, a term tree and a widget list are documents
|
|
2085
|
+
// the client posts whole, so "the editor wouldn't send that" is not a boundary — every one
|
|
2086
|
+
// of these shapes is reachable with a curl.
|
|
2087
|
+
|
|
2088
|
+
const MENU_ITEM_KINDS: readonly MenuItemKind[] = ["custom", "page", "term", "collection"];
|
|
2089
|
+
|
|
2090
|
+
/** A stable machine key — the string `getMenu(name)` / `getWidgetArea(name)` resolves, and
|
|
2091
|
+
* a taxonomy's URL segment. Same rule as a page slug, and for the same reason: it lands in
|
|
2092
|
+
* a route. */
|
|
2093
|
+
function assertKey(v: unknown, what: string): string {
|
|
2094
|
+
const s = typeof v === "string" ? v.trim() : "";
|
|
2095
|
+
if (!isSlugString(s)) throw new BadRequest(`${what} must be a key (lowercase letters, digits and single hyphens), got ${JSON.stringify(v)}`);
|
|
2096
|
+
return s;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
/** A REGISTRY key — a block type's slug. Looser than {@link assertKey} by one character:
|
|
2100
|
+
* underscores are admitted, because a block-type slug is not a URL segment. It is the key a
|
|
2101
|
+
* front end maps to a component (`{ rich_text: RichText }`), and `rich_text` is the
|
|
2102
|
+
* convention every existing schema and the shipped example already use. */
|
|
2103
|
+
function assertRegistryKey(v: unknown, what: string): string {
|
|
2104
|
+
const str = typeof v === "string" ? v.trim() : "";
|
|
2105
|
+
if (!/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/.test(str) || str.length > 80) {
|
|
2106
|
+
throw new BadRequest(`${what} must be a key (lowercase letters, digits, and single hyphens or underscores), got ${JSON.stringify(v)}`);
|
|
2107
|
+
}
|
|
2108
|
+
return str;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
/** Refuse an editor write to a CODE-DEFINED type.
|
|
2112
|
+
*
|
|
2113
|
+
* `cmsBootstrap` reconciles these rows on every boot, so a save here would return 200 and
|
|
2114
|
+
* then be reverted at the next cold start, taking any content authored against the added
|
|
2115
|
+
* field with it. A 409 is the honest answer: the row exists, the edit is well-formed, and
|
|
2116
|
+
* the conflict is with a definition that lives somewhere this request cannot reach.
|
|
2117
|
+
*
|
|
2118
|
+
* The editor renders a managed type read-only, so this is the curl / stale-tab half.
|
|
2119
|
+
*
|
|
2120
|
+
* The caller MUST have read `managedBy` explicitly (`select`), not taken it off a wide read.
|
|
2121
|
+
* Reads are column-projected against the caller's policy, so under a read policy with a
|
|
2122
|
+
* `fields` list the column is simply absent — and a guard written as "absent means editable"
|
|
2123
|
+
* disarms itself for exactly the deployments that restrict fields. `select` fails CLOSED
|
|
2124
|
+
* instead: an unreadable column is a 403 before this runs. */
|
|
2125
|
+
export function assertNotManaged(row: Record<string, unknown>, what: string, defineFn: string): void {
|
|
2126
|
+
if (!("managedBy" in row)) throw new Error(`assertNotManaged: 'managedBy' was not selected for ${what} — the guard would fail open`);
|
|
2127
|
+
if (row.managedBy == null) return;
|
|
2128
|
+
throw new Conflict(
|
|
2129
|
+
`${what} '${String(row.slug)}' is defined in code (cmsBootstrap owner '${String(row.managedBy)}') — edit its ` +
|
|
2130
|
+
`${defineFn}(...) declaration and redeploy. A change saved here would be reverted on the next boot.`,
|
|
2131
|
+
);
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
/**
|
|
2135
|
+
* An RPC handler name, as an authored field schema may name one (`optionsFrom`,
|
|
2136
|
+
* `referenceFrom`).
|
|
2137
|
+
*
|
|
2138
|
+
* Shape-checked rather than merely non-empty, because the editor interpolates it straight
|
|
2139
|
+
* into a request path — `fetch(\`${base}/rpc/${name}\`)`. `"../admin/data"` normalizes to
|
|
2140
|
+
* `/admin/data`, so a stored string an EDITOR authored became an arbitrary same-origin
|
|
2141
|
+
* authenticated POST fired by whoever opened the block — including an admin, whose token
|
|
2142
|
+
* passes the `/admin/*` gate the editor role cannot. A handler name is an identifier;
|
|
2143
|
+
* nothing that can traverse a path is one.
|
|
2144
|
+
*/
|
|
2145
|
+
function assertHandlerName(v: unknown, what: string): string {
|
|
2146
|
+
const str = typeof v === "string" ? v.trim() : "";
|
|
2147
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(str) || str.length > 80) {
|
|
2148
|
+
throw new BadRequest(`${what} must be a handler name (a letter or underscore, then letters/digits/underscores), got ${JSON.stringify(v)}`);
|
|
2149
|
+
}
|
|
2150
|
+
return str;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
/**
|
|
2154
|
+
* A menu item's `ref` for a non-`custom` kind.
|
|
2155
|
+
*
|
|
2156
|
+
* `menuHref` interpolates this into a path, so a ref starting with `/` produced
|
|
2157
|
+
* `//evil.example/` — protocol-relative, off-origin, in the site's primary nav on every
|
|
2158
|
+
* page — while the sibling `custom` branch three lines away ran the same string through
|
|
2159
|
+
* `isSafeHref`. A reference is an id or a slug: no slashes, no scheme, no dots.
|
|
2160
|
+
*/
|
|
2161
|
+
function assertRef(v: unknown, label: string, kind: MenuItemKind): string {
|
|
2162
|
+
const str = typeof v === "string" ? v.trim() : "";
|
|
2163
|
+
if (!/^[A-Za-z0-9_-]{1,200}$/.test(str)) {
|
|
2164
|
+
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)}`);
|
|
2165
|
+
}
|
|
2166
|
+
return str;
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
function assertLabel(v: unknown, what: string): string {
|
|
2170
|
+
const s = typeof v === "string" ? v.trim() : "";
|
|
2171
|
+
if (!s) throw new BadRequest(`${what} must not be empty`);
|
|
2172
|
+
return s;
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
/**
|
|
2176
|
+
* Validate + canonicalize a posted menu tree.
|
|
2177
|
+
*
|
|
2178
|
+
* Every item is rebuilt field by field rather than spread: `items` is a `t.json()` column,
|
|
2179
|
+
* so anything in the posted object would be stored verbatim and handed to a layout that
|
|
2180
|
+
* renders it. Rebuilding is what makes the stored document exactly the declared shape.
|
|
2181
|
+
*/
|
|
2182
|
+
function normalizeMenuItems(raw: unknown, depth = 0, budget = { left: MAX_MENU_ITEMS }): MenuItem[] {
|
|
2183
|
+
if (!Array.isArray(raw)) {
|
|
2184
|
+
if (raw == null) return [];
|
|
2185
|
+
throw new BadRequest("menu items must be a list");
|
|
2186
|
+
}
|
|
2187
|
+
if (depth >= MAX_MENU_DEPTH) throw new BadRequest(`menu items may nest at most ${MAX_MENU_DEPTH} levels deep`);
|
|
2188
|
+
return raw.map((entry, i) => {
|
|
2189
|
+
// Counted across the WHOLE tree, not per level — the budget is threaded through the
|
|
2190
|
+
// recursion for that reason.
|
|
2191
|
+
if (--budget.left < 0) throw new BadRequest(`a menu may hold at most ${MAX_MENU_ITEMS} items`);
|
|
2192
|
+
const o = asObj(entry) as Record<string, unknown>;
|
|
2193
|
+
const label = assertLabel(o.label, `menu item [${i}] label`);
|
|
2194
|
+
const kind = (typeof o.kind === "string" ? o.kind : "custom") as MenuItemKind;
|
|
2195
|
+
if (!MENU_ITEM_KINDS.includes(kind)) {
|
|
2196
|
+
throw new BadRequest(`menu item '${label}' has unknown kind '${String(o.kind)}' (known: ${MENU_ITEM_KINDS.join(", ")})`);
|
|
2197
|
+
}
|
|
2198
|
+
// A stable id per item. Minted here when absent so a client that posts a tree without
|
|
2199
|
+
// ids still gets reorderable rows back, rather than a list React can only key by index.
|
|
2200
|
+
const item: MenuItem = { id: typeof o.id === "string" && o.id.trim() !== "" ? o.id.trim() : crypto.randomUUID(), label, kind };
|
|
2201
|
+
if (kind === "custom") {
|
|
2202
|
+
// The SAME allow-list a rich-text link mark goes through. A menu is rendered into an
|
|
2203
|
+
// `<a href>` on every page of the site, so `javascript:` here is exactly the hole
|
|
2204
|
+
// `isSafeHref` exists to close — and the editor is not the only writer.
|
|
2205
|
+
const url = normalizeHref(typeof o.url === "string" ? o.url : "");
|
|
2206
|
+
if (!isSafeHref(url)) throw new BadRequest(`menu item '${label}' needs a valid url (http(s), mailto:, tel:, a rooted path, or #anchor)`);
|
|
2207
|
+
item.url = url;
|
|
2208
|
+
} else {
|
|
2209
|
+
item.ref = assertRef(o.ref, label, kind);
|
|
2210
|
+
}
|
|
2211
|
+
// `target` is written into an anchor; anything but the four browsing-context keywords
|
|
2212
|
+
// is a named window, which is a way to reuse a tab the site does not own.
|
|
2213
|
+
if (typeof o.target === "string" && o.target !== "") {
|
|
2214
|
+
if (!["_self", "_blank", "_parent", "_top"].includes(o.target)) throw new BadRequest(`menu item '${label}' has an unsupported target '${o.target}'`);
|
|
2215
|
+
item.target = o.target;
|
|
2216
|
+
}
|
|
2217
|
+
if (typeof o.titleAttr === "string" && o.titleAttr !== "") item.titleAttr = o.titleAttr;
|
|
2218
|
+
if (typeof o.cssClasses === "string" && o.cssClasses !== "") item.cssClasses = o.cssClasses;
|
|
2219
|
+
const children = normalizeMenuItems(o.children, depth + 1, budget);
|
|
2220
|
+
if (children.length > 0) item.children = children;
|
|
2221
|
+
return item;
|
|
2222
|
+
});
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
/** Every `page`/`term` ref in a tree, so resolution is two queries rather than one per item. */
|
|
2226
|
+
function collectMenuRefs(items: readonly MenuItem[], pages: Set<string>, terms: Set<string>): void {
|
|
2227
|
+
for (const it of items) {
|
|
2228
|
+
if (it.ref) {
|
|
2229
|
+
if (it.kind === "page") pages.add(it.ref);
|
|
2230
|
+
else if (it.kind === "term") terms.add(it.ref);
|
|
2231
|
+
}
|
|
2232
|
+
if (it.children) collectMenuRefs(it.children, pages, terms);
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
/** Where a resolved menu item points. Passed to `menuHref` so a deployment can route its
|
|
2237
|
+
* own way without the CMS guessing. */
|
|
2238
|
+
export type MenuHrefTarget =
|
|
2239
|
+
| { kind: "page"; slug: string; locale: string }
|
|
2240
|
+
| { kind: "term"; taxonomy: string; slug: string }
|
|
2241
|
+
| { kind: "collection"; slug: string };
|
|
2242
|
+
|
|
2243
|
+
/** A URL redirect's status. 301/308 are permanent (cached by browsers, and by search
|
|
2244
|
+
* engines as a canonical move); 302/307 are not. Anything else is not a redirect. */
|
|
2245
|
+
export const REDIRECT_STATUSES: readonly number[] = [301, 302, 307, 308];
|
|
2246
|
+
|
|
2247
|
+
/**
|
|
2248
|
+
* The stored form of a redirect's `fromPath`: a rooted, query-less, fragment-less path.
|
|
2249
|
+
*
|
|
2250
|
+
* Canonicalized rather than merely validated, because matching is an exact string lookup
|
|
2251
|
+
* against a UNIQUE column. `"/old"` and `"/old/"` are the same URL to a visitor and two
|
|
2252
|
+
* rows here, so the second one is dead the moment the first exists — and which one wins is
|
|
2253
|
+
* whichever the editor happened to type. Trailing slash off (except the root), fragment
|
|
2254
|
+
* and query dropped, percent-encoding left exactly as written (the parser's, and the
|
|
2255
|
+
* request's, canonical form).
|
|
2256
|
+
*/
|
|
2257
|
+
export function normalizeRedirectPath(raw: unknown): string {
|
|
2258
|
+
const s = typeof raw === "string" ? normalizeHref(raw) : "";
|
|
2259
|
+
if (!s.startsWith("/") || s.startsWith("//") || s.startsWith("/\\")) {
|
|
2260
|
+
throw new BadRequest(`redirect path must be a rooted path like /old-url, got ${JSON.stringify(raw)}`);
|
|
2261
|
+
}
|
|
2262
|
+
const path = s.split("#")[0]!.split("?")[0]!;
|
|
2263
|
+
const trimmed = path.length > 1 ? path.replace(/\/+$/, "") || "/" : "/";
|
|
2264
|
+
// PERCENT-ENCODED, through the same parser the request goes through. A visitor's path
|
|
2265
|
+
// reaches `resolveRedirect` as `url.pathname`, which the WHATWG parser has already
|
|
2266
|
+
// encoded — so an editor typing `/o-nás` stored a string that the exact-match lookup
|
|
2267
|
+
// could never be handed, and the redirect silently never fired. On precisely the
|
|
2268
|
+
// non-English sites where slug changes are most common. Idempotent: an already-encoded
|
|
2269
|
+
// path parses back to itself.
|
|
2270
|
+
try {
|
|
2271
|
+
return new URL(trimmed, "https://pramen.invalid").pathname;
|
|
2272
|
+
} catch {
|
|
2273
|
+
throw new BadRequest(`redirect path is not a usable path: ${JSON.stringify(raw)}`);
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
/**
|
|
2278
|
+
* Is this redirect a loop — does its destination resolve back to its own source?
|
|
2279
|
+
*
|
|
2280
|
+
* Compared through `normalizeRedirectPath` on BOTH sides, which a raw `from === to` did
|
|
2281
|
+
* not do: `from: "/old", to: "/old/"` differ as strings, so the guard passed — and then a
|
|
2282
|
+
* visitor hitting `/old` was sent to `/old/`, whose 404 handler canonicalizes the trailing
|
|
2283
|
+
* slash back to `/old` and matches the same row. An infinite redirect, from the one pair
|
|
2284
|
+
* the guard exists to catch. (A test here even asserted this pair was fine, on the reading
|
|
2285
|
+
* that a trailing-slash redirect is a normal canonicalization — true in general, and not
|
|
2286
|
+
* true when the lookup canonicalizes the slash away again.)
|
|
2287
|
+
*
|
|
2288
|
+
* An absolute destination is never a loop with a rooted source: it names an origin, and
|
|
2289
|
+
* `resolveRedirect` is only ever handed a path.
|
|
2290
|
+
*/
|
|
2291
|
+
function isSelfRedirect(fromPath: string, toPath: string): boolean {
|
|
2292
|
+
if (/^https?:\/\//i.test(toPath)) return false;
|
|
2293
|
+
try {
|
|
2294
|
+
return normalizeRedirectPath(toPath) === fromPath;
|
|
2295
|
+
} catch {
|
|
2296
|
+
return false;
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
/** A redirect's destination: a rooted path or an absolute http(s) url. `mailto:`/`tel:` are
|
|
2301
|
+
* refused — they are not somewhere a `Location` header can send a page request. */
|
|
2302
|
+
function normalizeRedirectTarget(raw: unknown): string {
|
|
2303
|
+
const s = typeof raw === "string" ? normalizeHref(raw) : "";
|
|
2304
|
+
const ok = /^https?:\/\//i.test(s) || (s.startsWith("/") && !s.startsWith("//") && !s.startsWith("/\\"));
|
|
2305
|
+
if (!ok) throw new BadRequest(`redirect target must be a rooted path or an absolute http(s) url, got ${JSON.stringify(raw)}`);
|
|
2306
|
+
return s;
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
/** Assemble a flat term list into a forest, ordered by `position` then `label`.
|
|
2310
|
+
*
|
|
2311
|
+
* A term whose `parentId` names a row that is not in `rows` is treated as a ROOT rather
|
|
2312
|
+
* than dropped. That is the case where a parent was deleted mid-read (the FK sets children
|
|
2313
|
+
* to NULL, but a snapshot taken across the two states can see the old value) — and a term
|
|
2314
|
+
* that vanishes from a vocabulary listing is a worse answer than one that shows up a level
|
|
2315
|
+
* too high.
|
|
2316
|
+
*/
|
|
2317
|
+
function buildTermTree(rows: readonly Term[]): Term[] {
|
|
2318
|
+
const byId = new Map<string, Term & { children: Term[] }>();
|
|
2319
|
+
for (const r of rows) byId.set(r.id, { ...r, children: [] });
|
|
2320
|
+
const roots: Term[] = [];
|
|
2321
|
+
for (const node of byId.values()) {
|
|
2322
|
+
const parent = node.parentId ? byId.get(node.parentId) : undefined;
|
|
2323
|
+
// `parent !== node` guards the one cycle a single row can make on its own; deeper
|
|
2324
|
+
// cycles are refused on write by `assertTermParent`, which is where a cycle is
|
|
2325
|
+
// actually preventable.
|
|
2326
|
+
if (parent && parent !== node) parent.children.push(node);
|
|
2327
|
+
else roots.push(node);
|
|
2328
|
+
}
|
|
2329
|
+
const sort = (list: Term[]): Term[] => {
|
|
2330
|
+
list.sort((a, b) => a.position - b.position || a.label.localeCompare(b.label));
|
|
2331
|
+
for (const t of list) if (t.children) sort(t.children);
|
|
2332
|
+
return list;
|
|
2333
|
+
};
|
|
2334
|
+
return sort(roots);
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
const WIDGET_TYPES: readonly WidgetType[] = ["content", "menu", "component"];
|
|
2338
|
+
|
|
2339
|
+
/** A list limit from client input, clamped to what `listPages` already allows. Absent or
|
|
2340
|
+
* unusable falls back to the default rather than to "unbounded" — a request with no limit
|
|
2341
|
+
* on a store reached over RPC is the shape that made lists hang (GitHub #22). */
|
|
2342
|
+
function clampLimit(v: unknown): number {
|
|
2343
|
+
const n = typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : PAGE_LIST_LIMIT;
|
|
2344
|
+
return Math.max(1, Math.min(n, PAGE_LIST_MAX_LIMIT));
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
/** The most terms one vocabulary (or one page) may carry in a single read.
|
|
2348
|
+
*
|
|
2349
|
+
* A vocabulary is read WHOLE by `listTerms`/`getTermTree` — a tree cannot be paged without
|
|
2350
|
+
* either losing branches or fetching ancestors separately — so the cap is what keeps that
|
|
2351
|
+
* read bounded. Tags are the case that grows without anyone deciding to grow it. */
|
|
2352
|
+
const MAX_TERMS = 1000;
|
|
2353
|
+
|
|
2354
|
+
/** A redirect's editable fields, as posted. `requireEnds` is the CREATE case, where both
|
|
2355
|
+
* ends must be present; an update patches whichever keys it sends. */
|
|
2356
|
+
interface RedirectPatch {
|
|
2357
|
+
fromPath?: string;
|
|
2358
|
+
toPath?: string;
|
|
2359
|
+
status?: number;
|
|
2360
|
+
enabled?: boolean;
|
|
2361
|
+
note?: string | null;
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
function redirectPatch(raw: unknown, requireEnds: boolean): RedirectPatch {
|
|
2365
|
+
const o = asObj(raw);
|
|
2366
|
+
const out: RedirectPatch = {};
|
|
2367
|
+
if (requireEnds || o.fromPath !== undefined) out.fromPath = normalizeRedirectPath(o.fromPath);
|
|
2368
|
+
if (requireEnds || o.toPath !== undefined) out.toPath = normalizeRedirectTarget(o.toPath);
|
|
2369
|
+
if (o.status !== undefined) {
|
|
2370
|
+
const status = typeof o.status === "number" ? Math.trunc(o.status) : NaN;
|
|
2371
|
+
if (!REDIRECT_STATUSES.includes(status)) throw new BadRequest(`redirect status must be one of ${REDIRECT_STATUSES.join(", ")}`);
|
|
2372
|
+
out.status = status;
|
|
2373
|
+
}
|
|
2374
|
+
if (o.enabled !== undefined) out.enabled = Boolean(o.enabled);
|
|
2375
|
+
if (o.note !== undefined) out.note = typeof o.note === "string" ? o.note : null;
|
|
2376
|
+
return out;
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
/** A required row id from client input. */
|
|
2380
|
+
function requireId(raw: unknown, what = "id"): string {
|
|
2381
|
+
const v = (asObj(raw) as Record<string, unknown>)[what];
|
|
2382
|
+
if (typeof v !== "string" || v === "") throw new BadRequest(`${what} is required`);
|
|
2383
|
+
return v;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
/** Resolve a taxonomy by its slug. */
|
|
2387
|
+
async function taxonomyBySlug(db: CmsDb, slug: string): Promise<{ id: string; hierarchical: boolean } | null> {
|
|
2388
|
+
const rows = await db.find({ from: "cms_taxonomies", where: { slug }, select: ["id", "hierarchical"], limit: 1 });
|
|
2389
|
+
const row = rows[0];
|
|
2390
|
+
return row ? { id: String(row.id), hierarchical: Boolean(row.hierarchical) } : null;
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
/**
|
|
2394
|
+
* Check a proposed `parentId` for a term: it exists, it is in the SAME vocabulary, the
|
|
2395
|
+
* vocabulary is hierarchical, the tree stays inside {@link MAX_TERM_DEPTH}, and — for an
|
|
2396
|
+
* update — the new parent is not the term itself or one of its own descendants.
|
|
2397
|
+
*
|
|
2398
|
+
* The cycle check is the one that matters. `ON DELETE SET NULL` keeps the FK honest but
|
|
2399
|
+
* says nothing about shape, so `A.parent = B; B.parent = A` is two perfectly legal writes
|
|
2400
|
+
* that together make `buildTermTree` produce a forest missing both, and any recursive
|
|
2401
|
+
* renderer loop forever. It is only preventable on write, which is here.
|
|
2402
|
+
*/
|
|
2403
|
+
async function assertTermParent(db: CmsDb, tax: { id: string; hierarchical: boolean }, parentId: string | null, termId: string | null): Promise<void> {
|
|
2404
|
+
if (parentId === null) return;
|
|
2405
|
+
if (!tax.hierarchical) throw new BadRequest("this vocabulary is flat — its terms cannot have a parent");
|
|
2406
|
+
if (termId !== null && parentId === termId) throw new BadRequest("a term cannot be its own parent");
|
|
2407
|
+
// How many levels the MOVED term itself occupies. A leaf is 1; a term with children takes
|
|
2408
|
+
// its subtree with it, and a cap that ignored that admitted a 5-level tree grafted under a
|
|
2409
|
+
// 4-level parent. Zero cost on the create path, where there is no subtree yet.
|
|
2410
|
+
const moving = termId === null ? 1 : await subtreeHeight(db, tax.id, termId);
|
|
2411
|
+
let cursor: string | null = parentId;
|
|
2412
|
+
// `depth` counts ANCESTORS walked. The moved term sits at `ancestors + moving` levels, and
|
|
2413
|
+
// that is what the cap governs — counting ancestors alone admitted one level too many
|
|
2414
|
+
// (a chain of 5 put the new term at level 6 under a cap of 5).
|
|
2415
|
+
for (let depth = 0; cursor !== null; depth++) {
|
|
2416
|
+
// About to walk ancestor number `depth + 1`. The moved term would then sit at
|
|
2417
|
+
// `(depth + 1) + moving` levels, and THAT is what the cap governs.
|
|
2418
|
+
if (depth + 1 + moving > MAX_TERM_DEPTH) throw new BadRequest(`terms may nest at most ${MAX_TERM_DEPTH} levels deep`);
|
|
2419
|
+
const rows: Array<Record<string, unknown>> = await db.find({ from: "cms_terms", where: { id: cursor }, select: ["id", "taxonomyId", "parentId"], limit: 1 });
|
|
2420
|
+
const row = rows[0];
|
|
2421
|
+
if (!row) throw new BadRequest("parent term not found");
|
|
2422
|
+
if (String(row.taxonomyId) !== tax.id) throw new BadRequest("a term's parent must be in the same vocabulary");
|
|
2423
|
+
// Walking UP from the proposed parent: meeting the term being edited means the parent
|
|
2424
|
+
// is one of its own descendants, which is the cycle.
|
|
2425
|
+
if (termId !== null && String(row.id) === termId) throw new BadRequest("a term cannot be moved under one of its own descendants");
|
|
2426
|
+
cursor = row.parentId == null ? null : String(row.parentId);
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
/** How many levels a term's own subtree occupies (a leaf is 1).
|
|
2431
|
+
*
|
|
2432
|
+
* One query for the whole vocabulary rather than a walk per level: a vocabulary is already
|
|
2433
|
+
* read whole by `listTerms`/`getTermTree` and capped at `MAX_TERMS`, so this is the same
|
|
2434
|
+
* bounded read those make, not a new unbounded one. */
|
|
2435
|
+
async function subtreeHeight(db: CmsDb, taxonomyId: string, rootId: string): Promise<number> {
|
|
2436
|
+
const rows = await db.find({ from: "cms_terms", where: { taxonomyId }, select: ["id", "parentId"], limit: MAX_TERMS });
|
|
2437
|
+
const children = new Map<string, string[]>();
|
|
2438
|
+
for (const r of rows) {
|
|
2439
|
+
const parent = r.parentId == null ? null : String(r.parentId);
|
|
2440
|
+
if (parent) children.set(parent, [...(children.get(parent) ?? []), String(r.id)]);
|
|
2441
|
+
}
|
|
2442
|
+
// Iterative, and bounded by MAX_TERMS: a cycle already in the store (written before the
|
|
2443
|
+
// check that now prevents one) must not spin here.
|
|
2444
|
+
let level = 0;
|
|
2445
|
+
let frontier = [rootId];
|
|
2446
|
+
const seen = new Set<string>();
|
|
2447
|
+
while (frontier.length > 0 && level <= MAX_TERM_DEPTH + 1) {
|
|
2448
|
+
level++;
|
|
2449
|
+
const next: string[] = [];
|
|
2450
|
+
for (const id of frontier) {
|
|
2451
|
+
if (seen.has(id)) continue;
|
|
2452
|
+
seen.add(id);
|
|
2453
|
+
next.push(...(children.get(id) ?? []));
|
|
2454
|
+
}
|
|
2455
|
+
frontier = next;
|
|
2456
|
+
}
|
|
2457
|
+
return level;
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
/**
|
|
2461
|
+
* Validate + canonicalize a posted widget list.
|
|
2462
|
+
*
|
|
2463
|
+
* Rebuilt field by field for the same reason a menu tree is: `widgets` is a `t.json()`
|
|
2464
|
+
* column handed straight to a layout, so whatever the client posts is what renders.
|
|
2465
|
+
* A `content` widget's rich text goes through the SAME `normalizeRichText` allow-list every
|
|
2466
|
+
* block field does — this is a second write path into the same renderer, and it must not be
|
|
2467
|
+
* a weaker one.
|
|
2468
|
+
*/
|
|
2469
|
+
function normalizeWidgets(raw: unknown, rtSchema: RichTextSchema): Widget[] {
|
|
2470
|
+
if (!Array.isArray(raw)) {
|
|
2471
|
+
if (raw == null) return [];
|
|
2472
|
+
throw new BadRequest("widgets must be a list");
|
|
2473
|
+
}
|
|
2474
|
+
return raw.map((entry, i) => {
|
|
2475
|
+
const o = asObj(entry) as Record<string, unknown>;
|
|
2476
|
+
const type = (typeof o.type === "string" ? o.type : "") as WidgetType;
|
|
2477
|
+
if (!WIDGET_TYPES.includes(type)) throw new BadRequest(`widget [${i}] has unknown type '${String(o.type)}' (known: ${WIDGET_TYPES.join(", ")})`);
|
|
2478
|
+
const w: Widget = { id: typeof o.id === "string" && o.id.trim() !== "" ? o.id.trim() : crypto.randomUUID(), type };
|
|
2479
|
+
if (typeof o.title === "string" && o.title !== "") w.title = o.title;
|
|
2480
|
+
if (type === "content") {
|
|
2481
|
+
w.content = normalizeRichText(o.content, rtSchema);
|
|
2482
|
+
} else if (type === "menu") {
|
|
2483
|
+
w.menuName = assertKey(o.menuName, `widget [${i}] menuName`);
|
|
2484
|
+
} else {
|
|
2485
|
+
const id = typeof o.componentId === "string" ? o.componentId.trim() : "";
|
|
2486
|
+
if (!id) throw new BadRequest(`widget [${i}] is a component widget and needs a componentId`);
|
|
2487
|
+
w.componentId = id;
|
|
2488
|
+
// Props are opaque to the CMS — the front end's component owns their meaning — but
|
|
2489
|
+
// they must be a JSON OBJECT, not a bare array or scalar that a spread would silently
|
|
2490
|
+
// turn into indexed props.
|
|
2491
|
+
if (o.componentProps !== undefined) {
|
|
2492
|
+
if (o.componentProps === null || typeof o.componentProps !== "object" || Array.isArray(o.componentProps)) {
|
|
2493
|
+
throw new BadRequest(`widget [${i}] componentProps must be an object`);
|
|
2494
|
+
}
|
|
2495
|
+
w.componentProps = o.componentProps as FieldValues;
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
return w;
|
|
2499
|
+
});
|
|
2500
|
+
}
|
|
2501
|
+
|
|
1344
2502
|
/** What a preview link authorizes: one page, in one tenant, until `exp`. */
|
|
1345
2503
|
export interface PreviewToken {
|
|
1346
2504
|
/** tenant */ t: string;
|
|
@@ -1417,6 +2575,14 @@ export interface CmsHandlerOpts {
|
|
|
1417
2575
|
* (what the shipped editor produces). Widen it if your editor adds TipTap extensions —
|
|
1418
2576
|
* a node type absent from the schema is DROPPED on write, not rejected. */
|
|
1419
2577
|
richTextSchema?: RichTextSchema;
|
|
2578
|
+
/** Map a resolved menu reference to a site path. The CMS is headless, so it cannot know
|
|
2579
|
+
* how a deployment routes — this is the same seam `sitemapXml`'s `pageUrl` is.
|
|
2580
|
+
*
|
|
2581
|
+
* Defaults: a page is `/${slug}` on a monolingual deployment and `/${locale}/${slug}`
|
|
2582
|
+
* once `locales` declares more than one; a term is `/${taxonomy}/${term}`; a collection
|
|
2583
|
+
* is `/${slug}/`. Override it and `getMenu` follows — which is the point of storing a
|
|
2584
|
+
* REFERENCE rather than the href an editor typed: change the routing, not the menu. */
|
|
2585
|
+
menuHref?: (target: MenuHrefTarget) => string;
|
|
1420
2586
|
}
|
|
1421
2587
|
|
|
1422
2588
|
/** Build the CMS handler map. Spread into your app's handlers. Editor mutations are
|
|
@@ -1431,6 +2597,16 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1431
2597
|
const reviewer = { auth: reviewerRoles };
|
|
1432
2598
|
const previewTtl = opts.previewTtlSeconds ?? DEFAULT_PREVIEW_TTL_SECONDS;
|
|
1433
2599
|
const rtSchema = opts.richTextSchema ?? DEFAULT_RICH_TEXT_SCHEMA;
|
|
2600
|
+
const menuHref = opts.menuHref ?? ((target: MenuHrefTarget): string => {
|
|
2601
|
+
switch (target.kind) {
|
|
2602
|
+
// The locale segment appears only where there is a choice to make. A monolingual site
|
|
2603
|
+
// getting `/en/about` is the sitemap default's known wart, and a menu is the one place
|
|
2604
|
+
// it would be visible in the site's own chrome.
|
|
2605
|
+
case "page": return locales.length > 1 ? `/${target.locale}/${target.slug}` : `/${target.slug}`;
|
|
2606
|
+
case "term": return `/${target.taxonomy}/${target.slug}`;
|
|
2607
|
+
case "collection": return `/${target.slug}/`;
|
|
2608
|
+
}
|
|
2609
|
+
});
|
|
1434
2610
|
// Anyone who edits OR reviews may VIEW content (a reviewer must preview a page + load its
|
|
1435
2611
|
// content type/blocks before approving). Read/preview handlers use this; writes stay editor.
|
|
1436
2612
|
const viewerRoles = [...new Set([...editorRoles, ...reviewerRoles])];
|
|
@@ -1532,6 +2708,77 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1532
2708
|
}
|
|
1533
2709
|
};
|
|
1534
2710
|
|
|
2711
|
+
/**
|
|
2712
|
+
* One stored menu row, with its references resolved to hrefs.
|
|
2713
|
+
*
|
|
2714
|
+
* Shared by `getMenu` and `getWidgetArea` rather than inlined in the first: a `menu`
|
|
2715
|
+
* widget embeds a menu, and returning its RAW items there skipped every rule this
|
|
2716
|
+
* function exists to apply — a `page` item came back with no `url` at all (the layout
|
|
2717
|
+
* renders `href=undefined`) and an UNPUBLISHED page's label and id were served to
|
|
2718
|
+
* anonymous callers, which is precisely what the drop below prevents on the other path.
|
|
2719
|
+
*/
|
|
2720
|
+
const resolveMenuRow = async (db: CmsDb, row: Record<string, unknown>): Promise<Menu> => {
|
|
2721
|
+
const items = Array.isArray(row.items) ? (row.items as MenuItem[]) : [];
|
|
2722
|
+
|
|
2723
|
+
// Two lookups for the whole tree, not one per item. Both go through `ctx.db`, so the
|
|
2724
|
+
// caller's own read scope applies: for an anonymous visitor that is the public policy
|
|
2725
|
+
// (published, not trashed), which is precisely the filter a menu needs — a link to a
|
|
2726
|
+
// page that has been unpublished must not render.
|
|
2727
|
+
const pageIds = new Set<string>();
|
|
2728
|
+
const termIds = new Set<string>();
|
|
2729
|
+
collectMenuRefs(items, pageIds, termIds);
|
|
2730
|
+
const pages = new Map<string, { slug: string; locale: string }>();
|
|
2731
|
+
if (pageIds.size > 0) {
|
|
2732
|
+
const found = await db.find({ from: "cms_pages", where: { id: { in: [...pageIds] } }, select: ["id", "slug", "locale"], limit: pageIds.size });
|
|
2733
|
+
for (const p of found) pages.set(String(p.id), { slug: String(p.slug), locale: String(p.locale ?? defaultLocale) });
|
|
2734
|
+
}
|
|
2735
|
+
const terms = new Map<string, { slug: string; taxonomy: string }>();
|
|
2736
|
+
if (termIds.size > 0) {
|
|
2737
|
+
const found = await db.find({ from: "cms_terms", where: { id: { in: [...termIds] } }, select: ["id", "slug", "taxonomyId"], limit: termIds.size });
|
|
2738
|
+
const taxIds = [...new Set(found.map((t) => String(t.taxonomyId)))];
|
|
2739
|
+
const taxa = taxIds.length > 0 ? await db.find({ from: "cms_taxonomies", where: { id: { in: taxIds } }, select: ["id", "slug"], limit: taxIds.length }) : [];
|
|
2740
|
+
const taxSlug = new Map(taxa.map((t) => [String(t.id), String(t.slug)]));
|
|
2741
|
+
for (const t of found) {
|
|
2742
|
+
const tax = taxSlug.get(String(t.taxonomyId));
|
|
2743
|
+
if (tax) terms.set(String(t.id), { slug: String(t.slug), taxonomy: tax });
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
// An item whose target no longer resolves is DROPPED, together with its subtree. A
|
|
2748
|
+
// nav entry that renders no href is a dead link on every page of the site, and
|
|
2749
|
+
// hoisting orphaned children would silently promote a third-level item into the top
|
|
2750
|
+
// bar. The editor's own `listMenus` returns the raw tree, so nothing is lost there.
|
|
2751
|
+
const resolve = (list: readonly MenuItem[]): MenuItem[] => {
|
|
2752
|
+
const out: MenuItem[] = [];
|
|
2753
|
+
for (const item of list) {
|
|
2754
|
+
let url = item.url;
|
|
2755
|
+
if (item.kind === "page") {
|
|
2756
|
+
const page = item.ref ? pages.get(item.ref) : undefined;
|
|
2757
|
+
if (!page) continue;
|
|
2758
|
+
url = menuHref({ kind: "page", slug: page.slug, locale: page.locale });
|
|
2759
|
+
} else if (item.kind === "term") {
|
|
2760
|
+
const term = item.ref ? terms.get(item.ref) : undefined;
|
|
2761
|
+
if (!term) continue;
|
|
2762
|
+
url = menuHref({ kind: "term", taxonomy: term.taxonomy, slug: term.slug });
|
|
2763
|
+
} else if (item.kind === "collection") {
|
|
2764
|
+
if (!item.ref) continue;
|
|
2765
|
+
url = menuHref({ kind: "collection", slug: item.ref });
|
|
2766
|
+
}
|
|
2767
|
+
// The MINTED url goes through the same allow-list the `custom` branch enforces on
|
|
2768
|
+
// write. A reference is interpolated into a path (`/${slug}/`), and a `ref` that
|
|
2769
|
+
// began with a slash produced `//evil.example/` — protocol-relative, off-origin,
|
|
2770
|
+
// in the site's primary nav on every page. `assertRef` refuses that shape on
|
|
2771
|
+
// write; this is the second half, because `menuHref` is host-supplied and a
|
|
2772
|
+
// deployment's own mapping can build an unsafe href out of a safe ref.
|
|
2773
|
+
if (!url || !isSafeHref(url)) continue;
|
|
2774
|
+
const children = item.children ? resolve(item.children) : [];
|
|
2775
|
+
out.push({ ...item, url, ...(children.length > 0 ? { children } : { children: undefined }) });
|
|
2776
|
+
}
|
|
2777
|
+
return out;
|
|
2778
|
+
};
|
|
2779
|
+
return { id: String(row.id), name: String(row.name), label: String(row.label), items: resolve(items) };
|
|
2780
|
+
};
|
|
2781
|
+
|
|
1535
2782
|
const TASK_PUBLISH = "cms:publish";
|
|
1536
2783
|
const TASK_UNPUBLISH = "cms:unpublish";
|
|
1537
2784
|
|
|
@@ -1540,7 +2787,12 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1540
2787
|
listBlockTypes: query((ctx) => cdb(ctx).find({ from: "cms_block_types", orderBy: { column: "name" } })),
|
|
1541
2788
|
|
|
1542
2789
|
createBlockType: mutation(async (ctx, input: { name: string; slug: string; fieldsSchema?: FieldDefinition[]; icon?: string; category?: string; description?: string }) => {
|
|
1543
|
-
|
|
2790
|
+
const db = cdb(ctx);
|
|
2791
|
+
// A clean 409 before the UNIQUE constraint fires. The editor authors these now, and a
|
|
2792
|
+
// raw constraint error reads as "the CMS broke" rather than "that slug is taken".
|
|
2793
|
+
const clash = await db.find({ from: "cms_block_types", where: { slug: input.slug }, select: ["id"], limit: 1 });
|
|
2794
|
+
if (clash[0]) throw new Conflict(`block type '${input.slug}' already exists`);
|
|
2795
|
+
return db.insert("cms_block_types", {
|
|
1544
2796
|
name: input.name,
|
|
1545
2797
|
slug: input.slug,
|
|
1546
2798
|
description: input.description ?? null,
|
|
@@ -1553,12 +2805,30 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1553
2805
|
input: (raw): { name: string; slug: string; fieldsSchema?: FieldDefinition[]; icon?: string; category?: string; description?: string } => {
|
|
1554
2806
|
const o = asObj(raw);
|
|
1555
2807
|
if (typeof o.name !== "string" || typeof o.slug !== "string") throw new BadRequest("name and slug are required");
|
|
1556
|
-
|
|
2808
|
+
if (o.name.trim() === "") throw new BadRequest("name must not be empty");
|
|
2809
|
+
return {
|
|
2810
|
+
name: o.name.trim(),
|
|
2811
|
+
// A block type's slug is a registry key: the editor's inserter, `assertRegionAllows`
|
|
2812
|
+
// and `@pramen/cms/react`'s component map all resolve it. Held to the same shape as
|
|
2813
|
+
// any other key rather than accepted as free text.
|
|
2814
|
+
slug: assertRegistryKey(o.slug, "block type slug"),
|
|
2815
|
+
description: typeof o.description === "string" ? o.description : undefined,
|
|
2816
|
+
icon: typeof o.icon === "string" ? o.icon : undefined,
|
|
2817
|
+
category: typeof o.category === "string" ? o.category : undefined,
|
|
2818
|
+
fieldsSchema: normalizeFieldSchema(o.fieldsSchema),
|
|
2819
|
+
};
|
|
1557
2820
|
},
|
|
1558
2821
|
}),
|
|
1559
2822
|
|
|
1560
2823
|
createContentType: mutation(async (ctx, input: { name: string; slug: string; regions: RegionDefinition[]; fieldsSchema?: FieldDefinition[]; defaultBlocks?: DefaultBlockDefinition[] }) => {
|
|
1561
|
-
|
|
2824
|
+
const db = cdb(ctx);
|
|
2825
|
+
// The same pre-check `createBlockType` and every `create*` in this file already do.
|
|
2826
|
+
// It was the one create handler without it, so a duplicate slug surfaced as a raw
|
|
2827
|
+
// `UNIQUE constraint failed` with no status — a 500. Newly likely: an editor just told
|
|
2828
|
+
// a content type is code-defined and read-only will try to recreate it under that slug.
|
|
2829
|
+
const clash = await db.find({ from: "cms_content_types", where: { slug: input.slug }, select: ["id"], limit: 1 });
|
|
2830
|
+
if (clash[0]) throw new Conflict(`content type '${input.slug}' already exists`);
|
|
2831
|
+
return db.insert("cms_content_types", {
|
|
1562
2832
|
name: input.name,
|
|
1563
2833
|
slug: input.slug,
|
|
1564
2834
|
regions: input.regions ?? [],
|
|
@@ -1574,9 +2844,19 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1574
2844
|
// editor (`/types/:slug`) and the key `listPages({ contentType })` resolves. An
|
|
1575
2845
|
// empty one builds `/types/` — a path the router drops the empty segment from, so
|
|
1576
2846
|
// the type gets a tab that cannot be reached and a list that cannot be addressed.
|
|
1577
|
-
if (o.name.trim() === ""
|
|
1578
|
-
|
|
1579
|
-
return
|
|
2847
|
+
if (o.name.trim() === "") throw new BadRequest("name must not be empty");
|
|
2848
|
+
const regions = normalizeRegions(o.regions);
|
|
2849
|
+
return {
|
|
2850
|
+
name: o.name.trim(),
|
|
2851
|
+
// The SAME rule a block-type slug follows. They were split — block types admitted
|
|
2852
|
+
// `_`, content types did not — for no reason that survives inspection: both are
|
|
2853
|
+
// registry keys, and an underscore is as legal in the `/types/:slug` segment as it
|
|
2854
|
+
// is anywhere else in a URL. The example itself ships `seeded_doc`.
|
|
2855
|
+
slug: assertRegistryKey(o.slug, "content type slug"),
|
|
2856
|
+
regions,
|
|
2857
|
+
fieldsSchema: normalizeFieldSchema(o.fieldsSchema),
|
|
2858
|
+
defaultBlocks: normalizeDefaultBlocks(o.defaultBlocks, regions),
|
|
2859
|
+
};
|
|
1580
2860
|
},
|
|
1581
2861
|
}),
|
|
1582
2862
|
|
|
@@ -1585,9 +2865,13 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1585
2865
|
* what lets a schema evolve (e.g. a field text → date) without recreating the type. */
|
|
1586
2866
|
updateBlockType: mutation(async (ctx, input: { id?: string; slug?: string; name?: string; fieldsSchema?: FieldDefinition[]; icon?: string | null; category?: string | null; description?: string | null }) => {
|
|
1587
2867
|
const db = cdb(ctx);
|
|
1588
|
-
|
|
2868
|
+
// `select` for BOTH reasons the projection exists: the guard needs `managedBy` to be
|
|
2869
|
+
// present rather than projected away, and the lookup has no use for the wide
|
|
2870
|
+
// `fieldsSchema` blob it used to fetch and JSON-parse to read three columns.
|
|
2871
|
+
const rows = await db.find({ from: "cms_block_types", where: input.id ? { id: input.id } : { slug: input.slug }, select: ["id", "slug", "managedBy"], limit: 1 });
|
|
1589
2872
|
const row = rows[0];
|
|
1590
2873
|
if (!row) throw notFound("block type");
|
|
2874
|
+
assertNotManaged(row, "block type", "defineBlockType");
|
|
1591
2875
|
const patch: Record<string, unknown> = {};
|
|
1592
2876
|
for (const k of ["name", "fieldsSchema", "icon", "category", "description"] as const) {
|
|
1593
2877
|
if (k in input) patch[k] = (input as Record<string, unknown>)[k];
|
|
@@ -1598,7 +2882,12 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1598
2882
|
input: (raw): { id?: string; slug?: string; name?: string; fieldsSchema?: FieldDefinition[]; icon?: string | null; category?: string | null; description?: string | null } => {
|
|
1599
2883
|
const o = asObj(raw);
|
|
1600
2884
|
if (typeof o.id !== "string" && typeof o.slug !== "string") throw new BadRequest("id or slug is required");
|
|
1601
|
-
|
|
2885
|
+
const out = { ...o } as Record<string, unknown>;
|
|
2886
|
+
// `name` is the label in the editor's inserter; blanking it leaves an unnamed entry
|
|
2887
|
+
// nobody can identify.
|
|
2888
|
+
if (o.name !== undefined) out.name = assertLabel(o.name, "block type name");
|
|
2889
|
+
if (o.fieldsSchema !== undefined) out.fieldsSchema = normalizeFieldSchema(o.fieldsSchema);
|
|
2890
|
+
return out as never;
|
|
1602
2891
|
},
|
|
1603
2892
|
}),
|
|
1604
2893
|
|
|
@@ -1607,22 +2896,592 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1607
2896
|
updateContentType: mutation(async (ctx, input: { id?: string; slug?: string; name?: string; regions?: RegionDefinition[]; fieldsSchema?: FieldDefinition[]; defaultBlocks?: DefaultBlockDefinition[] }) => {
|
|
1608
2897
|
const db = cdb(ctx);
|
|
1609
2898
|
if ("regions" in input && (!Array.isArray(input.regions) || input.regions.length === 0)) throw new BadRequest("at least one region is required");
|
|
1610
|
-
|
|
2899
|
+
// `regions` as well, because the `defaultBlocks`-only patch below checks against the
|
|
2900
|
+
// STORED regions. See the block-type lookup for why this is a `select` at all.
|
|
2901
|
+
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 });
|
|
1611
2902
|
const row = rows[0];
|
|
1612
2903
|
if (!row) throw notFound("content type");
|
|
2904
|
+
assertNotManaged(row, "content type", "defineContentType");
|
|
1613
2905
|
const patch: Record<string, unknown> = {};
|
|
1614
2906
|
for (const k of ["name", "regions", "fieldsSchema", "defaultBlocks"] as const) {
|
|
1615
2907
|
if (k in input) patch[k] = (input as Record<string, unknown>)[k];
|
|
1616
2908
|
}
|
|
2909
|
+
// A `defaultBlocks` patch that does NOT also send regions is checked here against the
|
|
2910
|
+
// STORED ones — the input parser has no row to read, and a default block placed into a
|
|
2911
|
+
// region the type does not declare is created into a key no renderer looks at.
|
|
2912
|
+
if (patch.defaultBlocks !== undefined && patch.regions === undefined) {
|
|
2913
|
+
patch.defaultBlocks = normalizeDefaultBlocks(patch.defaultBlocks, (Array.isArray(row.regions) ? row.regions : []) as RegionDefinition[]);
|
|
2914
|
+
}
|
|
1617
2915
|
return db.update("cms_content_types", String(row.id), patch);
|
|
1618
2916
|
}, {
|
|
1619
2917
|
...editor,
|
|
1620
2918
|
input: (raw): { id?: string; slug?: string; name?: string; regions?: RegionDefinition[]; fieldsSchema?: FieldDefinition[]; defaultBlocks?: DefaultBlockDefinition[] } => {
|
|
1621
2919
|
const o = asObj(raw);
|
|
1622
2920
|
if (typeof o.id !== "string" && typeof o.slug !== "string") throw new BadRequest("id or slug is required");
|
|
2921
|
+
const out = { ...o } as Record<string, unknown>;
|
|
1623
2922
|
// `name` is the editor's tab label; blanking it leaves an unlabelled tab.
|
|
1624
|
-
if (
|
|
1625
|
-
|
|
2923
|
+
if (o.name !== undefined) out.name = assertLabel(o.name, "content type name");
|
|
2924
|
+
if (o.regions !== undefined) out.regions = normalizeRegions(o.regions);
|
|
2925
|
+
if (o.fieldsSchema !== undefined) out.fieldsSchema = normalizeFieldSchema(o.fieldsSchema);
|
|
2926
|
+
// Checked against the regions being SAVED where the same call sends both — patching
|
|
2927
|
+
// only `defaultBlocks` cannot see the stored regions from an input parser, and the
|
|
2928
|
+
// handler re-checks below.
|
|
2929
|
+
if (out.defaultBlocks !== undefined && out.regions !== undefined) {
|
|
2930
|
+
out.defaultBlocks = normalizeDefaultBlocks(out.defaultBlocks, out.regions as RegionDefinition[]);
|
|
2931
|
+
}
|
|
2932
|
+
return out as never;
|
|
2933
|
+
},
|
|
2934
|
+
}),
|
|
2935
|
+
|
|
2936
|
+
// ---- site furniture: menus ----------------------------------------------
|
|
2937
|
+
//
|
|
2938
|
+
// A menu is read WHOLE (`getMenu("primary")` on every page render) and written whole.
|
|
2939
|
+
// The public read RESOLVES references — that is what makes a `page` item follow its
|
|
2940
|
+
// page's slug instead of freezing the href an editor typed once.
|
|
2941
|
+
|
|
2942
|
+
/** One menu, references resolved to hrefs. PUBLIC — a menu is site chrome.
|
|
2943
|
+
*
|
|
2944
|
+
* Returns `null` for an unknown name rather than 404ing, because a layout asking for a
|
|
2945
|
+
* menu it has not created yet is the normal state of a site being built, and a thrown
|
|
2946
|
+
* error there takes down every page instead of rendering no nav. */
|
|
2947
|
+
getMenu: query(async (ctx, input: { name: string }): Promise<Menu | null> => {
|
|
2948
|
+
const rows = await cdb(ctx).find({ from: "cms_menus", where: { name: input.name }, limit: 1 });
|
|
2949
|
+
const row = rows[0];
|
|
2950
|
+
if (!row) return null;
|
|
2951
|
+
return resolveMenuRow(cdb(ctx), row);
|
|
2952
|
+
}, {
|
|
2953
|
+
input: (raw): { name: string } => ({ name: assertKey(asObj(raw).name, "menu name") }),
|
|
2954
|
+
}),
|
|
2955
|
+
|
|
2956
|
+
/** Every menu, RAW (references unresolved) — the editor's list.
|
|
2957
|
+
*
|
|
2958
|
+
* Capped like every other list here. A site-furniture table is small by nature, which is
|
|
2959
|
+
* an argument for the cap being generous, not for its absence: an unbounded SELECT of a
|
|
2960
|
+
* wide row over RPC is the D1 failure shape from GitHub #22, and "small by nature" is
|
|
2961
|
+
* not a property the query planner knows about. */
|
|
2962
|
+
listMenus: query((ctx) => cdb(ctx).find({ from: "cms_menus", orderBy: { column: "label" }, limit: PAGE_LIST_MAX_LIMIT }), viewer),
|
|
2963
|
+
|
|
2964
|
+
createMenu: mutation(async (ctx, input: { name: string; label: string; items?: MenuItem[] }) => {
|
|
2965
|
+
const db = cdb(ctx);
|
|
2966
|
+
// A clean 409, as every sibling create handler gives. Without it a taken key surfaced
|
|
2967
|
+
// as the UNIQUE constraint's own 500 — and the e2e only asserts "not 200", so it
|
|
2968
|
+
// could not tell the two apart.
|
|
2969
|
+
const clash = await db.find({ from: "cms_menus", where: { name: input.name }, select: ["id"], limit: 1 });
|
|
2970
|
+
if (clash[0]) throw new Conflict(`menu '${input.name}' already exists`);
|
|
2971
|
+
return db.insert("cms_menus", { name: input.name, label: input.label, items: input.items ?? [] });
|
|
2972
|
+
}, {
|
|
2973
|
+
...editor,
|
|
2974
|
+
input: (raw): { name: string; label: string; items?: MenuItem[] } => {
|
|
2975
|
+
const o = asObj(raw);
|
|
2976
|
+
return { name: assertKey(o.name, "menu name"), label: assertLabel(o.label, "menu label"), items: normalizeMenuItems(o.items) };
|
|
2977
|
+
},
|
|
2978
|
+
}),
|
|
2979
|
+
|
|
2980
|
+
/** Patch a menu found by `id` or `name`. `name` is the key layout code resolves and is
|
|
2981
|
+
* NOT mutable — retitling is what `label` is for. */
|
|
2982
|
+
updateMenu: mutation(async (ctx, input: { id?: string; name?: string; label?: string; items?: MenuItem[]; expectedVersion?: number }) => {
|
|
2983
|
+
const db = cdb(ctx);
|
|
2984
|
+
const rows = await db.find({ from: "cms_menus", where: input.id ? { id: input.id } : { name: input.name }, limit: 1 });
|
|
2985
|
+
const row = rows[0];
|
|
2986
|
+
if (!row) throw notFound("menu");
|
|
2987
|
+
const patch: Record<string, unknown> = { updatedAt: nowStamp(), version: nextVersion(row, input.expectedVersion, "this menu") };
|
|
2988
|
+
if (input.label !== undefined) patch.label = input.label;
|
|
2989
|
+
if (input.items !== undefined) patch.items = input.items;
|
|
2990
|
+
return db.update("cms_menus", String(row.id), patch);
|
|
2991
|
+
}, {
|
|
2992
|
+
...editor,
|
|
2993
|
+
input: (raw): { id?: string; name?: string; label?: string; items?: MenuItem[]; expectedVersion?: number } => {
|
|
2994
|
+
const o = asObj(raw);
|
|
2995
|
+
const out: { id?: string; name?: string; label?: string; items?: MenuItem[]; expectedVersion?: number } = {};
|
|
2996
|
+
if (typeof o.id === "string") out.id = o.id;
|
|
2997
|
+
else if (typeof o.name === "string") out.name = assertKey(o.name, "menu name");
|
|
2998
|
+
else throw new BadRequest("id or name is required");
|
|
2999
|
+
if (o.label !== undefined) out.label = assertLabel(o.label, "menu label");
|
|
3000
|
+
if (o.items !== undefined) out.items = normalizeMenuItems(o.items);
|
|
3001
|
+
if (typeof o.expectedVersion === "number") out.expectedVersion = o.expectedVersion;
|
|
3002
|
+
return out;
|
|
3003
|
+
},
|
|
3004
|
+
}),
|
|
3005
|
+
|
|
3006
|
+
deleteMenu: mutation(async (ctx, input: { id: string }) => {
|
|
3007
|
+
const ok = await cdb(ctx).delete("cms_menus", input.id);
|
|
3008
|
+
if (!ok) throw notFound("menu");
|
|
3009
|
+
return { ok: true as const };
|
|
3010
|
+
}, {
|
|
3011
|
+
...editor,
|
|
3012
|
+
input: (raw): { id: string } => {
|
|
3013
|
+
const id = asObj(raw).id;
|
|
3014
|
+
if (typeof id !== "string" || id === "") throw new BadRequest("id is required");
|
|
3015
|
+
return { id };
|
|
3016
|
+
},
|
|
3017
|
+
}),
|
|
3018
|
+
|
|
3019
|
+
// ---- site furniture: redirects -------------------------------------------
|
|
3020
|
+
|
|
3021
|
+
/** Resolve a request path to its redirect, or `null`. PUBLIC and READ-ONLY: this is
|
|
3022
|
+
* what a front end calls on a 404, so it is anonymous traffic on the hot path.
|
|
3023
|
+
*
|
|
3024
|
+
* `enabled` is enforced by the public read POLICY, not by a `where` here, so a disabled
|
|
3025
|
+
* redirect is invisible to every anonymous read (this handler, a future listing, a
|
|
3026
|
+
* relation traversal) rather than to the one call that remembered to filter. */
|
|
3027
|
+
resolveRedirect: query(async (ctx, input: { path: string }): Promise<{ to: string; status: number } | null> => {
|
|
3028
|
+
const rows = await cdb(ctx).find({ from: "cms_redirects", where: { fromPath: input.path, enabled: true }, select: ["toPath", "status"], limit: 1 });
|
|
3029
|
+
const row = rows[0];
|
|
3030
|
+
return row ? { to: String(row.toPath), status: Number(row.status ?? 301) } : null;
|
|
3031
|
+
}, {
|
|
3032
|
+
input: (raw): { path: string } => ({ path: normalizeRedirectPath(asObj(raw).path) }),
|
|
3033
|
+
}),
|
|
3034
|
+
|
|
3035
|
+
listRedirects: query((ctx, input: { limit?: number; offset?: number }) => {
|
|
3036
|
+
return cdb(ctx).find({ from: "cms_redirects", orderBy: { column: "fromPath" }, limit: input.limit ?? PAGE_LIST_LIMIT, offset: input.offset ?? 0 });
|
|
3037
|
+
}, {
|
|
3038
|
+
...viewer,
|
|
3039
|
+
input: (raw): { limit?: number; offset?: number } => {
|
|
3040
|
+
const o = asObj(raw);
|
|
3041
|
+
return { limit: clampLimit(o.limit), offset: typeof o.offset === "number" && o.offset > 0 ? Math.floor(o.offset) : 0 };
|
|
3042
|
+
},
|
|
3043
|
+
}),
|
|
3044
|
+
|
|
3045
|
+
createRedirect: mutation(async (ctx, input: RedirectPatch & { fromPath: string; toPath: string }) => {
|
|
3046
|
+
// A redirect to itself is an infinite loop the moment it is enabled, and the browser
|
|
3047
|
+
// is what discovers it. Cheap to refuse here; impossible to diagnose from the outside.
|
|
3048
|
+
if (isSelfRedirect(input.fromPath, input.toPath)) throw new BadRequest("a redirect cannot point at itself");
|
|
3049
|
+
const db = cdb(ctx);
|
|
3050
|
+
const clash = await db.find({ from: "cms_redirects", where: { fromPath: input.fromPath }, select: ["id"], limit: 1 });
|
|
3051
|
+
if (clash[0]) throw new Conflict(`a redirect from '${input.fromPath}' already exists`);
|
|
3052
|
+
return db.insert("cms_redirects", {
|
|
3053
|
+
fromPath: input.fromPath,
|
|
3054
|
+
toPath: input.toPath,
|
|
3055
|
+
status: input.status ?? 301,
|
|
3056
|
+
enabled: input.enabled ?? true,
|
|
3057
|
+
note: input.note ?? null,
|
|
3058
|
+
});
|
|
3059
|
+
}, {
|
|
3060
|
+
...editor,
|
|
3061
|
+
input: (raw): RedirectPatch & { fromPath: string; toPath: string } => redirectPatch(raw, true) as RedirectPatch & { fromPath: string; toPath: string },
|
|
3062
|
+
}),
|
|
3063
|
+
|
|
3064
|
+
updateRedirect: mutation(async (ctx, input: RedirectPatch & { id: string }) => {
|
|
3065
|
+
const db = cdb(ctx);
|
|
3066
|
+
const rows = await db.find({ from: "cms_redirects", where: { id: input.id }, limit: 1 });
|
|
3067
|
+
const row = rows[0];
|
|
3068
|
+
if (!row) throw notFound("redirect");
|
|
3069
|
+
const from = input.fromPath ?? String(row.fromPath);
|
|
3070
|
+
const to = input.toPath ?? String(row.toPath);
|
|
3071
|
+
if (isSelfRedirect(from, to)) throw new BadRequest("a redirect cannot point at itself");
|
|
3072
|
+
if (input.fromPath && input.fromPath !== row.fromPath) {
|
|
3073
|
+
const clash = await db.find({ from: "cms_redirects", where: { fromPath: input.fromPath }, select: ["id"], limit: 1 });
|
|
3074
|
+
if (clash[0]) throw new Conflict(`a redirect from '${input.fromPath}' already exists`);
|
|
3075
|
+
}
|
|
3076
|
+
const patch: Record<string, unknown> = { updatedAt: nowStamp() };
|
|
3077
|
+
for (const k of ["fromPath", "toPath", "status", "enabled", "note"] as const) {
|
|
3078
|
+
if (input[k] !== undefined) patch[k] = input[k];
|
|
3079
|
+
}
|
|
3080
|
+
return db.update("cms_redirects", input.id, patch);
|
|
3081
|
+
}, {
|
|
3082
|
+
...editor,
|
|
3083
|
+
input: (raw): RedirectPatch & { id: string } => ({ id: requireId(raw), ...redirectPatch(raw, false) }),
|
|
3084
|
+
}),
|
|
3085
|
+
|
|
3086
|
+
deleteRedirect: mutation(async (ctx, input: { id: string }) => {
|
|
3087
|
+
const ok = await cdb(ctx).delete("cms_redirects", input.id);
|
|
3088
|
+
if (!ok) throw notFound("redirect");
|
|
3089
|
+
return { ok: true as const };
|
|
3090
|
+
}, {
|
|
3091
|
+
...editor,
|
|
3092
|
+
input: (raw): { id: string } => {
|
|
3093
|
+
const id = asObj(raw).id;
|
|
3094
|
+
if (typeof id !== "string" || id === "") throw new BadRequest("id is required");
|
|
3095
|
+
return { id };
|
|
3096
|
+
},
|
|
3097
|
+
}),
|
|
3098
|
+
|
|
3099
|
+
// ---- site furniture: taxonomies ------------------------------------------
|
|
3100
|
+
//
|
|
3101
|
+
// `category` and `tag` are not built in: they are two rows a deployment creates, the
|
|
3102
|
+
// same way it creates its content types. A vocabulary that is hierarchical allows
|
|
3103
|
+
// `parentId` on its terms; a flat one refuses it rather than storing something no
|
|
3104
|
+
// listing renders.
|
|
3105
|
+
|
|
3106
|
+
/** Every vocabulary. PUBLIC — like content types, a taxonomy's slug is structural (it
|
|
3107
|
+
* is a URL segment) and a front end routes on it. */
|
|
3108
|
+
listTaxonomies: query((ctx) => cdb(ctx).find({ from: "cms_taxonomies", orderBy: { column: "label" }, limit: PAGE_LIST_MAX_LIMIT })),
|
|
3109
|
+
|
|
3110
|
+
createTaxonomy: mutation(async (ctx, input: { slug: string; label: string; pluralLabel?: string; description?: string; hierarchical?: boolean }) => {
|
|
3111
|
+
const db = cdb(ctx);
|
|
3112
|
+
const clash = await db.find({ from: "cms_taxonomies", where: { slug: input.slug }, select: ["id"], limit: 1 });
|
|
3113
|
+
if (clash[0]) throw new Conflict(`taxonomy '${input.slug}' already exists`);
|
|
3114
|
+
return db.insert("cms_taxonomies", {
|
|
3115
|
+
slug: input.slug,
|
|
3116
|
+
label: input.label,
|
|
3117
|
+
pluralLabel: input.pluralLabel ?? null,
|
|
3118
|
+
description: input.description ?? null,
|
|
3119
|
+
hierarchical: input.hierarchical ?? false,
|
|
3120
|
+
});
|
|
3121
|
+
}, {
|
|
3122
|
+
...editor,
|
|
3123
|
+
input: (raw): { slug: string; label: string; pluralLabel?: string; description?: string; hierarchical?: boolean } => {
|
|
3124
|
+
const o = asObj(raw);
|
|
3125
|
+
return {
|
|
3126
|
+
slug: assertKey(o.slug, "taxonomy slug"),
|
|
3127
|
+
label: assertLabel(o.label, "taxonomy label"),
|
|
3128
|
+
pluralLabel: typeof o.pluralLabel === "string" ? o.pluralLabel : undefined,
|
|
3129
|
+
description: typeof o.description === "string" ? o.description : undefined,
|
|
3130
|
+
hierarchical: typeof o.hierarchical === "boolean" ? o.hierarchical : undefined,
|
|
3131
|
+
};
|
|
3132
|
+
},
|
|
3133
|
+
}),
|
|
3134
|
+
|
|
3135
|
+
/** Patch a vocabulary. `slug` is a URL segment and the key `listTerms` resolves, so it
|
|
3136
|
+
* is not mutable — the same rule content types and block types already follow.
|
|
3137
|
+
*
|
|
3138
|
+
* Turning `hierarchical` OFF is refused while any term still has a parent. Allowing it
|
|
3139
|
+
* would leave a stored hierarchy that no reader renders and no writer can clear, and
|
|
3140
|
+
* flattening the terms silently is a destructive edit behind a checkbox. */
|
|
3141
|
+
updateTaxonomy: mutation(async (ctx, input: { id: string; label?: string; pluralLabel?: string | null; description?: string | null; hierarchical?: boolean }) => {
|
|
3142
|
+
const db = cdb(ctx);
|
|
3143
|
+
const rows = await db.find({ from: "cms_taxonomies", where: { id: input.id }, limit: 1 });
|
|
3144
|
+
const row = rows[0];
|
|
3145
|
+
if (!row) throw notFound("taxonomy");
|
|
3146
|
+
if (input.hierarchical === false && row.hierarchical) {
|
|
3147
|
+
const nested = await db.find({ from: "cms_terms", where: { taxonomyId: input.id, parentId: { isNull: false } }, select: ["id"], limit: 1 });
|
|
3148
|
+
if (nested[0]) throw new BadRequest("this vocabulary still has nested terms — move them to the top level before making it flat");
|
|
3149
|
+
}
|
|
3150
|
+
const patch: Record<string, unknown> = {};
|
|
3151
|
+
for (const k of ["label", "pluralLabel", "description", "hierarchical"] as const) {
|
|
3152
|
+
if (input[k] !== undefined) patch[k] = input[k];
|
|
3153
|
+
}
|
|
3154
|
+
return db.update("cms_taxonomies", input.id, patch);
|
|
3155
|
+
}, {
|
|
3156
|
+
...editor,
|
|
3157
|
+
input: (raw): { id: string; label?: string; pluralLabel?: string | null; description?: string | null; hierarchical?: boolean } => {
|
|
3158
|
+
const o = asObj(raw);
|
|
3159
|
+
if (typeof o.id !== "string" || o.id === "") throw new BadRequest("id is required");
|
|
3160
|
+
const out: { id: string; label?: string; pluralLabel?: string | null; description?: string | null; hierarchical?: boolean } = { id: o.id };
|
|
3161
|
+
if (o.label !== undefined) out.label = assertLabel(o.label, "taxonomy label");
|
|
3162
|
+
if (o.pluralLabel !== undefined) out.pluralLabel = typeof o.pluralLabel === "string" ? o.pluralLabel : null;
|
|
3163
|
+
if (o.description !== undefined) out.description = typeof o.description === "string" ? o.description : null;
|
|
3164
|
+
if (typeof o.hierarchical === "boolean") out.hierarchical = o.hierarchical;
|
|
3165
|
+
return out;
|
|
3166
|
+
},
|
|
3167
|
+
}),
|
|
3168
|
+
|
|
3169
|
+
/** Delete a vocabulary. Its terms go with it, and their page assignments with those —
|
|
3170
|
+
* both by real `ON DELETE CASCADE`, so the cleanup is the DB's and cannot be half-done
|
|
3171
|
+
* by a handler that threw between two writes. */
|
|
3172
|
+
deleteTaxonomy: mutation(async (ctx, input: { id: string }) => {
|
|
3173
|
+
const ok = await cdb(ctx).delete("cms_taxonomies", input.id);
|
|
3174
|
+
if (!ok) throw notFound("taxonomy");
|
|
3175
|
+
return { ok: true as const };
|
|
3176
|
+
}, {
|
|
3177
|
+
...editor,
|
|
3178
|
+
input: (raw): { id: string } => {
|
|
3179
|
+
const id = asObj(raw).id;
|
|
3180
|
+
if (typeof id !== "string" || id === "") throw new BadRequest("id is required");
|
|
3181
|
+
return { id };
|
|
3182
|
+
},
|
|
3183
|
+
}),
|
|
3184
|
+
|
|
3185
|
+
/** One vocabulary's terms, flat, ordered. PUBLIC. */
|
|
3186
|
+
listTerms: query(async (ctx, input: { taxonomy: string }): Promise<Term[]> => {
|
|
3187
|
+
const db = cdb(ctx);
|
|
3188
|
+
const tax = await taxonomyBySlug(db, input.taxonomy);
|
|
3189
|
+
if (!tax) return [];
|
|
3190
|
+
const rows = await db.find({ from: "cms_terms", where: { taxonomyId: tax.id }, orderBy: [{ column: "position" }, { column: "label" }], limit: MAX_TERMS });
|
|
3191
|
+
return rows as unknown as Term[];
|
|
3192
|
+
}, {
|
|
3193
|
+
input: (raw): { taxonomy: string } => ({ taxonomy: assertKey(asObj(raw).taxonomy, "taxonomy slug") }),
|
|
3194
|
+
}),
|
|
3195
|
+
|
|
3196
|
+
/** One vocabulary's terms as a TREE. PUBLIC. Assembled here rather than by the caller
|
|
3197
|
+
* because a hierarchy is what the nav renders and every consumer would otherwise write
|
|
3198
|
+
* the same fold. */
|
|
3199
|
+
getTermTree: query(async (ctx, input: { taxonomy: string }): Promise<Term[]> => {
|
|
3200
|
+
const db = cdb(ctx);
|
|
3201
|
+
const tax = await taxonomyBySlug(db, input.taxonomy);
|
|
3202
|
+
if (!tax) return [];
|
|
3203
|
+
const rows = await db.find({ from: "cms_terms", where: { taxonomyId: tax.id }, limit: MAX_TERMS });
|
|
3204
|
+
return buildTermTree(rows as unknown as Term[]);
|
|
3205
|
+
}, {
|
|
3206
|
+
input: (raw): { taxonomy: string } => ({ taxonomy: assertKey(asObj(raw).taxonomy, "taxonomy slug") }),
|
|
3207
|
+
}),
|
|
3208
|
+
|
|
3209
|
+
createTerm: mutation(async (ctx, input: { taxonomy: string; slug: string; label: string; description?: string; parentId?: string | null; position?: number }) => {
|
|
3210
|
+
const db = cdb(ctx);
|
|
3211
|
+
const tax = await taxonomyBySlug(db, input.taxonomy);
|
|
3212
|
+
if (!tax) throw notFound("taxonomy");
|
|
3213
|
+
await assertTermParent(db, tax, input.parentId ?? null, null);
|
|
3214
|
+
const clash = await db.find({ from: "cms_terms", where: { taxonomyId: tax.id, slug: input.slug }, select: ["id"], limit: 1 });
|
|
3215
|
+
if (clash[0]) throw new Conflict(`term '${input.slug}' already exists in '${input.taxonomy}'`);
|
|
3216
|
+
return db.insert("cms_terms", {
|
|
3217
|
+
taxonomyId: tax.id,
|
|
3218
|
+
slug: input.slug,
|
|
3219
|
+
label: input.label,
|
|
3220
|
+
description: input.description ?? null,
|
|
3221
|
+
parentId: input.parentId ?? null,
|
|
3222
|
+
position: input.position ?? 0,
|
|
3223
|
+
});
|
|
3224
|
+
}, {
|
|
3225
|
+
...editor,
|
|
3226
|
+
input: (raw): { taxonomy: string; slug: string; label: string; description?: string; parentId?: string | null; position?: number } => {
|
|
3227
|
+
const o = asObj(raw);
|
|
3228
|
+
return {
|
|
3229
|
+
taxonomy: assertKey(o.taxonomy, "taxonomy slug"),
|
|
3230
|
+
slug: assertKey(o.slug, "term slug"),
|
|
3231
|
+
label: assertLabel(o.label, "term label"),
|
|
3232
|
+
description: typeof o.description === "string" ? o.description : undefined,
|
|
3233
|
+
parentId: typeof o.parentId === "string" && o.parentId !== "" ? o.parentId : null,
|
|
3234
|
+
position: typeof o.position === "number" ? Math.trunc(o.position) : undefined,
|
|
3235
|
+
};
|
|
3236
|
+
},
|
|
3237
|
+
}),
|
|
3238
|
+
|
|
3239
|
+
updateTerm: mutation(async (ctx, input: { id: string; slug?: string; label?: string; description?: string | null; parentId?: string | null; position?: number }) => {
|
|
3240
|
+
const db = cdb(ctx);
|
|
3241
|
+
const rows = await db.find({ from: "cms_terms", where: { id: input.id }, limit: 1 });
|
|
3242
|
+
const row = rows[0];
|
|
3243
|
+
if (!row) throw notFound("term");
|
|
3244
|
+
const taxRows = await db.find({ from: "cms_taxonomies", where: { id: row.taxonomyId }, limit: 1 });
|
|
3245
|
+
const tax = taxRows[0];
|
|
3246
|
+
if (!tax) throw notFound("taxonomy");
|
|
3247
|
+
if (input.parentId !== undefined) {
|
|
3248
|
+
await assertTermParent(db, { id: String(tax.id), hierarchical: Boolean(tax.hierarchical) }, input.parentId, input.id);
|
|
3249
|
+
}
|
|
3250
|
+
// A term's slug IS mutable, unlike a taxonomy's: it is the leaf of a URL, an editor
|
|
3251
|
+
// fixing a typo in one is routine, and the uniqueness that matters is enforced below
|
|
3252
|
+
// and by the composite index behind it.
|
|
3253
|
+
if (input.slug && input.slug !== row.slug) {
|
|
3254
|
+
const clash = await db.find({ from: "cms_terms", where: { taxonomyId: row.taxonomyId, slug: input.slug }, select: ["id"], limit: 1 });
|
|
3255
|
+
if (clash[0]) throw new Conflict(`term '${input.slug}' already exists in this vocabulary`);
|
|
3256
|
+
}
|
|
3257
|
+
const patch: Record<string, unknown> = {};
|
|
3258
|
+
for (const k of ["slug", "label", "description", "parentId", "position"] as const) {
|
|
3259
|
+
if (input[k] !== undefined) patch[k] = input[k];
|
|
3260
|
+
}
|
|
3261
|
+
return db.update("cms_terms", input.id, patch);
|
|
3262
|
+
}, {
|
|
3263
|
+
...editor,
|
|
3264
|
+
input: (raw): { id: string; slug?: string; label?: string; description?: string | null; parentId?: string | null; position?: number } => {
|
|
3265
|
+
const o = asObj(raw);
|
|
3266
|
+
if (typeof o.id !== "string" || o.id === "") throw new BadRequest("id is required");
|
|
3267
|
+
const out: { id: string; slug?: string; label?: string; description?: string | null; parentId?: string | null; position?: number } = { id: o.id };
|
|
3268
|
+
if (o.slug !== undefined) out.slug = assertKey(o.slug, "term slug");
|
|
3269
|
+
if (o.label !== undefined) out.label = assertLabel(o.label, "term label");
|
|
3270
|
+
if (o.description !== undefined) out.description = typeof o.description === "string" ? o.description : null;
|
|
3271
|
+
if (o.parentId !== undefined) out.parentId = typeof o.parentId === "string" && o.parentId !== "" ? o.parentId : null;
|
|
3272
|
+
if (typeof o.position === "number") out.position = Math.trunc(o.position);
|
|
3273
|
+
return out;
|
|
3274
|
+
},
|
|
3275
|
+
}),
|
|
3276
|
+
|
|
3277
|
+
/** Delete a term. Children are promoted to the top level (`ON DELETE SET NULL`) and
|
|
3278
|
+
* page assignments are removed (`ON DELETE CASCADE`) — see `cms_terms.parentId`. */
|
|
3279
|
+
deleteTerm: mutation(async (ctx, input: { id: string }) => {
|
|
3280
|
+
const ok = await cdb(ctx).delete("cms_terms", input.id);
|
|
3281
|
+
if (!ok) throw notFound("term");
|
|
3282
|
+
return { ok: true as const };
|
|
3283
|
+
}, {
|
|
3284
|
+
...editor,
|
|
3285
|
+
input: (raw): { id: string } => {
|
|
3286
|
+
const id = asObj(raw).id;
|
|
3287
|
+
if (typeof id !== "string" || id === "") throw new BadRequest("id is required");
|
|
3288
|
+
return { id };
|
|
3289
|
+
},
|
|
3290
|
+
}),
|
|
3291
|
+
|
|
3292
|
+
/** A page's assigned terms. PUBLIC (a published page's classification is public). */
|
|
3293
|
+
listPageTerms: query(async (ctx, input: { pageId: string }): Promise<Term[]> => {
|
|
3294
|
+
const db = cdb(ctx);
|
|
3295
|
+
// Read the PAGE first, through `ctx.db`, so the caller's own page scope decides
|
|
3296
|
+
// whether this answers at all. Without it the handler never touched `cms_pages` — and
|
|
3297
|
+
// the public grants on the junction and on terms are unscoped `allow()` — so anyone
|
|
3298
|
+
// holding a page id could read a draft or trashed page's classification, and the
|
|
3299
|
+
// non-empty answer confirmed the page exists. `listPagesByTerm` was already safe for
|
|
3300
|
+
// the opposite reason: it traverses `where: { terms: … }`, so the page scope
|
|
3301
|
+
// AND-merges. This is the same rule, applied from the other end.
|
|
3302
|
+
const page = await db.find({ from: "cms_pages", where: { id: input.pageId }, select: ["id"], limit: 1 });
|
|
3303
|
+
if (!page[0]) return [];
|
|
3304
|
+
const links = await db.find({ from: "cms_page_terms", where: { pageId: input.pageId }, select: ["termId"], limit: MAX_TERMS });
|
|
3305
|
+
const ids = links.map((l) => String(l.termId));
|
|
3306
|
+
if (ids.length === 0) return [];
|
|
3307
|
+
const rows = await db.find({ from: "cms_terms", where: { id: { in: ids } }, orderBy: [{ column: "position" }, { column: "label" }], limit: ids.length });
|
|
3308
|
+
return rows as unknown as Term[];
|
|
3309
|
+
}, {
|
|
3310
|
+
input: (raw): { pageId: string } => {
|
|
3311
|
+
const id = asObj(raw).pageId;
|
|
3312
|
+
if (typeof id !== "string" || id === "") throw new BadRequest("pageId is required");
|
|
3313
|
+
return { pageId: id };
|
|
3314
|
+
},
|
|
3315
|
+
}),
|
|
3316
|
+
|
|
3317
|
+
/** Replace a page's term assignments wholesale.
|
|
3318
|
+
*
|
|
3319
|
+
* Set semantics, not add/remove: the editor's panel holds the whole selection, and two
|
|
3320
|
+
* calls that each patch one end of it race into a state neither asked for. Existing
|
|
3321
|
+
* links that survive are LEFT ALONE rather than deleted and reinserted, so the junction
|
|
3322
|
+
* rows (and any future column on them) are stable across a save that changed nothing. */
|
|
3323
|
+
setPageTerms: mutation(async (ctx, input: { pageId: string; termIds: string[] }) => {
|
|
3324
|
+
const db = cdb(ctx);
|
|
3325
|
+
const pages = await db.find({ from: "cms_pages", where: { id: input.pageId }, select: ["id"], limit: 1 });
|
|
3326
|
+
if (!pages[0]) throw notFound("page");
|
|
3327
|
+
const wanted = new Set(input.termIds);
|
|
3328
|
+
if (wanted.size > 0) {
|
|
3329
|
+
// Every id must be a real term. Without this the junction happily stores a dangling
|
|
3330
|
+
// uuid — the FK would catch it, but as a driver error with no HTTP status.
|
|
3331
|
+
const found = await db.find({ from: "cms_terms", where: { id: { in: [...wanted] } }, select: ["id"], limit: wanted.size });
|
|
3332
|
+
if (found.length !== wanted.size) throw new BadRequest("one or more termIds are not terms");
|
|
3333
|
+
}
|
|
3334
|
+
const existing = await db.find({ from: "cms_page_terms", where: { pageId: input.pageId }, select: ["id", "termId"], limit: MAX_TERMS });
|
|
3335
|
+
const have = new Map(existing.map((l) => [String(l.termId), String(l.id)]));
|
|
3336
|
+
for (const [termId, linkId] of have) if (!wanted.has(termId)) await db.delete("cms_page_terms", linkId);
|
|
3337
|
+
for (const termId of wanted) if (!have.has(termId)) await db.insert("cms_page_terms", { pageId: input.pageId, termId });
|
|
3338
|
+
return { ok: true as const, count: wanted.size };
|
|
3339
|
+
}, {
|
|
3340
|
+
...editor,
|
|
3341
|
+
input: (raw): { pageId: string; termIds: string[] } => {
|
|
3342
|
+
const o = asObj(raw);
|
|
3343
|
+
if (typeof o.pageId !== "string" || o.pageId === "") throw new BadRequest("pageId is required");
|
|
3344
|
+
if (!Array.isArray(o.termIds)) throw new BadRequest("termIds must be a list");
|
|
3345
|
+
const ids = o.termIds.map((v) => {
|
|
3346
|
+
if (typeof v !== "string" || v === "") throw new BadRequest("termIds must be a list of ids");
|
|
3347
|
+
return v;
|
|
3348
|
+
});
|
|
3349
|
+
if (ids.length > MAX_TERMS) throw new BadRequest(`a page may carry at most ${MAX_TERMS} terms`);
|
|
3350
|
+
return { pageId: o.pageId, termIds: ids };
|
|
3351
|
+
},
|
|
3352
|
+
}),
|
|
3353
|
+
|
|
3354
|
+
/** Published pages carrying a term. PUBLIC.
|
|
3355
|
+
*
|
|
3356
|
+
* A relation traversal (`where: { terms: { id } }`), so the page read scope is
|
|
3357
|
+
* AND-merged as it is anywhere else — traversal cannot widen access, and an anonymous
|
|
3358
|
+
* caller sees published pages only. */
|
|
3359
|
+
listPagesByTerm: query(async (ctx, input: { taxonomy: string; term: string; limit?: number; offset?: number }) => {
|
|
3360
|
+
const db = cdb(ctx);
|
|
3361
|
+
const tax = await taxonomyBySlug(db, input.taxonomy);
|
|
3362
|
+
if (!tax) return [];
|
|
3363
|
+
const terms = await db.find({ from: "cms_terms", where: { taxonomyId: tax.id, slug: input.term }, select: ["id"], limit: 1 });
|
|
3364
|
+
const term = terms[0];
|
|
3365
|
+
if (!term) return [];
|
|
3366
|
+
return db.find({
|
|
3367
|
+
from: "cms_pages",
|
|
3368
|
+
where: { terms: { id: String(term.id) } },
|
|
3369
|
+
orderBy: { column: "createdAt", dir: "desc" },
|
|
3370
|
+
select: ["id", "typeId", "title", "slug", "locale", "status", "publishedAt"],
|
|
3371
|
+
limit: input.limit ?? PAGE_LIST_LIMIT,
|
|
3372
|
+
offset: input.offset ?? 0,
|
|
3373
|
+
});
|
|
3374
|
+
}, {
|
|
3375
|
+
input: (raw): { taxonomy: string; term: string; limit?: number; offset?: number } => {
|
|
3376
|
+
const o = asObj(raw);
|
|
3377
|
+
return {
|
|
3378
|
+
taxonomy: assertKey(o.taxonomy, "taxonomy slug"),
|
|
3379
|
+
term: assertKey(o.term, "term slug"),
|
|
3380
|
+
limit: clampLimit(o.limit),
|
|
3381
|
+
offset: typeof o.offset === "number" && o.offset > 0 ? Math.floor(o.offset) : 0,
|
|
3382
|
+
};
|
|
3383
|
+
},
|
|
3384
|
+
}),
|
|
3385
|
+
|
|
3386
|
+
// ---- site furniture: widget areas ----------------------------------------
|
|
3387
|
+
|
|
3388
|
+
/** One widget area, with `menu` widgets resolved to their menus. PUBLIC.
|
|
3389
|
+
*
|
|
3390
|
+
* `null` for an unknown name, for the same reason `getMenu` returns null: a layout
|
|
3391
|
+
* asking for a sidebar nobody has filled in yet is the normal state of a site under
|
|
3392
|
+
* construction, and throwing there takes down the page. */
|
|
3393
|
+
getWidgetArea: query(async (ctx, input: { name: string }): Promise<WidgetArea | null> => {
|
|
3394
|
+
const db = cdb(ctx);
|
|
3395
|
+
const rows = await db.find({ from: "cms_widget_areas", where: { name: input.name }, limit: 1 });
|
|
3396
|
+
const row = rows[0];
|
|
3397
|
+
if (!row) return null;
|
|
3398
|
+
const widgets = Array.isArray(row.widgets) ? (row.widgets as Widget[]) : [];
|
|
3399
|
+
// Resolved inline so a layout renders a whole sidebar from ONE call. A menu widget
|
|
3400
|
+
// that names a menu which no longer exists keeps `menu: null` rather than being
|
|
3401
|
+
// dropped — unlike a menu ITEM, an empty widget is a visible hole an editor can see
|
|
3402
|
+
// and fix, where a silently missing one is not.
|
|
3403
|
+
const names = [...new Set(widgets.filter((w) => w.type === "menu" && w.menuName).map((w) => w.menuName!))];
|
|
3404
|
+
const menus = new Map<string, Menu>();
|
|
3405
|
+
if (names.length > 0) {
|
|
3406
|
+
const found = await db.find({ from: "cms_menus", where: { name: { in: names } }, limit: names.length });
|
|
3407
|
+
// RESOLVED, exactly as `getMenu` returns it. Embedding the raw row here served an
|
|
3408
|
+
// unpublished page's label and id to anonymous callers and handed the layout an
|
|
3409
|
+
// item with no `url` — the two things the resolver exists to prevent, skipped
|
|
3410
|
+
// because this path had its own one-line copy of "read the menu".
|
|
3411
|
+
for (const m of found) menus.set(String(m.name), await resolveMenuRow(db, m));
|
|
3412
|
+
}
|
|
3413
|
+
return {
|
|
3414
|
+
id: String(row.id),
|
|
3415
|
+
name: String(row.name),
|
|
3416
|
+
label: String(row.label),
|
|
3417
|
+
description: row.description == null ? null : String(row.description),
|
|
3418
|
+
widgets: widgets.map((w) => (w.type === "menu" && w.menuName ? { ...w, menu: menus.get(w.menuName) ?? null } : w)),
|
|
3419
|
+
};
|
|
3420
|
+
}, {
|
|
3421
|
+
input: (raw): { name: string } => ({ name: assertKey(asObj(raw).name, "widget area name") }),
|
|
3422
|
+
}),
|
|
3423
|
+
|
|
3424
|
+
listWidgetAreas: query((ctx) => cdb(ctx).find({ from: "cms_widget_areas", orderBy: { column: "label" }, limit: PAGE_LIST_MAX_LIMIT }), viewer),
|
|
3425
|
+
|
|
3426
|
+
createWidgetArea: mutation(async (ctx, input: { name: string; label: string; description?: string; widgets?: Widget[] }) => {
|
|
3427
|
+
const db = cdb(ctx);
|
|
3428
|
+
const clash = await db.find({ from: "cms_widget_areas", where: { name: input.name }, select: ["id"], limit: 1 });
|
|
3429
|
+
if (clash[0]) throw new Conflict(`widget area '${input.name}' already exists`);
|
|
3430
|
+
return db.insert("cms_widget_areas", {
|
|
3431
|
+
name: input.name,
|
|
3432
|
+
label: input.label,
|
|
3433
|
+
description: input.description ?? null,
|
|
3434
|
+
widgets: input.widgets ?? [],
|
|
3435
|
+
});
|
|
3436
|
+
}, {
|
|
3437
|
+
...editor,
|
|
3438
|
+
input: (raw): { name: string; label: string; description?: string; widgets?: Widget[] } => {
|
|
3439
|
+
const o = asObj(raw);
|
|
3440
|
+
return {
|
|
3441
|
+
name: assertKey(o.name, "widget area name"),
|
|
3442
|
+
label: assertLabel(o.label, "widget area label"),
|
|
3443
|
+
description: typeof o.description === "string" ? o.description : undefined,
|
|
3444
|
+
widgets: normalizeWidgets(o.widgets, rtSchema),
|
|
3445
|
+
};
|
|
3446
|
+
},
|
|
3447
|
+
}),
|
|
3448
|
+
|
|
3449
|
+
updateWidgetArea: mutation(async (ctx, input: { id?: string; name?: string; label?: string; description?: string | null; widgets?: Widget[]; expectedVersion?: number }) => {
|
|
3450
|
+
const db = cdb(ctx);
|
|
3451
|
+
const rows = await db.find({ from: "cms_widget_areas", where: input.id ? { id: input.id } : { name: input.name }, limit: 1 });
|
|
3452
|
+
const row = rows[0];
|
|
3453
|
+
if (!row) throw notFound("widget area");
|
|
3454
|
+
const patch: Record<string, unknown> = { updatedAt: nowStamp(), version: nextVersion(row, input.expectedVersion, "this widget area") };
|
|
3455
|
+
for (const k of ["label", "description", "widgets"] as const) {
|
|
3456
|
+
if (input[k] !== undefined) patch[k] = input[k];
|
|
3457
|
+
}
|
|
3458
|
+
return db.update("cms_widget_areas", String(row.id), patch);
|
|
3459
|
+
}, {
|
|
3460
|
+
...editor,
|
|
3461
|
+
input: (raw): { id?: string; name?: string; label?: string; description?: string | null; widgets?: Widget[]; expectedVersion?: number } => {
|
|
3462
|
+
const o = asObj(raw);
|
|
3463
|
+
const out: { id?: string; name?: string; label?: string; description?: string | null; widgets?: Widget[]; expectedVersion?: number } = {};
|
|
3464
|
+
if (typeof o.id === "string") out.id = o.id;
|
|
3465
|
+
else if (typeof o.name === "string") out.name = assertKey(o.name, "widget area name");
|
|
3466
|
+
else throw new BadRequest("id or name is required");
|
|
3467
|
+
if (o.label !== undefined) out.label = assertLabel(o.label, "widget area label");
|
|
3468
|
+
if (o.description !== undefined) out.description = typeof o.description === "string" ? o.description : null;
|
|
3469
|
+
if (o.widgets !== undefined) out.widgets = normalizeWidgets(o.widgets, rtSchema);
|
|
3470
|
+
if (typeof o.expectedVersion === "number") out.expectedVersion = o.expectedVersion;
|
|
3471
|
+
return out;
|
|
3472
|
+
},
|
|
3473
|
+
}),
|
|
3474
|
+
|
|
3475
|
+
deleteWidgetArea: mutation(async (ctx, input: { id: string }) => {
|
|
3476
|
+
const ok = await cdb(ctx).delete("cms_widget_areas", input.id);
|
|
3477
|
+
if (!ok) throw notFound("widget area");
|
|
3478
|
+
return { ok: true as const };
|
|
3479
|
+
}, {
|
|
3480
|
+
...editor,
|
|
3481
|
+
input: (raw): { id: string } => {
|
|
3482
|
+
const id = asObj(raw).id;
|
|
3483
|
+
if (typeof id !== "string" || id === "") throw new BadRequest("id is required");
|
|
3484
|
+
return { id };
|
|
1626
3485
|
},
|
|
1627
3486
|
}),
|
|
1628
3487
|
|
|
@@ -2089,7 +3948,30 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
2089
3948
|
* feature would render N tabs all showing every type's pages under a heading claiming
|
|
2090
3949
|
* otherwise — and "New page" from any of them would stamp that tab's type. Declared, not
|
|
2091
3950
|
* inferred: fail closed on the pooled list rather than open on N lying ones. */
|
|
2092
|
-
listCmsCapabilities: query(() => ({
|
|
3951
|
+
listCmsCapabilities: query((ctx) => ({
|
|
3952
|
+
locales,
|
|
3953
|
+
defaultLocale,
|
|
3954
|
+
multilingual: locales.length > 1,
|
|
3955
|
+
pagesByType: true as const,
|
|
3956
|
+
// Same kind of declaration as `pagesByType`, for the same reason: an OLDER server has
|
|
3957
|
+
// no menu/redirect/taxonomy/widget handlers at all, and a nav section whose every
|
|
3958
|
+
// screen 404s is worse than one that is absent. Fails closed by being absent there.
|
|
3959
|
+
siteFurniture: true as const,
|
|
3960
|
+
// Whether `managedBy` means anything on this server. Declared for the same reason as
|
|
3961
|
+
// its neighbours: `@pramen/cms-editor` is a separate package with no dependency on
|
|
3962
|
+
// `@pramen/cms`, so a newer editor CAN run against an older server — where every row
|
|
3963
|
+
// reports no owner, nothing renders read-only, the save succeeds, and it is reverted at
|
|
3964
|
+
// the next cold start. That is GitHub #48 in the deployment that upgraded the editor to
|
|
3965
|
+
// fix it. Absent ⇒ the editor treats no type as code-defined, which is correct there.
|
|
3966
|
+
codeDefinedTypes: true as const,
|
|
3967
|
+
// PER-CALLER, unlike everything else here. `viewer` is `editorRoles ∪ reviewerRoles`,
|
|
3968
|
+
// so a reviewer-only session reaches this handler and every read handler — but every
|
|
3969
|
+
// WRITE is `editorRoles`. Without this the editor renders the authoring surfaces
|
|
3970
|
+
// (Types, Menus, Redirects, …) for a reviewer and each one 403s on its first save.
|
|
3971
|
+
// The editor cannot work it out for itself: it knows the caller's roles from `me` but
|
|
3972
|
+
// not which roles this deployment configured as `editorRoles`.
|
|
3973
|
+
canEdit: isEditor(ctx, editorRoles),
|
|
3974
|
+
}), viewer),
|
|
2093
3975
|
|
|
2094
3976
|
/** Distinct locales present across all pages. NOTE: a DATA query — what is in the
|
|
2095
3977
|
* store — not configuration. `listCmsCapabilities().locales` is what the deployment
|
|
@@ -2448,11 +4330,11 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
2448
4330
|
signPagePreview: query(async (ctx, input: { pageId: string; expiresIn?: number }) => {
|
|
2449
4331
|
const secret = previewSecret(ctx.env);
|
|
2450
4332
|
if (!secret) throw previewUnconfigured(); // fail closed — never mint a forgeable link
|
|
2451
|
-
//
|
|
2452
|
-
//
|
|
2453
|
-
//
|
|
2454
|
-
//
|
|
2455
|
-
|
|
4333
|
+
// This used to refuse on the D1 store: redemption goes through `ctx.callPrivileged`,
|
|
4334
|
+
// which only forwarded to a DO, so a link minted on D1 would have 404'd forever while
|
|
4335
|
+
// the editor reported success. `callPrivileged` now dispatches locally in the Worker
|
|
4336
|
+
// on D1, so both stores mint. The redeem route is a BROWSER request carrying no
|
|
4337
|
+
// `x-pramen-store`, so a D1 deployment still needs `PRAMEN_STORE=d1` to route it.
|
|
2456
4338
|
const db = cdb(ctx);
|
|
2457
4339
|
// Read the page through the ACL first: minting a link is granting access to it, so a
|
|
2458
4340
|
// caller who cannot read the page must not be able to mint a link that can.
|
|
@@ -2693,7 +4575,12 @@ export function cmsPolicies(opts: CmsPolicyOpts = {}): { public: Policy[]; edito
|
|
|
2693
4575
|
// any app handler, silently overriding the read+create grant `collectionPolicies` emits —
|
|
2694
4576
|
// duplicate policies on the same (role, entity, action) OR-merge, so the wider one wins.
|
|
2695
4577
|
// The collection half owns that table's grant; see `collectionPolicies`.
|
|
2696
|
-
const tables = [
|
|
4578
|
+
const tables = [
|
|
4579
|
+
"cms_content_types", "cms_block_types", "cms_blocks", "cms_pages", "cms_page_blocks", "cms_page_revisions", "cms_media", "cms_audit",
|
|
4580
|
+
// Site furniture. Full CRUD for an editor, like every other cms_ table — the per-handler
|
|
4581
|
+
// `auth` gate is what separates editor from reviewer; this is the row scope.
|
|
4582
|
+
"cms_menus", "cms_redirects", "cms_taxonomies", "cms_terms", "cms_page_terms", "cms_widget_areas",
|
|
4583
|
+
] as const;
|
|
2697
4584
|
// Soft-deleted rows are filtered in the ACL, not in each handler. A read scope is
|
|
2698
4585
|
// AND-merged into every `ctx.db` read, so one policy hides a trashed row from the public
|
|
2699
4586
|
// API, the editor, relation traversals and eager-loads at once — where a per-handler
|
|
@@ -2733,6 +4620,23 @@ export function cmsPolicies(opts: CmsPolicyOpts = {}): { public: Policy[]; edito
|
|
|
2733
4620
|
policy(`${p}:public:revisions:read`, "cms_page_revisions", "read", { where: { page: { status: "published", deletedAt: { isNull: true } } } }),
|
|
2734
4621
|
// Media metadata is public (the bytes are separately gated by signed urls).
|
|
2735
4622
|
policy(`${p}:public:media:read`, "cms_media", "read", { where: { deletedAt: { isNull: true } } }),
|
|
4623
|
+
// --- site furniture ---
|
|
4624
|
+
// A menu is site chrome, rendered on every page. `getMenu` resolves its page
|
|
4625
|
+
// references THROUGH `ctx.db`, so the public page scope above is what decides whether
|
|
4626
|
+
// a link to an unpublished page renders — the menu grant does not widen it.
|
|
4627
|
+
policy(`${p}:public:menus:read`, "cms_menus", "read", allow()),
|
|
4628
|
+
// Only ENABLED redirects. `resolveRedirect` also filters, but the scope is the real
|
|
4629
|
+
// boundary: a disabled redirect is one an editor has deliberately taken out of
|
|
4630
|
+
// service, and it must stay invisible to every anonymous read, not just that one.
|
|
4631
|
+
policy(`${p}:public:redirects:read`, "cms_redirects", "read", { where: { enabled: true } }),
|
|
4632
|
+
// Taxonomies and terms are structural (they are URL segments a front end routes on),
|
|
4633
|
+
// exactly like content-type slugs. The junction is granted too, because
|
|
4634
|
+
// `where: { terms: … }` on a page compiles to a subquery THROUGH it — without the
|
|
4635
|
+
// grant the traversal matches nothing and "pages in this category" is silently empty.
|
|
4636
|
+
policy(`${p}:public:taxonomies:read`, "cms_taxonomies", "read", allow()),
|
|
4637
|
+
policy(`${p}:public:terms:read`, "cms_terms", "read", allow()),
|
|
4638
|
+
policy(`${p}:public:page-terms:read`, "cms_page_terms", "read", allow()),
|
|
4639
|
+
policy(`${p}:public:widget-areas:read`, "cms_widget_areas", "read", allow()),
|
|
2736
4640
|
],
|
|
2737
4641
|
editor: editorPolicies,
|
|
2738
4642
|
};
|
|
@@ -2755,6 +4659,12 @@ export function cmsPolicies(opts: CmsPolicyOpts = {}): { public: Policy[]; edito
|
|
|
2755
4659
|
// table, and writes are whitelisted to declared fields — the client can't set columns the
|
|
2756
4660
|
// collection didn't declare (e.g. a `roles` or `passwordHash` column on the entity).
|
|
2757
4661
|
|
|
4662
|
+
/** Nav positions for the editor's built-in sections — see `./nav`. Re-exported here so a
|
|
4663
|
+
* host writing `app.ts` imports it from the same place as `collection()`. It LIVES in a leaf
|
|
4664
|
+
* module because `blockkit.ts` needs it too and must not depend on this one. */
|
|
4665
|
+
export { NAV_ORDER } from "./nav";
|
|
4666
|
+
|
|
4667
|
+
|
|
2758
4668
|
/** Declares that a pramen entity is editable as a collection in the CMS editor. Both
|
|
2759
4669
|
* halves live here: the runtime facts (entity, idField, validation via `fields`) and the
|
|
2760
4670
|
* UI facts (labels, list columns, ordering). Mirror of {@link ContentTypeDef}. */
|
|
@@ -2783,6 +4693,16 @@ export interface CollectionDef {
|
|
|
2783
4693
|
readonly idField?: string;
|
|
2784
4694
|
/** Default list ordering; defaults to `{ column: "createdAt", dir: "desc" }`. */
|
|
2785
4695
|
readonly orderBy?: { column: string; dir?: "asc" | "desc" };
|
|
4696
|
+
/** Where this collection sits in the editor's primary nav. Lower comes first; ties keep
|
|
4697
|
+
* declaration order. Defaults to `NAV_ORDER.collections`, which is between Pages and
|
|
4698
|
+
* Media — where collections have always rendered.
|
|
4699
|
+
*
|
|
4700
|
+
* The point is not cosmetic. Before this the only extension seam was `extraNav`, which
|
|
4701
|
+
* renders dead LAST and (by default, and for good reason) opens a new tab — so a
|
|
4702
|
+
* project-specific section could only ever be a separate app bolted onto the end of the
|
|
4703
|
+
* admin. A section that belongs beside Pages can now say so. See {@link NAV_ORDER} for
|
|
4704
|
+
* the built-in positions to place against. */
|
|
4705
|
+
readonly navOrder?: number;
|
|
2786
4706
|
/** Workflow features this collection opts into — see {@link CollectionFeature}. Each is
|
|
2787
4707
|
* backed by MANAGED COLUMNS on `entity` that the CMS writes and `fields` may not declare.
|
|
2788
4708
|
* Validated against your schema at `createCollectionHandlers` time (which is why that call
|
|
@@ -2816,6 +4736,10 @@ export interface CollectionMeta {
|
|
|
2816
4736
|
label: string;
|
|
2817
4737
|
pluralLabel: string;
|
|
2818
4738
|
icon?: string;
|
|
4739
|
+
/** Nav position — see {@link CollectionDef.navOrder}. Always present in the meta (the
|
|
4740
|
+
* default is filled here) so the editor sorts one list of numbers rather than deciding
|
|
4741
|
+
* per entry whether a default applies. */
|
|
4742
|
+
navOrder: number;
|
|
2819
4743
|
fields: readonly FieldDefinition[];
|
|
2820
4744
|
list: readonly string[];
|
|
2821
4745
|
titleField: string;
|
|
@@ -2834,6 +4758,7 @@ function collectionMeta(c: CollectionDef): CollectionMeta {
|
|
|
2834
4758
|
label: c.label,
|
|
2835
4759
|
pluralLabel: c.pluralLabel ?? `${c.label}s`,
|
|
2836
4760
|
icon: c.icon,
|
|
4761
|
+
navOrder: c.navOrder ?? NAV_ORDER.collections,
|
|
2837
4762
|
fields: c.fields,
|
|
2838
4763
|
list: c.list ?? [titleField],
|
|
2839
4764
|
titleField,
|
|
@@ -2860,10 +4785,13 @@ function collectionMeta(c: CollectionDef): CollectionMeta {
|
|
|
2860
4785
|
// TIMESTAMP FORMAT. Managed timestamps are minted as ISO-8601 UTC with a `Z`
|
|
2861
4786
|
// (`2026-08-20T12:00:00.000Z`), in exactly one place (`isoStamp`). That is the format
|
|
2862
4787
|
// `$now()` produces, and the published-read scope compares against it LEXICOGRAPHICALLY.
|
|
2863
|
-
//
|
|
2864
|
-
//
|
|
2865
|
-
//
|
|
2866
|
-
//
|
|
4788
|
+
//
|
|
4789
|
+
// It used to be worth saying that this is NOT the shape `expr.now()` writes. It is now:
|
|
4790
|
+
// `expr.now()` emits the same ISO form, so a managed column and an `expr.now()` default
|
|
4791
|
+
// beside it are finally comparable, and `nowStamp` is an alias for `isoStamp` rather than a
|
|
4792
|
+
// second format. What still holds is the reason for minting in one place — the trap
|
|
4793
|
+
// documented on the `publish` field, where two controls wrote different formats into one
|
|
4794
|
+
// TEXT column and both passed validation.
|
|
2867
4795
|
|
|
2868
4796
|
/** A workflow feature a collection can opt into.
|
|
2869
4797
|
*
|
|
@@ -2918,11 +4846,6 @@ export const COLLECTION_PUBLISHED = "published";
|
|
|
2918
4846
|
const DEFAULT_COLLECTION_LIST_LIMIT = 100;
|
|
2919
4847
|
const MAX_COLLECTION_LIST_LIMIT = 500;
|
|
2920
4848
|
|
|
2921
|
-
/** An ISO-8601 UTC instant — the one format every managed collection timestamp is written
|
|
2922
|
-
* in, so it compares correctly against `$now()`. See the note above on why this is not
|
|
2923
|
-
* `nowStamp()`. */
|
|
2924
|
-
const isoStamp = (): string => new Date().toISOString();
|
|
2925
|
-
|
|
2926
4849
|
/** The epoch-ms range a schedule may name: 1970-01-01 up to (not including) year 10000.
|
|
2927
4850
|
*
|
|
2928
4851
|
* `Number.isFinite` is NOT a sufficient bound, in two directions. Above `8.64e15` (the max
|
|
@@ -2962,10 +4885,22 @@ const COLLECTION_FIELD_COLUMN_TYPES: Readonly<Record<FieldDefinition["type"], re
|
|
|
2962
4885
|
slug: ["text"],
|
|
2963
4886
|
media: ["text", "uuid"],
|
|
2964
4887
|
select: ["text"],
|
|
4888
|
+
// A SINGLE reference is one opaque id in a TEXT column. `multiple: true` stores an array
|
|
4889
|
+
// and needs `t.json()` — `referenceColumnTypes` below is what actually decides, because
|
|
4890
|
+
// this table is keyed by type alone and a reference is the one type whose storage depends
|
|
4891
|
+
// on a second flag.
|
|
4892
|
+
reference: ["text", "uuid"],
|
|
2965
4893
|
repeater: ["json"],
|
|
2966
4894
|
group: ["json"],
|
|
2967
4895
|
};
|
|
2968
4896
|
|
|
4897
|
+
/** The column types a field can be stored in, including the one case the type alone does
|
|
4898
|
+
* not settle (`reference` + `multiple`). */
|
|
4899
|
+
function fieldColumnTypes(f: FieldDefinition): readonly FieldType[] | undefined {
|
|
4900
|
+
if (f.type === "reference" && f.multiple) return ["json"];
|
|
4901
|
+
return COLLECTION_FIELD_COLUMN_TYPES[f.type];
|
|
4902
|
+
}
|
|
4903
|
+
|
|
2969
4904
|
/** Check a collection registry at BOOT: slugs and entities are unique, features are known
|
|
2970
4905
|
* and have their prerequisites, every declared field maps to a column that can hold it, and
|
|
2971
4906
|
* every managed column exists, has the shape the CMS writes, and is not also an editable
|
|
@@ -3057,7 +4992,7 @@ export function validateCollections(collections: readonly CollectionDef[], schem
|
|
|
3057
4992
|
`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`,
|
|
3058
4993
|
);
|
|
3059
4994
|
}
|
|
3060
|
-
const allowed =
|
|
4995
|
+
const allowed = fieldColumnTypes(f);
|
|
3061
4996
|
if (allowed && !allowed.includes(col.type)) {
|
|
3062
4997
|
const want = allowed.map((t) => `t.${t === "integer" ? "int" : t === "boolean" ? "bool" : t}()`).join(" or ");
|
|
3063
4998
|
throw new Error(
|
|
@@ -3691,7 +5626,7 @@ export function createCollectionHandlers(collections: readonly CollectionDef[],
|
|
|
3691
5626
|
// ---- preview ------------------------------------------------------------
|
|
3692
5627
|
|
|
3693
5628
|
/** Mint a signed link that shows ONE row's unpublished state, to whoever holds it.
|
|
3694
|
-
* Mirrors `signPagePreview` — same secret, same TTL clamp,
|
|
5629
|
+
* Mirrors `signPagePreview` — same secret, same TTL clamp, works on both stores, and the
|
|
3695
5630
|
* same rule that the row is read through the ACL FIRST: minting a link is granting
|
|
3696
5631
|
* access to the row, so a caller who cannot read it must not be able to mint one. */
|
|
3697
5632
|
signCollectionPreview: query(async (ctx, input: { collection: string; id: string; expiresIn?: number }) => {
|