@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.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +33 -4
- package/src/actor.ts +97 -0
- package/src/events.ts +257 -0
- package/src/index.ts +2 -0
- package/src/operations.ts +879 -0
- package/src/policy.ts +130 -0
- package/src/rules.ts +8 -0
- package/README.md +0 -3
|
@@ -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
|
+
}
|