@crombolo/content 0.0.0-stage → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,879 @@
1
+ /**
2
+ * Every document write, once (story 170). The backoffice, the command line, the
3
+ * scheduled job, approvals and form workflows all call these, so a write follows
4
+ * the same rules whoever starts it: the actor's permissions, the approval gate,
5
+ * one transaction, redirect tracking, and one event for the transport to announce.
6
+ *
7
+ * The repository keeps the SQL, the locking and the publish rules that guard
8
+ * every path (published ancestors, mandatory languages, filled mandatory fields).
9
+ * What it does not decide — who may, and whether a page needs approval — is here.
10
+ * docs/architecture.md, "Document writes".
11
+ */
12
+ import type { DocumentValidationError, DocumentValue } from '@crombolo/core'
13
+ import { normaliseUuid } from '@crombolo/core'
14
+ import {
15
+ appendCacheInstruction,
16
+ type Db,
17
+ DocumentRepository,
18
+ type DocumentRepositoryOptions,
19
+ DomainRepository,
20
+ InvalidParentError,
21
+ NodeRepository,
22
+ type NodeRow,
23
+ PublicAccessRepository,
24
+ PublishBlockedError,
25
+ type SetDomainsResult,
26
+ WriteRejectedError,
27
+ } from '@crombolo/data'
28
+ import type { Actor, ContentPrincipal } from './actor.ts'
29
+ import type { DocumentEvent, EventSink } from './events.ts'
30
+ import {
31
+ deniedPropertyWrite,
32
+ mayAt,
33
+ mayUseCultures,
34
+ WRITE_VERBS,
35
+ type WriteKind,
36
+ writesEveryProperty,
37
+ } from './policy.ts'
38
+
39
+ /** A page as a save sends it. */
40
+ export interface DocumentInput {
41
+ key: string
42
+ contentTypeKey: string
43
+ componentKey: string | null
44
+ parentKey: string | null
45
+ values: DocumentValue[]
46
+ variants: Array<{ culture: string | null; segment: string | null; name: string }>
47
+ }
48
+
49
+ export interface ScheduleEntry {
50
+ culture: string | null
51
+ publishTime: Date | null
52
+ unpublishTime: Date | null
53
+ }
54
+
55
+ /** Why an operation did nothing: the transport turns `kind` into its own answer. */
56
+ export interface Refusal {
57
+ ok: false
58
+ kind:
59
+ | 'notFound'
60
+ | 'forbidden'
61
+ | 'needsApproval'
62
+ | 'invalid'
63
+ | 'conflict'
64
+ | 'blocked'
65
+ | 'validation'
66
+ status: number
67
+ reason: string
68
+ errors?: DocumentValidationError[]
69
+ }
70
+
71
+ export type Done<T extends object = object> = { ok: true } & T
72
+ export type Result<T extends object = object> = Done<T> | Refusal
73
+
74
+ /**
75
+ * The approval workflows' say over a write (story 028). `who` is the user, or
76
+ * undefined for a local operator, who has no publishing rights of their own.
77
+ */
78
+ export interface ApprovalsGate {
79
+ publishRefusal(
80
+ who: ContentPrincipal | undefined,
81
+ key: string,
82
+ action: 'publish' | 'unpublish',
83
+ ): Promise<string | undefined>
84
+ branchRefusal(who: ContentPrincipal | undefined, key: string): Promise<string | undefined>
85
+ newPageRefusal(
86
+ who: ContentPrincipal | undefined,
87
+ parentKey: string | null,
88
+ contentTypeKey: string,
89
+ ): Promise<string | undefined>
90
+ beforeSave(who: ContentPrincipal | undefined, key: string): Promise<string | undefined>
91
+ afterSave(who: ContentPrincipal | undefined, key: string): Promise<void>
92
+ /** A publish, schedule or unpublish that passed the gate supersedes a scheduled request. */
93
+ afterDirectPublish(who: ContentPrincipal | undefined, key: string, what: string): Promise<void>
94
+ }
95
+
96
+ /** Records redirects from the addresses a page had, once it has new ones. */
97
+ export interface RedirectTracking {
98
+ capture(key: string): Promise<unknown>
99
+ captureChildren(parentKey: string | null): Promise<unknown>
100
+ commit(before: unknown): Promise<void>
101
+ }
102
+
103
+ export interface DocumentOperationsOptions {
104
+ db: Db
105
+ repository?: DocumentRepositoryOptions
106
+ events: EventSink
107
+ approvals?: ApprovalsGate
108
+ redirects?: RedirectTracking
109
+ /** Told after every committed change, for a cache held in this process. */
110
+ onChange?(): void
111
+ /**
112
+ * After a page's hostnames were saved: a site that keeps `domains.toml` writes
113
+ * them there too. Answers the file it wrote, if any.
114
+ */
115
+ afterDomains?(node: NodeRow, assignment: DomainAssignment): Promise<string | undefined>
116
+ /**
117
+ * Errors a value's intake throws that are the sender's to fix — an upload that
118
+ * expired — as the reason to give; anything else not a rule is thrown on.
119
+ */
120
+ refusalReason?(error: unknown): string | undefined
121
+ }
122
+
123
+ export interface DomainAssignment {
124
+ defaultIsoCode: string | null
125
+ domains: Array<{ domainName: string; isoCode: string }>
126
+ }
127
+
128
+ export interface PublicAccessInput {
129
+ loginDocumentKey: string
130
+ errorDocumentKey: string
131
+ memberGroupNames: string[]
132
+ memberUserNames: string[]
133
+ }
134
+
135
+ export type PublicAccessStatus =
136
+ | 'ok'
137
+ | 'content-not-found'
138
+ | 'login-node-not-found'
139
+ | 'error-node-not-found'
140
+ | 'entry-not-found'
141
+ | 'forbidden'
142
+
143
+ const NOT_FOUND = 'The document could not be found.'
144
+
145
+ const refuse = (kind: Refusal['kind'], status: number, reason: string): Refusal => ({
146
+ ok: false,
147
+ kind,
148
+ status,
149
+ reason,
150
+ })
151
+
152
+ /** A write the repository refused as a rule, as a refusal; anything else is thrown on. */
153
+ function refusalFrom(error: unknown, extra?: (error: unknown) => string | undefined): Refusal {
154
+ if (error instanceof WriteRejectedError) return refuse('conflict', 409, error.message)
155
+ if (error instanceof InvalidParentError) return refuse('invalid', 400, error.message)
156
+ if (error instanceof PublishBlockedError) return refuse('blocked', 400, error.message)
157
+ const reason = extra?.(error)
158
+ if (reason) return refuse('invalid', 400, reason)
159
+ throw error
160
+ }
161
+
162
+ export type DocumentOperations = ReturnType<typeof createDocumentOperations>
163
+
164
+ export function createDocumentOperations(options: DocumentOperationsOptions) {
165
+ const { db, events } = options
166
+ const repo = new DocumentRepository(db, options.repository)
167
+ const nodes = new NodeRepository(db)
168
+ const changed = () => options.onChange?.()
169
+ const emit = (event: DocumentEvent, actor: Actor) => events.emit(event, actor)
170
+
171
+ /** The version author: the user's row, when the actor is one. */
172
+ const userIdOf = async (actor: Actor): Promise<number | undefined> => {
173
+ if (actor.kind !== 'user') return undefined
174
+ const rows = await db.query<{ id: number }>('SELECT id FROM user_account WHERE key = ?', [
175
+ actor.principal.id,
176
+ ])
177
+ return rows[0] ? Number(rows[0].id) : undefined
178
+ }
179
+
180
+ /** A document's place: its ancestors' keys then its own, root first. */
181
+ const placeOf = async (
182
+ key: string,
183
+ ): Promise<{ node: NodeRow; chain: string[]; trashed: boolean } | undefined> => {
184
+ const node = await nodes.byKey(key)
185
+ if (!node || node.objectType !== repo.objectType) return undefined
186
+ const ids = node.path
187
+ .split(',')
188
+ .map(Number)
189
+ .filter((id) => id > 0)
190
+ const byId = new Map((await nodes.byIds(ids)).map((row) => [row.id, row]))
191
+ const chain = ids.flatMap((id) => {
192
+ const row = byId.get(id)
193
+ return row && row.objectType === repo.objectType ? [row.key] : []
194
+ })
195
+ return { node, chain, trashed: node.trashed }
196
+ }
197
+
198
+ /**
199
+ * Whether the actor may do `kind` at `key` (or the root, or the bin). Only a
200
+ * user is checked: an operator holds the database and the system acts on what
201
+ * was already decided. A page that does not exist is not refused here: the
202
+ * operation answers that it is not found.
203
+ */
204
+ const permits = async (
205
+ actor: Actor,
206
+ kind: WriteKind,
207
+ key: string | 'root' | 'bin',
208
+ ): Promise<boolean> => {
209
+ if (actor.kind !== 'user') return true
210
+ if (key === 'root' || key === 'bin') return mayAt(actor.principal, WRITE_VERBS[kind], key)
211
+ const place = await placeOf(key)
212
+ if (!place) return true
213
+ return mayAt(actor.principal, WRITE_VERBS[kind], place.chain, place.trashed)
214
+ }
215
+
216
+ const forbidden = () => refuse('forbidden', 403, 'You may not do that to this page.')
217
+
218
+ /** Whether the actor may work in these languages; only a user is limited to some. */
219
+ const languagesAllowed = (actor: Actor, cultures: readonly string[] | null) =>
220
+ actor.kind !== 'user' || !cultures || mayUseCultures(actor.principal, cultures)
221
+
222
+ /** The cultures a save names, so a user is held to their languages. */
223
+ const culturesSaved = (input: Pick<DocumentInput, 'variants' | 'values'>) => [
224
+ ...new Set([...input.variants, ...input.values].flatMap((v) => (v.culture ? [v.culture] : []))),
225
+ ]
226
+
227
+ /** The approval gate, as the actor meets it; `skip` for the system and an operator who chose to. */
228
+ const gateFor = (actor: Actor): { skip: boolean; who: ContentPrincipal | undefined } => {
229
+ if (actor.kind === 'user') return { skip: false, who: actor.principal }
230
+ if (actor.kind === 'operator') return { skip: actor.skipApproval === true, who: undefined }
231
+ return { skip: true, who: undefined }
232
+ }
233
+ const needsApproval = (reason: string) => refuse('needsApproval', 400, reason)
234
+
235
+ /** A user may not change a property their groups only read (story 088). */
236
+ const propertyRefusal = async (
237
+ actor: Actor,
238
+ properties: Array<{ alias: string; key: string; name: string }> | undefined,
239
+ before: readonly DocumentValue[],
240
+ after: readonly DocumentValue[],
241
+ ): Promise<Refusal | undefined> => {
242
+ if (actor.kind !== 'user' || !properties || writesEveryProperty(actor.principal))
243
+ return undefined
244
+ const denied = deniedPropertyWrite(actor.principal, properties, before, after)
245
+ return denied
246
+ ? refuse('forbidden', 403, `You may not change "${denied.name}" (${denied.alias}).`)
247
+ : undefined
248
+ }
249
+
250
+ /** The properties a page of this type has, compositions included, for the property check. */
251
+ const propertiesOfType = async (contentTypeKey: string) => {
252
+ const type = await nodes.byKey(contentTypeKey)
253
+ if (!type) return undefined
254
+ const rows = await db.query<{ alias: string; unique_id: string; name: string }>(
255
+ `WITH RECURSIVE ancestry(id) AS (
256
+ SELECT ?
257
+ UNION
258
+ SELECT c.parent_content_type_id FROM content_type_composition c JOIN ancestry a ON a.id = c.child_content_type_id
259
+ )
260
+ SELECT p.alias, p.unique_id, p.name FROM property_type p
261
+ WHERE p.content_type_id IN (SELECT id FROM ancestry) AND p.retired_at IS NULL`,
262
+ [type.id],
263
+ )
264
+ return rows.map((row) => ({
265
+ alias: String(row.alias),
266
+ key: normaliseUuid(String(row.unique_id)),
267
+ name: String(row.name),
268
+ }))
269
+ }
270
+
271
+ const validationRefusal = (errors: DocumentValidationError[]): Refusal => ({
272
+ ...refuse('validation', 400, 'One or more properties did not pass validation'),
273
+ errors,
274
+ })
275
+
276
+ return {
277
+ async create(actor: Actor, input: DocumentInput): Promise<Result<{ key: string }>> {
278
+ if (!(await permits(actor, 'create', input.parentKey ?? 'root'))) return forbidden()
279
+ if (!languagesAllowed(actor, culturesSaved(input))) return forbidden()
280
+ const denied = await propertyRefusal(
281
+ actor,
282
+ await propertiesOfType(input.contentTypeKey),
283
+ [],
284
+ input.values,
285
+ )
286
+ if (denied) return denied
287
+ try {
288
+ const created = await repo.create({ ...input, userId: await userIdOf(actor) })
289
+ changed()
290
+ await emit({ type: 'created', key: created.key }, actor)
291
+ return { ok: true, key: created.key }
292
+ } catch (error) {
293
+ return refusalFrom(error, options.refusalReason)
294
+ }
295
+ },
296
+
297
+ async save(actor: Actor, key: string, input: DocumentInput): Promise<Result> {
298
+ const existing = await repo.byKey(key)
299
+ if (!existing) return refuse('notFound', 404, NOT_FOUND)
300
+ if (!(await permits(actor, 'save', key))) return forbidden()
301
+ if (!languagesAllowed(actor, culturesSaved(input))) return forbidden()
302
+ const denied = await propertyRefusal(
303
+ actor,
304
+ await repo.propertyTypeKeys(key),
305
+ existing.values,
306
+ input.values,
307
+ )
308
+ if (denied) return denied
309
+ const gate = gateFor(actor)
310
+ if (!gate.skip) {
311
+ const waiting = await options.approvals?.beforeSave(gate.who, key)
312
+ if (waiting) return needsApproval(waiting)
313
+ }
314
+ try {
315
+ // The content type never changes on a save.
316
+ const updated = await repo.update({
317
+ ...input,
318
+ key,
319
+ contentTypeKey: existing.contentTypeKey,
320
+ userId: await userIdOf(actor),
321
+ })
322
+ if (!updated) return refuse('notFound', 404, NOT_FOUND)
323
+ changed()
324
+ if (!gate.skip) await options.approvals?.afterSave(gate.who, key)
325
+ await emit({ type: 'saved', key }, actor)
326
+ return { ok: true }
327
+ } catch (error) {
328
+ return refusalFrom(error, options.refusalReason)
329
+ }
330
+ },
331
+
332
+ async createAndPublish(
333
+ actor: Actor,
334
+ input: DocumentInput,
335
+ cultures: string[] | null,
336
+ ): Promise<Result<{ key: string }>> {
337
+ if (!(await permits(actor, 'publish', input.parentKey ?? 'root'))) return forbidden()
338
+ if (!languagesAllowed(actor, cultures) || !languagesAllowed(actor, culturesSaved(input)))
339
+ return forbidden()
340
+ const denied = await propertyRefusal(
341
+ actor,
342
+ await propertiesOfType(input.contentTypeKey),
343
+ [],
344
+ input.values,
345
+ )
346
+ if (denied) return denied
347
+ const gate = gateFor(actor)
348
+ if (!gate.skip) {
349
+ const refusal = await options.approvals?.newPageRefusal(
350
+ gate.who,
351
+ input.parentKey,
352
+ input.contentTypeKey,
353
+ )
354
+ if (refusal) return needsApproval(refusal)
355
+ }
356
+ const errors = await repo.validate(input, cultures)
357
+ if (errors.length > 0) return validationRefusal(errors)
358
+ try {
359
+ const userId = await userIdOf(actor)
360
+ // One transaction: a page that cannot be published is not left behind as a draft.
361
+ const result = await repo.atomically((tx) =>
362
+ tx.createAndPublish({ ...input, userId }, cultures),
363
+ )
364
+ if (!result.ok) return validationRefusal(result.errors)
365
+ changed()
366
+ await emit({ type: 'createdAndPublished', key: result.document.key, cultures }, actor)
367
+ return { ok: true, key: result.document.key }
368
+ } catch (error) {
369
+ return refusalFrom(error, options.refusalReason)
370
+ }
371
+ },
372
+
373
+ async saveAndPublish(
374
+ actor: Actor,
375
+ key: string,
376
+ input: DocumentInput,
377
+ cultures: string[] | null,
378
+ ): Promise<Result> {
379
+ const existing = await repo.byKey(key)
380
+ if (!existing) return refuse('notFound', 404, NOT_FOUND)
381
+ if (!(await permits(actor, 'publish', key))) return forbidden()
382
+ if (!languagesAllowed(actor, cultures) || !languagesAllowed(actor, culturesSaved(input)))
383
+ return forbidden()
384
+ const denied = await propertyRefusal(
385
+ actor,
386
+ await repo.propertyTypeKeys(key),
387
+ existing.values,
388
+ input.values,
389
+ )
390
+ if (denied) return denied
391
+ const gate = gateFor(actor)
392
+ if (!gate.skip) {
393
+ const refusal =
394
+ (await options.approvals?.beforeSave(gate.who, key)) ??
395
+ (await options.approvals?.publishRefusal(gate.who, key, 'publish'))
396
+ if (refusal) return needsApproval(refusal)
397
+ }
398
+ try {
399
+ const before = await options.redirects?.capture(key)
400
+ const userId = await userIdOf(actor)
401
+ const result = await repo.atomically((tx) =>
402
+ tx.updateAndPublish({ ...input, key, userId }, cultures),
403
+ )
404
+ if (!result) return refuse('notFound', 404, NOT_FOUND)
405
+ if (!result.ok) return validationRefusal(result.errors)
406
+ changed()
407
+ await options.redirects?.commit(before)
408
+ if (!gate.skip) await options.approvals?.afterDirectPublish(gate.who, key, 'published it')
409
+ await emit({ type: 'savedAndPublished', key, cultures }, actor)
410
+ return { ok: true }
411
+ } catch (error) {
412
+ return refusalFrom(error, options.refusalReason)
413
+ }
414
+ },
415
+
416
+ /**
417
+ * Publishes a page now, or schedules it: a schedule entry with a publish time
418
+ * still to come schedules that culture instead of publishing it.
419
+ */
420
+ async publish(
421
+ actor: Actor,
422
+ key: string,
423
+ request: {
424
+ cultures: string[] | null
425
+ schedules?: readonly ScheduleEntry[]
426
+ now?: number
427
+ },
428
+ ): Promise<Result<{ scheduledOnly: boolean }>> {
429
+ if (!(await repo.byKey(key))) return refuse('notFound', 404, NOT_FOUND)
430
+ if (!(await permits(actor, 'publish', key))) return forbidden()
431
+ const schedules = request.schedules ?? []
432
+ const named = [
433
+ ...(request.cultures ?? []),
434
+ ...schedules.flatMap((s) => (s.culture ? [s.culture] : [])),
435
+ ]
436
+ if (!languagesAllowed(actor, named)) return forbidden()
437
+ const gate = gateFor(actor)
438
+ if (!gate.skip) {
439
+ const refusal = await options.approvals?.publishRefusal(gate.who, key, 'publish')
440
+ if (refusal) return needsApproval(refusal)
441
+ }
442
+ const now = request.now ?? Date.now()
443
+ const later = (s: ScheduleEntry) => s.publishTime !== null && s.publishTime.getTime() > now
444
+ try {
445
+ if (schedules.length > 0)
446
+ await repo.setSchedule(
447
+ key,
448
+ schedules.map((s) => ({ ...s, publishTime: later(s) ? s.publishTime : null })),
449
+ )
450
+ const immediate = schedules.filter((s) => !later(s))
451
+ if (schedules.length > 0 && immediate.length === 0) {
452
+ changed()
453
+ if (!gate.skip)
454
+ await options.approvals?.afterDirectPublish(gate.who, key, 'rescheduled it')
455
+ await emit({ type: 'scheduled', key }, actor)
456
+ return { ok: true, scheduledOnly: true }
457
+ }
458
+ const cultures = immediate.some((s) => s.culture !== null)
459
+ ? immediate.flatMap((s) => (s.culture === null ? [] : [s.culture]))
460
+ : request.cultures
461
+ const before = await options.redirects?.capture(key)
462
+ const published = await repo.publish(key, cultures)
463
+ if (!published) return refuse('notFound', 404, NOT_FOUND)
464
+ changed()
465
+ await options.redirects?.commit(before)
466
+ if (!gate.skip) await options.approvals?.afterDirectPublish(gate.who, key, 'published it')
467
+ await emit({ type: 'published', key, cultures }, actor)
468
+ return { ok: true, scheduledOnly: false }
469
+ } catch (error) {
470
+ return refusalFrom(error, options.refusalReason)
471
+ }
472
+ },
473
+
474
+ async unpublish(actor: Actor, key: string, cultures: string[] | null): Promise<Result> {
475
+ if (!(await repo.byKey(key))) return refuse('notFound', 404, NOT_FOUND)
476
+ if (!(await permits(actor, 'unpublish', key))) return forbidden()
477
+ if (!languagesAllowed(actor, cultures)) return forbidden()
478
+ const gate = gateFor(actor)
479
+ if (!gate.skip) {
480
+ const refusal = await options.approvals?.publishRefusal(gate.who, key, 'unpublish')
481
+ if (refusal) return needsApproval(refusal)
482
+ }
483
+ try {
484
+ const result = await repo.unpublish(key, cultures)
485
+ if (!result) return refuse('notFound', 404, NOT_FOUND)
486
+ changed()
487
+ if (!gate.skip) await options.approvals?.afterDirectPublish(gate.who, key, 'took it down')
488
+ await emit({ type: 'unpublished', key, cultures }, actor)
489
+ return { ok: true }
490
+ } catch (error) {
491
+ return refusalFrom(error, options.refusalReason)
492
+ }
493
+ },
494
+
495
+ /**
496
+ * Publishes or unpublishes several pages as one change: every page is checked
497
+ * first, then all are written in one transaction, so a run never stops half
498
+ * done. Parents first, so a child is not blocked by an ancestor the same run
499
+ * takes live.
500
+ */
501
+ async publishMany(
502
+ actor: Actor,
503
+ keys: readonly string[],
504
+ cultures: string[] | null,
505
+ action: 'publish' | 'unpublish',
506
+ ): Promise<
507
+ | Result<{ changed: string[] }>
508
+ | (Refusal & { blocked?: Array<{ key: string; reasons: string[] }> })
509
+ > {
510
+ const targets: NodeRow[] = []
511
+ for (const key of keys) {
512
+ const node = await nodes.byKey(key)
513
+ if (!node || node.objectType !== repo.objectType) return refuse('notFound', 404, NOT_FOUND)
514
+ targets.push(node)
515
+ }
516
+ targets.sort((a, b) => a.level - b.level)
517
+ for (const node of targets) if (!(await permits(actor, action, node.key))) return forbidden()
518
+ if (!languagesAllowed(actor, cultures)) return forbidden()
519
+ const gate = gateFor(actor)
520
+ if (!gate.skip)
521
+ for (const node of targets) {
522
+ const refusal = await options.approvals?.publishRefusal(gate.who, node.key, action)
523
+ if (refusal) return needsApproval(refusal)
524
+ }
525
+ if (action === 'publish') {
526
+ const selection = new Set(targets.map((node) => node.id))
527
+ const blocked: Array<{ key: string; reasons: string[] }> = []
528
+ for (const node of targets) {
529
+ const reasons = await repo.publishBlockers(node.key, cultures, selection)
530
+ if (reasons.length > 0) blocked.push({ key: node.key, reasons })
531
+ }
532
+ if (blocked.length > 0)
533
+ return {
534
+ ...refuse(
535
+ 'blocked',
536
+ 400,
537
+ `${blocked.length} of ${targets.length} page(s) cannot be published, so nothing was published.`,
538
+ ),
539
+ blocked,
540
+ }
541
+ }
542
+ try {
543
+ const befores = new Map<string, unknown>()
544
+ if (action === 'publish')
545
+ for (const node of targets)
546
+ befores.set(node.key, await options.redirects?.capture(node.key))
547
+ const done = await repo.atomically(async (tx) => {
548
+ const out: string[] = []
549
+ for (const node of targets) {
550
+ const result =
551
+ action === 'publish'
552
+ ? await tx.publish(node.key, cultures)
553
+ : await tx.unpublish(node.key, cultures)
554
+ if (result) out.push(node.key)
555
+ }
556
+ return out
557
+ })
558
+ changed()
559
+ for (const key of done) {
560
+ if (action === 'publish') await options.redirects?.commit(befores.get(key))
561
+ if (!gate.skip)
562
+ await options.approvals?.afterDirectPublish(
563
+ gate.who,
564
+ key,
565
+ action === 'publish' ? 'published it' : 'took it down',
566
+ )
567
+ await emit(
568
+ action === 'publish'
569
+ ? { type: 'published', key, cultures }
570
+ : { type: 'unpublished', key, cultures },
571
+ actor,
572
+ )
573
+ }
574
+ return { ok: true, changed: done }
575
+ } catch (error) {
576
+ return refusalFrom(error, options.refusalReason)
577
+ }
578
+ },
579
+
580
+ /** Sets when a page goes live or comes down, without publishing anything now. */
581
+ async schedule(actor: Actor, key: string, entries: readonly ScheduleEntry[]): Promise<Result> {
582
+ if (!(await repo.byKey(key))) return refuse('notFound', 404, NOT_FOUND)
583
+ if (!(await permits(actor, 'publish', key))) return forbidden()
584
+ if (
585
+ !languagesAllowed(
586
+ actor,
587
+ entries.flatMap((e) => (e.culture ? [e.culture] : [])),
588
+ )
589
+ )
590
+ return forbidden()
591
+ const gate = gateFor(actor)
592
+ if (!gate.skip) {
593
+ const refusal = await options.approvals?.publishRefusal(gate.who, key, 'publish')
594
+ if (refusal) return needsApproval(refusal)
595
+ }
596
+ try {
597
+ await repo.setSchedule(key, entries)
598
+ changed()
599
+ if (!gate.skip) await options.approvals?.afterDirectPublish(gate.who, key, 'rescheduled it')
600
+ await emit({ type: 'scheduled', key }, actor)
601
+ return { ok: true }
602
+ } catch (error) {
603
+ return refusalFrom(error, options.refusalReason)
604
+ }
605
+ },
606
+
607
+ async publishBranch(
608
+ actor: Actor,
609
+ key: string,
610
+ cultures: string[] | null,
611
+ includeUnpublished: boolean,
612
+ ): Promise<Result<{ published: string[]; failed: Array<{ key: string; reason: string }> }>> {
613
+ const root = await placeOf(key)
614
+ if (!root) return refuse('notFound', 404, NOT_FOUND)
615
+ if (actor.kind === 'user') {
616
+ const below = await db.query<{ unique_id: string }>(
617
+ 'SELECT unique_id FROM node WHERE id = ? OR path LIKE ?',
618
+ [root.node.id, `${root.node.path},%`],
619
+ )
620
+ for (const row of below)
621
+ if (!(await permits(actor, 'publish', normaliseUuid(String(row.unique_id)))))
622
+ return forbidden()
623
+ }
624
+ if (!languagesAllowed(actor, cultures)) return forbidden()
625
+ const gate = gateFor(actor)
626
+ if (!gate.skip) {
627
+ const refusal = await options.approvals?.branchRefusal(gate.who, key)
628
+ if (refusal) return needsApproval(refusal)
629
+ }
630
+ try {
631
+ const before = await options.redirects?.capture(key)
632
+ const result = await repo.publishBranch(key, cultures, includeUnpublished)
633
+ if (!result) return refuse('notFound', 404, NOT_FOUND)
634
+ changed()
635
+ await options.redirects?.commit(before)
636
+ if (!gate.skip)
637
+ for (const published of result.published)
638
+ await options.approvals?.afterDirectPublish(gate.who, published, 'published its branch')
639
+ await emit({ type: 'branchPublished', key, published: result.published }, actor)
640
+ return { ok: true, ...result }
641
+ } catch (error) {
642
+ return refusalFrom(error, options.refusalReason)
643
+ }
644
+ },
645
+
646
+ async trash(actor: Actor, key: string): Promise<Result> {
647
+ if (!(await permits(actor, 'trash', key))) return forbidden()
648
+ const moved = await repo.moveToRecycleBin(key)
649
+ if (!moved) return refuse('notFound', 404, NOT_FOUND)
650
+ changed()
651
+ await emit({ type: 'trashed', key }, actor)
652
+ return { ok: true }
653
+ },
654
+
655
+ /** Deletes a page for good; `fromBin` refuses one that is not in the recycle bin. */
656
+ async delete(actor: Actor, key: string, options_: { fromBin?: boolean } = {}): Promise<Result> {
657
+ const node = await nodes.byKey(key)
658
+ if (!node || node.objectType !== repo.objectType) return refuse('notFound', 404, NOT_FOUND)
659
+ if (options_.fromBin && !node.trashed)
660
+ return refuse('invalid', 400, 'The document is not in the recycle bin')
661
+ if (!(await permits(actor, 'delete', options_.fromBin ? 'bin' : key))) return forbidden()
662
+ const removed = await repo.delete(key)
663
+ if (!removed) return refuse('notFound', 404, NOT_FOUND)
664
+ changed()
665
+ await emit({ type: 'deleted', key, name: node.text ?? null }, actor)
666
+ return { ok: true }
667
+ },
668
+
669
+ async emptyBin(actor: Actor): Promise<Result<{ deleted: number }>> {
670
+ if (!(await permits(actor, 'emptyBin', 'bin'))) return forbidden()
671
+ let deleted = 0
672
+ for (const node of await repo.trashedRoots()) if (await repo.delete(node.key)) deleted++
673
+ changed()
674
+ await emit({ type: 'binEmptied' }, actor)
675
+ return { ok: true, deleted }
676
+ },
677
+
678
+ async restore(
679
+ actor: Actor,
680
+ key: string,
681
+ targetKey: string | null | undefined,
682
+ ): Promise<Result> {
683
+ if (!(await permits(actor, 'restore', 'bin'))) return forbidden()
684
+ try {
685
+ const result = await repo.restore(key, targetKey)
686
+ if (result === 'notFound')
687
+ return refuse('notFound', 404, 'The document is not in the recycle bin.')
688
+ if (result === 'invalid')
689
+ return refuse(
690
+ 'invalid',
691
+ 400,
692
+ 'The restore target does not exist or is in the recycle bin.',
693
+ )
694
+ changed()
695
+ await emit({ type: 'restored', key }, actor)
696
+ return { ok: true }
697
+ } catch (error) {
698
+ return refusalFrom(error, options.refusalReason)
699
+ }
700
+ },
701
+
702
+ async move(actor: Actor, key: string, targetKey: string | null): Promise<Result> {
703
+ if (!(await permits(actor, 'move', key))) return forbidden()
704
+ if (!(await permits(actor, 'intoTarget', targetKey ?? 'root'))) return forbidden()
705
+ try {
706
+ const before = await options.redirects?.capture(key)
707
+ const result = await repo.move(key, targetKey)
708
+ if (result === 'notFound')
709
+ return refuse('notFound', 404, 'The document or target could not be found.')
710
+ if (result === 'invalid')
711
+ return refuse(
712
+ 'invalid',
713
+ 400,
714
+ 'A document cannot be moved below itself or into the recycle bin.',
715
+ )
716
+ changed()
717
+ await options.redirects?.commit(before)
718
+ await emit({ type: 'moved', key }, actor)
719
+ return { ok: true }
720
+ } catch (error) {
721
+ return refusalFrom(error, options.refusalReason)
722
+ }
723
+ },
724
+
725
+ async copy(
726
+ actor: Actor,
727
+ key: string,
728
+ targetKey: string | null,
729
+ copyOptions: { includeDescendants: boolean },
730
+ ): Promise<Result<{ key: string }>> {
731
+ if (!(await permits(actor, 'copy', key))) return forbidden()
732
+ if (!(await permits(actor, 'intoTarget', targetKey ?? 'root'))) return forbidden()
733
+ try {
734
+ const copied = await repo.copy(key, targetKey, {
735
+ includeDescendants: copyOptions.includeDescendants,
736
+ userId: await userIdOf(actor),
737
+ })
738
+ if (!copied) return refuse('notFound', 404, 'The document or target could not be found.')
739
+ changed()
740
+ await emit({ type: 'copied', key, copyKey: copied }, actor)
741
+ return { ok: true, key: copied }
742
+ } catch (error) {
743
+ return refusalFrom(error, options.refusalReason)
744
+ }
745
+ },
746
+
747
+ async sort(
748
+ actor: Actor,
749
+ parentKey: string | null,
750
+ sorting: ReadonlyArray<{ key: string; sortOrder: number }>,
751
+ ): Promise<Result> {
752
+ if (!(await permits(actor, 'sort', parentKey ?? 'root'))) return forbidden()
753
+ for (const item of sorting) if (!(await permits(actor, 'sort', item.key))) return forbidden()
754
+ try {
755
+ const before = await options.redirects?.captureChildren(parentKey)
756
+ const result = await repo.sort(parentKey, sorting)
757
+ if (result === 'notFound')
758
+ return refuse(
759
+ 'notFound',
760
+ 404,
761
+ 'The parent, or one of the items, could not be found under it.',
762
+ )
763
+ changed()
764
+ await options.redirects?.commit(before)
765
+ await emit({ type: 'sorted', parentKey }, actor)
766
+ return { ok: true }
767
+ } catch (error) {
768
+ return refusalFrom(error, options.refusalReason)
769
+ }
770
+ },
771
+
772
+ async sortChildrenBy(
773
+ actor: Actor,
774
+ parentKey: string | null,
775
+ field: 'Name' | 'CreateDate' | 'UpdateDate',
776
+ direction: 'Ascending' | 'Descending',
777
+ ): Promise<Result> {
778
+ if (!(await permits(actor, 'sort', parentKey ?? 'root'))) return forbidden()
779
+ try {
780
+ const before = await options.redirects?.captureChildren(parentKey)
781
+ const result = await repo.sortChildrenBy(parentKey, field, direction)
782
+ if (result === 'notFound') return refuse('notFound', 404, NOT_FOUND)
783
+ changed()
784
+ await options.redirects?.commit(before)
785
+ await emit({ type: 'sorted', parentKey }, actor)
786
+ return { ok: true }
787
+ } catch (error) {
788
+ return refusalFrom(error, options.refusalReason)
789
+ }
790
+ },
791
+
792
+ async rollback(actor: Actor, versionId: string): Promise<Result> {
793
+ const document = await repo.atVersion(versionId)
794
+ if (!document) return refuse('notFound', 404, 'The version could not be found.')
795
+ if (!(await permits(actor, 'rollback', document.document.key))) return forbidden()
796
+ try {
797
+ const result = await repo.rollback(versionId)
798
+ if (!result) return refuse('notFound', 404, 'The version could not be found.')
799
+ changed()
800
+ await emit({ type: 'rolledBack', key: result.key, versionId }, actor)
801
+ return { ok: true }
802
+ } catch (error) {
803
+ return refusalFrom(error, options.refusalReason)
804
+ }
805
+ },
806
+
807
+ async setDomains(
808
+ actor: Actor,
809
+ key: string,
810
+ assignment: DomainAssignment,
811
+ ): Promise<
812
+ | Exclude<SetDomainsResult, { status: 'ok' }>
813
+ | { status: 'notFound' }
814
+ | { status: 'forbidden' }
815
+ | { status: 'ok'; file?: string }
816
+ > {
817
+ const node = await nodes.byKey(key)
818
+ if (!node || node.objectType !== repo.objectType) return { status: 'notFound' }
819
+ if (!(await permits(actor, 'domains', key))) return { status: 'forbidden' }
820
+ const result = await new DomainRepository(db).setForNode(node.id, assignment)
821
+ if (result.status !== 'ok') return result
822
+ // Every node routes by hostname from its published cache, so they are all told.
823
+ await repoInstruction(node.key)
824
+ changed()
825
+ const file = await options.afterDomains?.(node, assignment)
826
+ await emit({ type: 'domainsAssigned', key: node.key }, actor)
827
+ return file ? { status: 'ok', file } : { status: 'ok' }
828
+ },
829
+
830
+ async setPublicAccess(
831
+ actor: Actor,
832
+ key: string,
833
+ input: PublicAccessInput,
834
+ change: 'added' | 'set',
835
+ ): Promise<PublicAccessStatus> {
836
+ if (!(await isDocument(key))) return 'content-not-found'
837
+ if (!(await permits(actor, 'publicAccess', key))) return 'forbidden'
838
+ if (!(await isDocument(input.loginDocumentKey))) return 'login-node-not-found'
839
+ if (!(await isDocument(input.errorDocumentKey))) return 'error-node-not-found'
840
+ const saved = await new PublicAccessRepository(db).save({
841
+ nodeKey: key,
842
+ loginNodeKey: input.loginDocumentKey,
843
+ errorNodeKey: input.errorDocumentKey,
844
+ memberGroupNames: input.memberGroupNames,
845
+ memberUserNames: input.memberUserNames,
846
+ })
847
+ if (!saved) return 'content-not-found'
848
+ changed()
849
+ await emit({ type: 'publicAccessChanged', key, change }, actor)
850
+ return 'ok'
851
+ },
852
+
853
+ async removePublicAccess(actor: Actor, key: string): Promise<PublicAccessStatus> {
854
+ if (!(await isDocument(key))) return 'content-not-found'
855
+ if (!(await permits(actor, 'publicAccess', key))) return 'forbidden'
856
+ if (!(await new PublicAccessRepository(db).remove(key))) return 'entry-not-found'
857
+ changed()
858
+ await emit({ type: 'publicAccessChanged', key, change: 'removed' }, actor)
859
+ return 'ok'
860
+ },
861
+
862
+ /** The repository these operations write through, for reads that go with them. */
863
+ repository: repo,
864
+ }
865
+
866
+ async function isDocument(key: string): Promise<boolean> {
867
+ const node = await nodes.byKey(key)
868
+ return node?.objectType === repo.objectType
869
+ }
870
+
871
+ /** The content cache instruction other nodes rebuild on. */
872
+ async function repoInstruction(key: string): Promise<void> {
873
+ await appendCacheInstruction(db, {
874
+ kind: 'content',
875
+ payload: { key },
876
+ by: options.repository?.nodeId,
877
+ })
878
+ }
879
+ }