@crombolo/server 0.12.1 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/server",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
4
4
  "description": "The crombolo HTTP server: routing, configuration, media stores and background jobs.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -28,17 +28,17 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@crombolo/api-delivery": "0.12.1",
32
- "@crombolo/api-management": "0.12.1",
33
- "@crombolo/assistant": "0.12.1",
34
- "@crombolo/auth": "0.12.1",
35
- "@crombolo/backoffice-host": "0.12.1",
36
- "@crombolo/contracts": "0.12.1",
37
- "@crombolo/core": "0.12.1",
38
- "@crombolo/data": "0.12.1",
39
- "@crombolo/render": "0.12.1",
40
- "@crombolo/schema": "0.12.1",
41
- "@crombolo/transfer": "0.12.1",
31
+ "@crombolo/api-delivery": "0.13.1",
32
+ "@crombolo/api-management": "0.13.1",
33
+ "@crombolo/assistant": "0.13.1",
34
+ "@crombolo/auth": "0.13.1",
35
+ "@crombolo/backoffice-host": "0.13.1",
36
+ "@crombolo/contracts": "0.13.1",
37
+ "@crombolo/core": "0.13.1",
38
+ "@crombolo/data": "0.13.1",
39
+ "@crombolo/render": "0.13.1",
40
+ "@crombolo/schema": "0.13.1",
41
+ "@crombolo/transfer": "0.13.1",
42
42
  "@logtape/logtape": "^2.3.8"
43
43
  }
44
44
  }
@@ -49,6 +49,7 @@ import {
49
49
  type BundleNode,
50
50
  type BundleSection,
51
51
  bundleIdFor,
52
+ bundlePeerRange,
52
53
  type ContentSet,
53
54
  exportBundle,
54
55
  isPackageName,
@@ -442,7 +443,7 @@ export function createPackagePort(db: Db, options: PackagePortOptions): PackageP
442
443
  version: pkg.version,
443
444
  id: bundleIdFor(pkg.name.replace(/^@[^/]+\//, '')),
444
445
  label: from.name,
445
- peerRange: `^${VERSION}`,
446
+ peerRange: bundlePeerRange(VERSION),
446
447
  })
447
448
  const entries = files.map((file) => ({
448
449
  path: file.path,
@@ -0,0 +1,189 @@
1
+ /**
2
+ * The Workflow section's and the page tab's reads and actions (story 028),
3
+ * under `<backoffice>/crombolo/api/approvals/`, and saving a workflow drawn in
4
+ * the designer back to `schema/workflows/`. docs/workflows.md.
5
+ *
6
+ * A workflow is written as its file, validated against the whole set first,
7
+ * then synced at once, so the gate follows it without a restart; the file is
8
+ * the truth, as for forms, and a read-only schema directory refuses the save.
9
+ */
10
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import type { Principal } from '@crombolo/api-management'
13
+ import type { SchemaWorkflow } from '@crombolo/core'
14
+ import { type Db, readWorkflows, syncWorkflows, UserGroupRepository } from '@crombolo/data'
15
+ import {
16
+ fileNameFor,
17
+ loadSchemaDirectory,
18
+ parseWorkflow,
19
+ validateWorkflows,
20
+ workflowVersion,
21
+ writeWorkflow,
22
+ } from '@crombolo/schema'
23
+ import { ApprovalRefused, type Approvals } from './approvals.ts'
24
+
25
+ export interface ApprovalsApiContext {
26
+ db: Db
27
+ approvals: Approvals
28
+ principal: Principal
29
+ schemaDir: string
30
+ /** False on a deployed node with no schema store: a save would not last. */
31
+ writable: boolean
32
+ onChanged?: () => Promise<void>
33
+ }
34
+
35
+ const json = (body: unknown, status = 200) => Response.json(body, { status })
36
+
37
+ /** The workflows in `schema/workflows/`, synced into the database: what the gate reads. */
38
+ export async function syncWorkflowFiles(db: Db, schemaDir: string) {
39
+ const workflows = loadSchemaDirectory(schemaDir).set.workflows ?? []
40
+ return syncWorkflows(
41
+ db,
42
+ workflows.map((workflow) => ({ workflow, version: workflowVersion(workflow) })),
43
+ )
44
+ }
45
+
46
+ /** What saving a workflow would do to the requests in flight under it. */
47
+ async function changePreview(approvals: Approvals, principal: Principal, workflow: SchemaWorkflow) {
48
+ const id = workflow.key ?? workflow.alias
49
+ const stored = (await approvals.workflows()).find(
50
+ (s) => (s.workflow.key ?? s.workflow.alias) === id,
51
+ )
52
+ const version = workflowVersion(workflow)
53
+ if (stored && stored.version === version) return []
54
+ const waiting = (await approvals.inFlight(principal)).filter(
55
+ (r) => (r.workflow.key ?? r.workflow.alias) === id,
56
+ )
57
+ return waiting.map((r) => ({ key: r.key, page: r.nodeName, outcome: workflow.onChange }))
58
+ }
59
+
60
+ export async function handleApprovalsApi(
61
+ request: Request,
62
+ path: string,
63
+ ctx: ApprovalsApiContext,
64
+ ): Promise<Response | undefined> {
65
+ const { approvals, principal } = ctx
66
+ const body = async () => (await request.json().catch(() => ({}))) as Record<string, unknown>
67
+ const text = (value: unknown) => (typeof value === 'string' ? value : undefined)
68
+ try {
69
+ let match = /^page\/([^/]+)$/.exec(path)
70
+ if (match && request.method === 'GET')
71
+ return json(await approvals.forPage(principal, decodeURIComponent(match[1] as string)))
72
+
73
+ match = /^page\/([^/]+)\/send$/.exec(path)
74
+ if (match && request.method === 'POST') {
75
+ const b = await body()
76
+ const cultures = Array.isArray(b.cultures) ? b.cultures.map(String) : null
77
+ return json(
78
+ await approvals.send(
79
+ principal,
80
+ decodeURIComponent(match[1] as string),
81
+ b.action === 'unpublish' ? 'unpublish' : 'publish',
82
+ cultures && cultures.length > 0 ? cultures : null,
83
+ text(b.comment),
84
+ ),
85
+ )
86
+ }
87
+
88
+ match = /^request\/([^/]+)\/(approve|send-back|withdraw)$/.exec(path)
89
+ if (match && request.method === 'POST') {
90
+ const key = decodeURIComponent(match[1] as string)
91
+ const b = await body()
92
+ if (match[2] === 'approve')
93
+ return json(await approvals.approve(principal, key, text(b.stage), text(b.comment)))
94
+ if (match[2] === 'send-back')
95
+ return json(await approvals.sendBack(principal, key, text(b.stage), text(b.comment)))
96
+ return json(await approvals.withdraw(principal, key))
97
+ }
98
+
99
+ if (path === 'waiting' && request.method === 'GET')
100
+ return json({ items: await approvals.waitingFor(principal) })
101
+ if (path === 'in-flight' && request.method === 'GET')
102
+ return json({ items: await approvals.inFlight(principal) })
103
+ if (path === 'history' && request.method === 'GET') {
104
+ const url = new URL(request.url)
105
+ const skip = Math.max(0, Number(url.searchParams.get('skip') ?? 0) || 0)
106
+ const take = Math.min(100, Math.max(1, Number(url.searchParams.get('take') ?? 25) || 25))
107
+ return json(await approvals.finished(principal, skip, take))
108
+ }
109
+
110
+ if (path === 'workflows' && request.method === 'GET') {
111
+ const loaded = loadSchemaDirectory(ctx.schemaDir)
112
+ return json({
113
+ items: (await readWorkflows(ctx.db)).map((s) => s.workflow),
114
+ editable: ctx.writable && principal.isAdmin,
115
+ groups: (await new UserGroupRepository(ctx.db).all()).map((g) => ({
116
+ alias: g.alias,
117
+ name: g.name,
118
+ })),
119
+ contentTypes: loaded.set.contentTypes.map((t) => ({ alias: t.alias, name: t.name })),
120
+ })
121
+ }
122
+
123
+ match = /^workflows\/([^/]+)(\/preview)?$/.exec(path)
124
+ if (match) {
125
+ if (!principal.isAdmin)
126
+ return json({ ok: false, message: 'Only administrators change workflows.' }, 403)
127
+ const alias = decodeURIComponent(match[1] as string)
128
+ const file = join(ctx.schemaDir, 'workflows', fileNameFor(alias))
129
+ if (request.method === 'DELETE') {
130
+ if (!ctx.writable) return readOnly()
131
+ if (!existsSync(file))
132
+ return json({ ok: false, message: 'There is no such workflow.' }, 404)
133
+ rmSync(file)
134
+ await ctx.onChanged?.()
135
+ return json({ ok: true, report: await syncWorkflowFiles(ctx.db, ctx.schemaDir) })
136
+ }
137
+ if (request.method !== 'PUT' && request.method !== 'POST') return undefined
138
+ const b = await body()
139
+ // Through the file and the strict parser, as a hand-written one would be.
140
+ const source = writeWorkflow(b.workflow as SchemaWorkflow)
141
+ const parsed = parseWorkflow(`schema/workflows/${fileNameFor(alias)}`, source)
142
+ if (!parsed.value) return json({ ok: false, problems: parsed.problems }, 400)
143
+ const workflow = parsed.value
144
+ const loaded = loadSchemaDirectory(ctx.schemaDir)
145
+ const others = (loaded.set.workflows ?? []).filter(
146
+ (w) => w.alias !== alias && w.alias !== workflow.alias,
147
+ )
148
+ const problems = validateWorkflows(
149
+ [...others, workflow],
150
+ new Set(loaded.set.contentTypes.map((t) => t.alias)),
151
+ )
152
+ const groups = new Set((await new UserGroupRepository(ctx.db).all()).map((g) => g.alias))
153
+ workflow.stages.forEach((stage, i) => {
154
+ for (const approver of stage.approvers)
155
+ if (!groups.has(approver))
156
+ problems.push({
157
+ file: `schema/workflows/${fileNameFor(workflow.alias)}`,
158
+ path: `stage[${i}].approvers`,
159
+ message: `there is no user group "${approver}"`,
160
+ })
161
+ })
162
+ if (problems.length > 0) return json({ ok: false, problems }, 400)
163
+ const affected = await changePreview(approvals, principal, workflow)
164
+ if (match[2]) return json({ ok: true, affected })
165
+ if (!ctx.writable) return readOnly()
166
+ mkdirSync(join(ctx.schemaDir, 'workflows'), { recursive: true })
167
+ // A renamed workflow leaves its old file behind otherwise.
168
+ if (alias !== workflow.alias && existsSync(file)) rmSync(file)
169
+ writeFileSync(join(ctx.schemaDir, 'workflows', fileNameFor(workflow.alias)), source, 'utf8')
170
+ await ctx.onChanged?.()
171
+ return json({ ok: true, affected, report: await syncWorkflowFiles(ctx.db, ctx.schemaDir) })
172
+ }
173
+ } catch (error) {
174
+ if (error instanceof ApprovalRefused)
175
+ return json({ ok: false, message: error.message }, error.status)
176
+ throw error
177
+ }
178
+ return undefined
179
+ }
180
+
181
+ const readOnly = () =>
182
+ json(
183
+ {
184
+ ok: false,
185
+ message:
186
+ 'The schema directory is read-only on this server, so a workflow cannot be saved here. Change the file in the repository and deploy it.',
187
+ },
188
+ 409,
189
+ )
@@ -0,0 +1,823 @@
1
+ /**
2
+ * Approval before publishing (story 028): which workflow covers a page, the
3
+ * gate that refuses publishing a covered page directly, and the requests that
4
+ * go through a workflow's stages. docs/workflows.md.
5
+ *
6
+ * The graph rules are `@crombolo/core`'s; the record is `ApprovalRepository`'s.
7
+ * This decides: who may act, what an edit does to a waiting request, when a
8
+ * stage is done, and when the page is published.
9
+ */
10
+ import type { Principal } from '@crombolo/api-management'
11
+ import {
12
+ approvalsNeeded,
13
+ DocumentPermissions,
14
+ descendantStages,
15
+ normaliseUuid,
16
+ permissionsForPath,
17
+ type SchemaWorkflow,
18
+ SEND_BACK_TO_EDITOR,
19
+ stagesNowWaiting,
20
+ startStages,
21
+ workflowComplete,
22
+ workflowFor,
23
+ } from '@crombolo/core'
24
+ import {
25
+ type ApprovalAction,
26
+ ApprovalRepository,
27
+ type ApprovalRepository as ApprovalRepositoryType,
28
+ type ApprovalRequest,
29
+ type ApprovalStep,
30
+ AuditLog,
31
+ type Db,
32
+ DocumentRepository,
33
+ NodeRepository,
34
+ type NodeSchemaState,
35
+ readWorkflows,
36
+ type StoredWorkflow,
37
+ UserGroupRepository,
38
+ } from '@crombolo/data'
39
+ import type { EmailPort } from './email.ts'
40
+
41
+ /** What publishing and unpublishing go through when approval says yes. */
42
+ export interface ApprovalPublisher {
43
+ publish(
44
+ key: string,
45
+ cultures: string[] | null,
46
+ ): Promise<{ ok: true } | { ok: false; reason: string }>
47
+ unpublish(
48
+ key: string,
49
+ cultures: string[] | null,
50
+ ): Promise<{ ok: true } | { ok: false; reason: string }>
51
+ }
52
+
53
+ export interface ApprovalsOptions {
54
+ db: Db
55
+ publisher: ApprovalPublisher
56
+ nodeState?: NodeSchemaState
57
+ email?: EmailPort
58
+ /** Where a link in an e-mail points, e.g. `https://site.example/crombolo`. */
59
+ backOfficeUrl?: string
60
+ }
61
+
62
+ /** A refusal a person reads: why, and what to do instead. */
63
+ export class ApprovalRefused extends Error {
64
+ constructor(
65
+ message: string,
66
+ readonly status = 400,
67
+ ) {
68
+ super(message)
69
+ }
70
+ }
71
+
72
+ /** The page a request or a check is about, as the engine needs it. */
73
+ interface PageFacts {
74
+ key: string
75
+ name: string
76
+ chain: string[]
77
+ contentTypeAlias: string | undefined
78
+ }
79
+
80
+ export interface PageApproval {
81
+ /** The workflow that covers the page, as it stands now. */
82
+ workflow: SchemaWorkflow | undefined
83
+ request: RequestView | undefined
84
+ /** Earlier requests, newest first. */
85
+ past: RequestView[]
86
+ can: { send: boolean; requestUnpublish: boolean; publishDirectly: boolean }
87
+ }
88
+
89
+ export interface RequestView {
90
+ key: string
91
+ nodeKey: string
92
+ nodeName: string
93
+ action: ApprovalAction
94
+ cultures: string[] | null
95
+ state: ApprovalRequest['state']
96
+ requestedBy: string
97
+ requestedAt: string
98
+ workflow: SchemaWorkflow
99
+ stages: Array<{
100
+ alias: string
101
+ state: 'waiting' | 'approved' | 'pending' | 'discarded'
102
+ approvals: number
103
+ needed: number
104
+ }>
105
+ history: Array<{
106
+ kind: string
107
+ stage: string | null
108
+ userKey: string | null
109
+ comment: string | null
110
+ at: string
111
+ }>
112
+ /** What the asking user may do now. */
113
+ can: { approve: string[]; sendBack: string[]; withdraw: boolean }
114
+ }
115
+
116
+ export interface Approvals {
117
+ /** The workflows the site has, as last synced. */
118
+ workflows(): Promise<StoredWorkflow[]>
119
+ /** Undefined when the caller may go ahead; otherwise why not. */
120
+ publishRefusal(
121
+ principal: Principal,
122
+ key: string,
123
+ action: ApprovalAction,
124
+ ): Promise<string | undefined>
125
+ /** A branch publish: the root and every page below it. */
126
+ branchRefusal(principal: Principal, key: string): Promise<string | undefined>
127
+ /** For a page being created and published at once: by its parent and type. */
128
+ newPageRefusal(
129
+ principal: Principal,
130
+ parentKey: string | null,
131
+ contentTypeKey: string,
132
+ ): Promise<string | undefined>
133
+ /** Undefined when the save may go ahead; otherwise why not. */
134
+ beforeSave(principal: Principal, key: string): Promise<string | undefined>
135
+ /** What the save does to a waiting request, once it has gone through. */
136
+ afterSave(principal: Principal, key: string): Promise<void>
137
+ send(
138
+ principal: Principal,
139
+ key: string,
140
+ action: ApprovalAction,
141
+ cultures: string[] | null,
142
+ comment?: string,
143
+ ): Promise<RequestView>
144
+ approve(
145
+ principal: Principal,
146
+ requestKey: string,
147
+ stage?: string,
148
+ comment?: string,
149
+ ): Promise<RequestView>
150
+ sendBack(
151
+ principal: Principal,
152
+ requestKey: string,
153
+ stage: string | undefined,
154
+ comment?: string,
155
+ ): Promise<RequestView>
156
+ withdraw(principal: Principal, requestKey: string): Promise<RequestView>
157
+ forPage(principal: Principal, key: string): Promise<PageApproval>
158
+ /** Requests waiting on a stage the user may approve. */
159
+ waitingFor(principal: Principal): Promise<RequestView[]>
160
+ inFlight(principal: Principal): Promise<RequestView[]>
161
+ finished(
162
+ principal: Principal,
163
+ skip: number,
164
+ take: number,
165
+ ): Promise<{ total: number; items: RequestView[] }>
166
+ }
167
+
168
+ export function createApprovals(options: ApprovalsOptions): Approvals {
169
+ const { db } = options
170
+ const requests = (tx: Db = db): ApprovalRepositoryType => new ApprovalRepository(tx)
171
+ const nodes = new NodeRepository(db)
172
+ const groups = new UserGroupRepository(db)
173
+ const audit = new AuditLog(db)
174
+
175
+ const page = async (key: string): Promise<PageFacts | undefined> => {
176
+ const node = await nodes.byKey(key)
177
+ if (!node) return undefined
178
+ const ids = node.path
179
+ .split(',')
180
+ .map(Number)
181
+ .filter((id) => id > 0)
182
+ const rows = await nodes.byIds(ids)
183
+ const keyOf = new Map(rows.map((r) => [r.id, r.key]))
184
+ const type = await db.query<{ alias: string }>(
185
+ `SELECT ct.alias FROM content c JOIN content_type ct ON ct.node_id = c.content_type_id WHERE c.node_id = ?`,
186
+ [node.id],
187
+ )
188
+ return {
189
+ key: node.key,
190
+ name: node.text ?? node.key,
191
+ chain: ids.flatMap((id) => keyOf.get(id) ?? []),
192
+ contentTypeAlias: type[0]?.alias,
193
+ }
194
+ }
195
+
196
+ const covering = async (facts: PageFacts): Promise<SchemaWorkflow | undefined> =>
197
+ workflowFor(
198
+ (await readWorkflows(db)).map((s) => s.workflow),
199
+ facts.chain,
200
+ facts.contentTypeAlias,
201
+ )
202
+
203
+ const groupAliases = async (principal: Principal): Promise<Set<string>> =>
204
+ new Set((await groups.byKeys(principal.groupKeys)).map((g) => g.alias))
205
+
206
+ const mayPublish = (principal: Principal, facts: PageFacts, action: ApprovalAction): boolean =>
207
+ principal.isAdmin ||
208
+ permissionsForPath(principal.groups, facts.chain).has(
209
+ action === 'publish' ? DocumentPermissions.Publish : DocumentPermissions.Unpublish,
210
+ )
211
+
212
+ const covers = (workflow: SchemaWorkflow | undefined, action: ApprovalAction) =>
213
+ workflow !== undefined && (action === 'publish' || workflow.unpublish)
214
+
215
+ const refusalFor = (
216
+ principal: Principal,
217
+ facts: PageFacts,
218
+ workflow: SchemaWorkflow | undefined,
219
+ action: ApprovalAction,
220
+ ): string | undefined => {
221
+ if (!workflow || !covers(workflow, action)) return undefined
222
+ if (workflow.publishersMaySkip && mayPublish(principal, facts, action)) return undefined
223
+ return action === 'publish'
224
+ ? `"${facts.name}" needs approval through "${workflow.name}" before it is published. Send it for approval from its Workflow tab.`
225
+ : `Taking "${facts.name}" down needs approval through "${workflow.name}". Request it from its Workflow tab.`
226
+ }
227
+
228
+ const membersOf = async (aliases: readonly string[]): Promise<number> => {
229
+ if (aliases.length === 0) return 0
230
+ const rows = await db.query<{ n: number }>(
231
+ `SELECT COUNT(DISTINCT m.user_id) AS n FROM user_group_member m
232
+ JOIN user_group g ON g.id = m.user_group_id WHERE g.alias IN (${aliases.map(() => '?').join(', ')})`,
233
+ [...aliases],
234
+ )
235
+ return Number(rows[0]?.n ?? 0)
236
+ }
237
+
238
+ const stageOf = (workflow: SchemaWorkflow, alias: string) =>
239
+ workflow.stages.find((s) => s.alias === alias)
240
+
241
+ /** Approvals given in a stage visit, by distinct user. */
242
+ const approvalsIn = async (
243
+ repo: ApprovalRepositoryType,
244
+ request: ApprovalRequest,
245
+ step: ApprovalStep,
246
+ ) => {
247
+ const history = await repo.history(request.id)
248
+ return new Set(
249
+ history
250
+ .filter((h) => h.kind === 'approved' && h.stepId === step.id && h.userKey)
251
+ .map((h) => h.userKey as string),
252
+ )
253
+ }
254
+
255
+ const notify = async (
256
+ to: 'approvers' | 'editor',
257
+ request: ApprovalRequest,
258
+ stages: readonly string[],
259
+ subject: string,
260
+ body: string,
261
+ ) => {
262
+ if (!options.email) return
263
+ let emails: string[] = []
264
+ if (to === 'editor') {
265
+ const rows = await db.query<{ email: string }>(
266
+ 'SELECT email FROM user_account WHERE key = ?',
267
+ [request.requestedBy],
268
+ )
269
+ emails = rows.map((r) => r.email)
270
+ } else {
271
+ const aliases = stages.flatMap((s) => stageOf(request.workflow, s)?.approvers ?? [])
272
+ if (aliases.length === 0) return
273
+ const rows = await db.query<{ email: string }>(
274
+ `SELECT DISTINCT u.email FROM user_account u
275
+ JOIN user_group_member m ON m.user_id = u.id JOIN user_group g ON g.id = m.user_group_id
276
+ WHERE g.alias IN (${aliases.map(() => '?').join(', ')}) AND u.is_approved = ?`,
277
+ [...aliases, db.dialect.boolValue(true)],
278
+ )
279
+ emails = rows.map((r) => r.email)
280
+ }
281
+ if (emails.length === 0) return
282
+ const link = options.backOfficeUrl
283
+ ? `\n\n${options.backOfficeUrl}/section/content/workspace/document/edit/${request.nodeKey}`
284
+ : ''
285
+ await options.email.send({
286
+ to: emails.map((email) => ({ email })),
287
+ subject,
288
+ text: `${body}${link}`,
289
+ })
290
+ }
291
+
292
+ const auditStep = async (
293
+ principal: Principal | undefined,
294
+ facts: PageFacts | undefined,
295
+ action: string,
296
+ detail: string,
297
+ ) => {
298
+ await audit.record({
299
+ category: 'content',
300
+ action,
301
+ source: 'request',
302
+ userKey: principal?.id ?? null,
303
+ userName: principal?.name ?? null,
304
+ entityType: 'Document',
305
+ entityKey: facts?.key ?? null,
306
+ entityName: facts?.name ?? null,
307
+ detail,
308
+ })
309
+ }
310
+
311
+ /**
312
+ * Stages just reached count the person who made the change as approving
313
+ * when the workflow says so and they are in the stage's groups.
314
+ */
315
+ const reach = async (
316
+ repo: ApprovalRepositoryType,
317
+ request: ApprovalRequest,
318
+ stages: readonly string[],
319
+ ): Promise<void> => {
320
+ for (const stage of stages) {
321
+ const stepId = await repo.addStep(request.id, stage)
322
+ if (request.workflow.selfApproval !== 'automatic') continue
323
+ const approvers = stageOf(request.workflow, stage)?.approvers ?? []
324
+ const member = await db.query<{ n: number }>(
325
+ `SELECT COUNT(*) AS n FROM user_group_member m JOIN user_group g ON g.id = m.user_group_id
326
+ JOIN user_account u ON u.id = m.user_id WHERE u.key = ? AND g.alias IN (${approvers.map(() => '?').join(', ') || "''"})`,
327
+ [request.requestedBy, ...approvers],
328
+ )
329
+ if (Number(member[0]?.n ?? 0) > 0)
330
+ await repo.record(request.id, {
331
+ kind: 'approved',
332
+ stepId,
333
+ stage,
334
+ userKey: request.requestedBy,
335
+ comment: 'counted automatically: they made the change',
336
+ })
337
+ }
338
+ }
339
+
340
+ /** Marks done whatever stages have their approvals, reaches what follows, and publishes at the end. */
341
+ /** True when every last stage has approved: the caller publishes, once its transaction is over. */
342
+ const settle = async (
343
+ repo: ApprovalRepositoryType,
344
+ request: ApprovalRequest,
345
+ ): Promise<boolean> => {
346
+ for (let round = 0; round < request.workflow.stages.length + 1; round++) {
347
+ const steps = await repo.steps(request.id)
348
+ let moved = false
349
+ for (const step of steps.filter((s) => s.state === 'waiting')) {
350
+ const stage = stageOf(request.workflow, step.stage)
351
+ if (!stage) continue
352
+ const given = await approvalsIn(repo, request, step)
353
+ if (given.size >= approvalsNeeded(stage.required, await membersOf(stage.approvers))) {
354
+ await repo.setStepState(step.id, 'approved')
355
+ moved = true
356
+ }
357
+ }
358
+ const now = await repo.steps(request.id)
359
+ const approved = new Set(now.filter((s) => s.state === 'approved').map((s) => s.stage))
360
+ const reached = new Set(
361
+ now.filter((s) => s.state === 'waiting' || s.state === 'approved').map((s) => s.stage),
362
+ )
363
+ if (workflowComplete(request.workflow, approved)) return true
364
+ const next = stagesNowWaiting(request.workflow, approved, reached)
365
+ if (next.length > 0) {
366
+ await reach(repo, request, next)
367
+ const facts = await page(request.nodeKey)
368
+ await notify(
369
+ 'approvers',
370
+ request,
371
+ next,
372
+ `Waiting for your approval: ${facts?.name ?? 'a page'}`,
373
+ `"${facts?.name}" is waiting for approval through "${request.workflow.name}".`,
374
+ )
375
+ moved = true
376
+ }
377
+ if (!moved) return false
378
+ }
379
+ return false
380
+ }
381
+
382
+ /**
383
+ * Publishes (or takes down) a fully approved page, outside any transaction:
384
+ * publishing takes the content tree's lock, which must never be waited on
385
+ * from inside one. A page that cannot be published goes back to its editor
386
+ * with the reason, rather than sitting approved and unpublished.
387
+ */
388
+ const finish = async (
389
+ request: ApprovalRequest,
390
+ principal: Principal | undefined,
391
+ ): Promise<void> => {
392
+ const repo = requests()
393
+ const facts = await page(request.nodeKey)
394
+ const result =
395
+ request.action === 'publish'
396
+ ? await options.publisher.publish(request.nodeKey, request.cultures)
397
+ : await options.publisher.unpublish(request.nodeKey, request.cultures)
398
+ if (!result.ok) {
399
+ await repo.setState(request.id, 'sent-back')
400
+ await repo.record(request.id, {
401
+ kind: 'sent-back',
402
+ comment: `approved, but it could not be ${request.action === 'publish' ? 'published' : 'taken down'}: ${result.reason}`,
403
+ })
404
+ await notify(
405
+ 'editor',
406
+ request,
407
+ [],
408
+ `Could not publish: ${facts?.name ?? 'a page'}`,
409
+ `"${facts?.name}" was approved but could not be published: ${result.reason}`,
410
+ )
411
+ return
412
+ }
413
+ await repo.setState(request.id, 'approved')
414
+ await repo.record(request.id, {
415
+ kind: request.action === 'publish' ? 'published' : 'unpublished',
416
+ userKey: principal?.id ?? null,
417
+ })
418
+ await auditStep(
419
+ principal,
420
+ facts,
421
+ request.action === 'publish' ? 'Published on approval' : 'Unpublished on approval',
422
+ `through "${request.workflow.name}"`,
423
+ )
424
+ await notify(
425
+ 'editor',
426
+ request,
427
+ [],
428
+ `Approved: ${facts?.name ?? 'a page'}`,
429
+ `"${facts?.name}" was approved through "${request.workflow.name}" and is now ${request.action === 'publish' ? 'published' : 'taken down'}.`,
430
+ )
431
+ }
432
+
433
+ const view = async (principal: Principal, request: ApprovalRequest): Promise<RequestView> => {
434
+ const repo = requests()
435
+ const steps = await repo.steps(request.id)
436
+ const history = await repo.history(request.id)
437
+ const mine = await groupAliases(principal)
438
+ const facts = await page(request.nodeKey)
439
+ const current = new Map<string, ApprovalStep>()
440
+ for (const step of steps)
441
+ if (step.state === 'waiting' || step.state === 'approved') current.set(step.stage, step)
442
+ const stages: RequestView['stages'] = []
443
+ for (const stage of request.workflow.stages) {
444
+ const step = current.get(stage.alias)
445
+ const given = step
446
+ ? history.filter((h) => h.kind === 'approved' && h.stepId === step.id).length
447
+ : 0
448
+ stages.push({
449
+ alias: stage.alias,
450
+ state: step
451
+ ? (step.state as 'waiting' | 'approved')
452
+ : steps.some((s) => s.stage === stage.alias)
453
+ ? 'discarded'
454
+ : 'pending',
455
+ approvals: given,
456
+ needed: approvalsNeeded(stage.required, await membersOf(stage.approvers)),
457
+ })
458
+ }
459
+ const waiting = request.state === 'waiting'
460
+ const actionable = waiting
461
+ ? request.workflow.stages.filter(
462
+ (s) => current.get(s.alias)?.state === 'waiting' && s.approvers.some((a) => mine.has(a)),
463
+ )
464
+ : []
465
+ const already = (alias: string) =>
466
+ history.some(
467
+ (h) =>
468
+ h.kind === 'approved' &&
469
+ h.stepId === current.get(alias)?.id &&
470
+ h.userKey === normaliseUuid(principal.id),
471
+ )
472
+ const ownChange = normaliseUuid(principal.id) === request.requestedBy
473
+ return {
474
+ key: request.key,
475
+ nodeKey: request.nodeKey,
476
+ nodeName: facts?.name ?? request.nodeKey,
477
+ action: request.action,
478
+ cultures: request.cultures,
479
+ state: request.state,
480
+ requestedBy: request.requestedBy,
481
+ requestedAt: request.requestedAt.toISOString(),
482
+ workflow: request.workflow,
483
+ stages,
484
+ history: history.map((h) => ({
485
+ kind: h.kind,
486
+ stage: h.stage,
487
+ userKey: h.userKey,
488
+ comment: h.comment,
489
+ at: h.createDate.toISOString(),
490
+ })),
491
+ can: {
492
+ approve:
493
+ ownChange && request.workflow.selfApproval === 'refused'
494
+ ? []
495
+ : actionable.filter((s) => !already(s.alias)).map((s) => s.alias),
496
+ sendBack: actionable.map((s) => s.alias),
497
+ withdraw: waiting && (ownChange || principal.isAdmin),
498
+ },
499
+ }
500
+ }
501
+
502
+ /** After a decision's transaction: publishes when it completed the workflow, then the request as it stands. */
503
+ const concluded = async (
504
+ principal: Principal,
505
+ outcome: { request: ApprovalRequest; complete: boolean },
506
+ ): Promise<RequestView> => {
507
+ if (outcome.complete) await finish(outcome.request, principal)
508
+ return view(principal, (await requests().byKey(outcome.request.key)) as ApprovalRequest)
509
+ }
510
+
511
+ const needsComment = (
512
+ workflow: SchemaWorkflow,
513
+ decision: 'approve' | 'send-back',
514
+ comment: string | undefined,
515
+ ) =>
516
+ (workflow.comments === 'always' ||
517
+ (workflow.comments === 'on-send-back' && decision === 'send-back')) &&
518
+ !comment?.trim()
519
+
520
+ const waitingRequest = async (requestKey: string): Promise<ApprovalRequest> => {
521
+ const request = await requests().byKey(requestKey)
522
+ if (!request) throw new ApprovalRefused('There is no such request.', 404)
523
+ if (request.state !== 'waiting')
524
+ throw new ApprovalRefused('This request has already been decided.', 409)
525
+ return request
526
+ }
527
+
528
+ return {
529
+ workflows: () => readWorkflows(db),
530
+
531
+ async publishRefusal(principal, key, action) {
532
+ const facts = await page(key)
533
+ if (!facts) return undefined
534
+ return refusalFor(principal, facts, await covering(facts), action)
535
+ },
536
+
537
+ async branchRefusal(principal, key) {
538
+ const root = await nodes.byKey(key)
539
+ if (!root) return undefined
540
+ const workflows = (await readWorkflows(db)).map((s) => s.workflow)
541
+ if (workflows.length === 0) return undefined
542
+ const below = await db.query<{ unique_id: string }>(
543
+ 'SELECT unique_id FROM node WHERE id = ? OR path LIKE ? ORDER BY level',
544
+ [root.id, `${root.path},%`],
545
+ )
546
+ for (const row of below) {
547
+ const facts = await page(String(row.unique_id))
548
+ if (!facts) continue
549
+ const refusal = refusalFor(principal, facts, await covering(facts), 'publish')
550
+ if (refusal) return refusal
551
+ }
552
+ return undefined
553
+ },
554
+
555
+ async newPageRefusal(principal, parentKey, contentTypeKey) {
556
+ const parent = parentKey ? await page(parentKey) : undefined
557
+ const type = await db.query<{ alias: string }>(
558
+ 'SELECT ct.alias FROM content_type ct JOIN node n ON n.id = ct.node_id WHERE n.unique_id = ?',
559
+ [normaliseUuid(contentTypeKey)],
560
+ )
561
+ const facts: PageFacts = {
562
+ key: '',
563
+ name: 'This page',
564
+ chain: parent?.chain ?? [],
565
+ contentTypeAlias: type[0]?.alias,
566
+ }
567
+ return refusalFor(principal, facts, await covering(facts), 'publish')
568
+ },
569
+
570
+ async beforeSave(principal, key) {
571
+ const request = await requests().waitingFor(key)
572
+ if (!request || principal.isAdmin) return undefined
573
+ const edit = request.workflow.onEdit
574
+ if (edit === 'lock')
575
+ return 'This page is waiting for approval and cannot be changed until it is decided.'
576
+ if (edit === 'lock-after-first-approval') {
577
+ const history = await requests().history(request.id)
578
+ if (history.some((h) => h.kind === 'approved'))
579
+ return 'This page has been approved by someone already, and cannot be changed until the request is decided.'
580
+ }
581
+ return undefined
582
+ },
583
+
584
+ async afterSave(principal, key) {
585
+ const repo = requests()
586
+ const request = await repo.waitingFor(key)
587
+ if (!request) return
588
+ const head =
589
+ (await new DocumentRepository(db, { nodeState: options.nodeState }).eventMarkers(key))
590
+ ?.head ?? null
591
+ if (head === request.eventId) return
592
+ // Administrators may always edit; the request goes on, on what they saved.
593
+ if (!principal.isAdmin && request.workflow.onEdit === 'withdraw') {
594
+ await repo.setState(request.id, 'withdrawn')
595
+ await repo.record(request.id, {
596
+ kind: 'withdrawn',
597
+ userKey: principal.id,
598
+ comment: 'the page was changed after it was sent',
599
+ })
600
+ return
601
+ }
602
+ await repo.setEvent(request.id, head)
603
+ await repo.record(request.id, { kind: 'edited', userKey: principal.id })
604
+ },
605
+
606
+ async send(principal, key, action, cultures, comment) {
607
+ const facts = await page(key)
608
+ if (!facts) throw new ApprovalRefused('The page could not be found.', 404)
609
+ const workflow = await covering(facts)
610
+ if (!workflow || !covers(workflow, action))
611
+ throw new ApprovalRefused(
612
+ action === 'publish'
613
+ ? 'No workflow covers this page: it can be published directly.'
614
+ : 'Taking this page down needs no approval.',
615
+ )
616
+ const stored = (await readWorkflows(db)).find(
617
+ (s) => (s.workflow.key ?? s.workflow.alias) === (workflow.key ?? workflow.alias),
618
+ )
619
+ const outcome = await db.transaction(async (tx) => {
620
+ const repo = requests(tx)
621
+ if (await repo.waitingFor(key))
622
+ throw new ApprovalRefused('This page is already waiting for approval.', 409)
623
+ const head =
624
+ (await new DocumentRepository(tx, { nodeState: options.nodeState }).eventMarkers(key))
625
+ ?.head ?? null
626
+ const request = await repo.create({
627
+ nodeKey: key,
628
+ cultures,
629
+ action,
630
+ eventId: head,
631
+ workflow,
632
+ workflowVersion: stored?.version ?? '',
633
+ requestedBy: principal.id,
634
+ })
635
+ await repo.record(request.id, {
636
+ kind: 'sent',
637
+ userKey: principal.id,
638
+ comment: comment?.trim() || null,
639
+ })
640
+ const first = startStages(workflow)
641
+ await reach(repo, request, first)
642
+ await notify(
643
+ 'approvers',
644
+ request,
645
+ first,
646
+ `Waiting for your approval: ${facts.name}`,
647
+ `${principal.name} sent "${facts.name}" for approval through "${workflow.name}".${comment ? `\n\n${comment}` : ''}`,
648
+ )
649
+ const complete = await settle(repo, request)
650
+ await auditStep(
651
+ principal,
652
+ facts,
653
+ action === 'publish' ? 'Sent for approval' : 'Requested unpublish',
654
+ `through "${workflow.name}"`,
655
+ )
656
+ return { request, complete }
657
+ })
658
+ return concluded(principal, outcome)
659
+ },
660
+
661
+ async approve(principal, requestKey, stage, comment) {
662
+ const request = await waitingRequest(requestKey)
663
+ const current = await view(principal, request)
664
+ const target = stage ?? current.can.approve[0]
665
+ if (!target || !current.can.approve.includes(target))
666
+ throw new ApprovalRefused(
667
+ normaliseUuid(principal.id) === request.requestedBy &&
668
+ request.workflow.selfApproval === 'refused'
669
+ ? 'You made this change, so someone else has to approve it.'
670
+ : 'There is no stage waiting for your approval on this request.',
671
+ 403,
672
+ )
673
+ if (needsComment(request.workflow, 'approve', comment))
674
+ throw new ApprovalRefused('This workflow asks for a comment with every decision.')
675
+ const outcome = await db.transaction(async (tx) => {
676
+ const repo = requests(tx)
677
+ const step = (await repo.steps(request.id)).find(
678
+ (s) => s.stage === target && s.state === 'waiting',
679
+ )
680
+ await repo.record(request.id, {
681
+ kind: 'approved',
682
+ stepId: step?.id ?? null,
683
+ stage: target,
684
+ userKey: principal.id,
685
+ comment: comment?.trim() || null,
686
+ })
687
+ const complete = await settle(repo, request)
688
+ await auditStep(
689
+ principal,
690
+ await page(request.nodeKey),
691
+ 'Approved',
692
+ `stage "${target}" of "${request.workflow.name}"`,
693
+ )
694
+ return { request, complete }
695
+ })
696
+ return concluded(principal, outcome)
697
+ },
698
+
699
+ async sendBack(principal, requestKey, stage, comment) {
700
+ const request = await waitingRequest(requestKey)
701
+ const current = await view(principal, request)
702
+ const from = stage ?? current.can.sendBack[0]
703
+ if (!from || !current.can.sendBack.includes(from))
704
+ throw new ApprovalRefused(
705
+ 'There is no stage waiting for your decision on this request.',
706
+ 403,
707
+ )
708
+ if (needsComment(request.workflow, 'send-back', comment))
709
+ throw new ApprovalRefused('Say why it is being sent back.')
710
+ const to = stageOf(request.workflow, from)?.sendBack ?? SEND_BACK_TO_EDITOR
711
+ const outcome = await db.transaction(async (tx) => {
712
+ const repo = requests(tx)
713
+ let complete = false
714
+ const steps = await repo.steps(request.id)
715
+ await repo.record(request.id, {
716
+ kind: 'sent-back',
717
+ stage: from,
718
+ userKey: principal.id,
719
+ comment: comment?.trim() || null,
720
+ })
721
+ const facts = await page(request.nodeKey)
722
+ if (to === SEND_BACK_TO_EDITOR) {
723
+ for (const step of steps.filter((s) => s.state === 'waiting' || s.state === 'approved'))
724
+ await repo.setStepState(step.id, step.stage === from ? 'sent-back' : 'discarded')
725
+ await repo.setState(request.id, 'sent-back')
726
+ await notify(
727
+ 'editor',
728
+ request,
729
+ [],
730
+ `Sent back: ${facts?.name ?? 'a page'}`,
731
+ `"${facts?.name}" was sent back${comment ? `:\n\n${comment}` : '.'}`,
732
+ )
733
+ } else {
734
+ // The stage it goes back to runs again, and so does everything after it.
735
+ const redo = new Set([to, ...descendantStages(request.workflow, to)])
736
+ for (const step of steps.filter(
737
+ (s) => (s.state === 'waiting' || s.state === 'approved') && redo.has(s.stage),
738
+ ))
739
+ await repo.setStepState(step.id, step.stage === from ? 'sent-back' : 'discarded')
740
+ await reach(repo, request, [to])
741
+ await notify(
742
+ 'approvers',
743
+ request,
744
+ [to],
745
+ `Back for your approval: ${facts?.name ?? 'a page'}`,
746
+ `"${facts?.name}" was sent back to you${comment ? `:\n\n${comment}` : '.'}`,
747
+ )
748
+ complete = await settle(repo, request)
749
+ }
750
+ await auditStep(
751
+ principal,
752
+ facts,
753
+ 'Sent back',
754
+ `from stage "${from}" of "${request.workflow.name}" to ${to === SEND_BACK_TO_EDITOR ? 'the editor' : `"${to}"`}`,
755
+ )
756
+ return { request, complete }
757
+ })
758
+ return concluded(principal, outcome)
759
+ },
760
+
761
+ async withdraw(principal, requestKey) {
762
+ const request = await waitingRequest(requestKey)
763
+ if (normaliseUuid(principal.id) !== request.requestedBy && !principal.isAdmin)
764
+ throw new ApprovalRefused(
765
+ 'Only the person who sent it, or an administrator, can withdraw it.',
766
+ 403,
767
+ )
768
+ await requests().setState(request.id, 'withdrawn')
769
+ await requests().record(request.id, { kind: 'withdrawn', userKey: principal.id })
770
+ await auditStep(
771
+ principal,
772
+ await page(request.nodeKey),
773
+ 'Withdrew approval request',
774
+ `under "${request.workflow.name}"`,
775
+ )
776
+ return view(principal, (await requests().byKey(request.key)) as ApprovalRequest)
777
+ },
778
+
779
+ async forPage(principal, key) {
780
+ const facts = await page(key)
781
+ if (!facts) throw new ApprovalRefused('The page could not be found.', 404)
782
+ const workflow = await covering(facts)
783
+ const all = await requests().forNode(key)
784
+ const waiting = all.find((r) => r.state === 'waiting')
785
+ return {
786
+ workflow,
787
+ request: waiting ? await view(principal, waiting) : undefined,
788
+ past: await Promise.all(
789
+ all
790
+ .filter((r) => r !== waiting)
791
+ .slice(0, 10)
792
+ .map((r) => view(principal, r)),
793
+ ),
794
+ can: {
795
+ send: !waiting && covers(workflow, 'publish'),
796
+ requestUnpublish: !waiting && covers(workflow, 'unpublish'),
797
+ publishDirectly: refusalFor(principal, facts, workflow, 'publish') === undefined,
798
+ },
799
+ }
800
+ },
801
+
802
+ async waitingFor(principal) {
803
+ const out: RequestView[] = []
804
+ for (const request of await requests().waiting()) {
805
+ const v = await view(principal, request)
806
+ if (v.can.approve.length > 0 || v.can.sendBack.length > 0) out.push(v)
807
+ }
808
+ return out
809
+ },
810
+
811
+ async inFlight(principal) {
812
+ return Promise.all((await requests().waiting()).map((r) => view(principal, r)))
813
+ },
814
+
815
+ async finished(principal, skip, take) {
816
+ const page = await requests().finished(skip, take)
817
+ return {
818
+ total: page.total,
819
+ items: await Promise.all(page.items.map((r) => view(principal, r))),
820
+ }
821
+ },
822
+ }
823
+ }
package/src/config.ts CHANGED
@@ -272,6 +272,16 @@ export interface CromboloConfig {
272
272
  * deploy turns it off, and an install can decline it for itself.
273
273
  */
274
274
  applyBundleContentOnInstall: boolean
275
+ /**
276
+ * Whether the Packages section may install and remove packages on the running
277
+ * site (`bun add` on the node that served the request). Off, the Marketplace
278
+ * still lists packages and says to add them to `package.json` and deploy:
279
+ * what a site of several nodes, or of containers that start afresh, needs —
280
+ * an install there reaches one node and is lost at its next restart. On unless
281
+ * set, or unless a schema store is configured, which says the same thing
282
+ * about the nodes' disks. `CROMBOLO_RUNTIME_INSTALLS`.
283
+ */
284
+ runtimeInstalls: boolean
275
285
  /**
276
286
  * Where the Packages section's Marketplace sends someone browsing for
277
287
  * extensions. Never framed — npm refuses to be framed — so the native view
@@ -495,6 +505,8 @@ export function loadConfig(
495
505
  redirects: [],
496
506
  trackRedirects: Bun.env.CROMBOLO_TRACK_REDIRECTS !== 'false',
497
507
  applyBundleContentOnInstall: Bun.env.CROMBOLO_APPLY_BUNDLE_CONTENT !== 'false',
508
+ // Resolved again below, once it is known whether a store was configured.
509
+ runtimeInstalls: Bun.env.CROMBOLO_RUNTIME_INSTALLS !== 'false',
498
510
  seo: stripUndefined({
499
511
  sitemap: envFlag(Bun.env.CROMBOLO_SITEMAP),
500
512
  indexable: envFlag(Bun.env.CROMBOLO_INDEXABLE),
@@ -533,6 +545,15 @@ export function loadConfig(
533
545
  ) {
534
546
  merged.schemaWritable = true
535
547
  }
548
+ // Likewise a store says the nodes' disks do not last, so an install on one of
549
+ // them would not either. An explicit setting still wins.
550
+ if (
551
+ merged.schemaStore &&
552
+ Bun.env.CROMBOLO_RUNTIME_INSTALLS === undefined &&
553
+ overrides.runtimeInstalls === undefined
554
+ ) {
555
+ merged.runtimeInstalls = false
556
+ }
536
557
  // Relative paths are relative to the site, not to wherever the process started.
537
558
  merged.siteDir = resolve(cwd, merged.siteDir)
538
559
  // Against the site rather than the working directory: it has to sit where the
@@ -597,4 +618,4 @@ export function describeDatabase(
597
618
  }
598
619
  }
599
620
 
600
- export const VERSION = '0.12.1'
621
+ export const VERSION = '0.13.1'
@@ -88,6 +88,11 @@ export interface MarketplaceOptions {
88
88
  applyContent?: (packageName: string) => Promise<ContentOutcome>
89
89
  /** The site's `applyBundleContentOnInstall`. */
90
90
  applyContentOnInstall?: boolean
91
+ /**
92
+ * False refuses installing and removing here: the dependency belongs in
93
+ * `package.json` and a deploy, so every node has it and it outlives a restart.
94
+ */
95
+ installs?: boolean
91
96
  }
92
97
 
93
98
  export interface InstallOutcome {
@@ -308,6 +313,7 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
308
313
  },
309
314
 
310
315
  async install(name, version, request = {}) {
316
+ if (options.installs === false) return refusedHere(name, 'add')
311
317
  if (!isValidPackageName(name))
312
318
  return { ok: false, message: `'${name}' is not a package name npm would accept.` }
313
319
  if (version !== undefined && !isValidVersion(version))
@@ -365,6 +371,7 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
365
371
  },
366
372
 
367
373
  async uninstall(name) {
374
+ if (options.installs === false) return refusedHere(name, 'remove')
368
375
  if (!isValidPackageName(name))
369
376
  return { ok: false, message: `'${name}' is not a package name npm would accept.` }
370
377
  const result = await install('remove', name, options.siteDir)
@@ -379,4 +386,15 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
379
386
  }
380
387
  }
381
388
 
389
+ /** What a site that does not install at runtime says instead, with what to do. */
390
+ function refusedHere(name: string, verb: 'add' | 'remove'): InstallOutcome {
391
+ return {
392
+ ok: false,
393
+ message:
394
+ verb === 'add'
395
+ ? `Packages are not installed from the backoffice on this site. Add ${name} to the site's package.json (bun add ${name}) and deploy, so every server has it.`
396
+ : `Packages are not removed from the backoffice on this site. Remove ${name} from the site's package.json (bun remove ${name}) and deploy.`,
397
+ }
398
+ }
399
+
382
400
  export { EXTENSION_KEYWORD }
package/src/ports.ts CHANGED
@@ -70,6 +70,7 @@ import { createTemplateFileStore } from './adapters/template-files.ts'
70
70
  import { createTemplateQueryPort } from './adapters/template-query.ts'
71
71
  import { avatarUrls, createUserPorts } from './adapters/users.ts'
72
72
  import { createWebhookPort } from './adapters/webhooks.ts'
73
+ import { createApprovals } from './approvals.ts'
73
74
  import { COMPONENT_SNIPPETS } from './component-snippets.ts'
74
75
  import { type CromboloConfig, type UserLinkSender, VERSION } from './config.ts'
75
76
  import { type EmailPort, resolveEmailPort } from './email.ts'
@@ -603,6 +604,20 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
603
604
  }),
604
605
  options.config.siteDir,
605
606
  )
607
+ if (deps.documents) {
608
+ const documents = deps.documents
609
+ deps.approvals = createApprovals({
610
+ db: options.db,
611
+ nodeState: options.schema?.nodeState,
612
+ email,
613
+ // Through the same port an editor's publish takes, so what approval
614
+ // publishes is cached, tracked and announced as any publish is.
615
+ publisher: {
616
+ publish: (key, cultures) => documents.publish(key, cultures),
617
+ unpublish: (key, cultures) => documents.unpublish(key, cultures),
618
+ },
619
+ })
620
+ }
606
621
  }
607
622
  return deps
608
623
  }
package/src/server.ts CHANGED
@@ -84,6 +84,8 @@ import { createAuditHost, createAuditSink, pluginAuditPlan, recordAudit } from '
84
84
  import { readCreatedBundlePackage, writeCreatedBundlePackage } from './adapters/bundles.ts'
85
85
  import { createPublishedContentSource, loadDraftNode } from './adapters/documents.ts'
86
86
  import { createSchemaProposals } from './adapters/schema-proposals.ts'
87
+ import type { Approvals } from './approvals.ts'
88
+ import { handleApprovalsApi } from './approvals-api.ts'
87
89
  import { CommandError, installBundleContent } from './bundle-install.ts'
88
90
  import { bundleChains, bundleTablesReport } from './bundle-tables.ts'
89
91
  import { createBundleRedirects, createServerBundles, validateServerBundles } from './bundles.ts'
@@ -247,6 +249,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
247
249
  contentApplied: async (packageName) =>
248
250
  (await new TransferRunRepository(db).appliedForPackage(packageName))?.packageVersion ?? null,
249
251
  applyContentOnInstall: config.applyBundleContentOnInstall,
252
+ installs: config.runtimeInstalls,
250
253
  applyContent: async (packageName) => {
251
254
  // A package from the marketplace is for anyone, so nothing in it can be
252
255
  // sealed; one that is is refused rather than half applied.
@@ -657,37 +660,36 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
657
660
  }
658
661
  : undefined,
659
662
  })
660
- const api = createManagementApiRouter({
661
- deps: {
662
- ...createDeps({
663
- config,
664
- paths,
665
- extensions,
666
- db,
667
- webhooks,
668
- cache,
669
- schema,
670
- mediaFiles,
671
- email,
672
- onSearchQueued: () => jobs.kickSearchIndexing(),
673
- // This node looks again at once; the others are told to. The write has
674
- // already landed wherever `componentsDir` points, so there is nothing to
675
- // distribute — only the fact that it happened.
676
- onViewsChange: async (alias) => {
677
- // The snapshot first, and awaited: the hash announced is then the one
678
- // this write produced rather than the generation it replaced, and the
679
- // editor's next request renders what they just saved.
680
- const hash = await snapshots.refresh()
681
- void appendCacheInstruction(db, {
682
- kind: 'components',
683
- payload: hash ? { alias, hash } : { alias },
684
- by: schema.nodeId,
685
- }).catch((error: unknown) => {
686
- renderLog.error('Could not announce the template {alias}: {error}', { alias, error })
687
- })
688
- },
689
- }),
663
+ const managementDeps = createDeps({
664
+ config,
665
+ paths,
666
+ extensions,
667
+ db,
668
+ webhooks,
669
+ cache,
670
+ schema,
671
+ mediaFiles,
672
+ email,
673
+ onSearchQueued: () => jobs.kickSearchIndexing(),
674
+ // This node looks again at once; the others are told to. The write has
675
+ // already landed wherever `componentsDir` points, so there is nothing to
676
+ // distribute — only the fact that it happened.
677
+ onViewsChange: async (alias) => {
678
+ // The snapshot first, and awaited: the hash announced is then the one
679
+ // this write produced rather than the generation it replaced, and the
680
+ // editor's next request renders what they just saved.
681
+ const hash = await snapshots.refresh()
682
+ void appendCacheInstruction(db, {
683
+ kind: 'components',
684
+ payload: hash ? { alias, hash } : { alias },
685
+ by: schema.nodeId,
686
+ }).catch((error: unknown) => {
687
+ renderLog.error('Could not announce the template {alias}: {error}', { alias, error })
688
+ })
690
689
  },
690
+ })
691
+ const api = createManagementApiRouter({
692
+ deps: managementDeps,
691
693
  authenticate,
692
694
  nodes: createNodeLookup(db),
693
695
  allowLocalLogin: config.allowLocalLogin,
@@ -1089,6 +1091,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1089
1091
  url: marketplace.url(),
1090
1092
  keyword: config.packageKeyword,
1091
1093
  appliesContent: config.applyBundleContentOnInstall,
1094
+ installs: config.runtimeInstalls,
1092
1095
  items: await marketplace.search(url.searchParams.get('q') ?? undefined),
1093
1096
  })
1094
1097
  }
@@ -1110,7 +1113,10 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1110
1113
  }
1111
1114
 
1112
1115
  if (action === 'installed' && request.method === 'GET') {
1113
- return Response.json({ items: await marketplace.installed() })
1116
+ return Response.json({
1117
+ installs: config.runtimeInstalls,
1118
+ items: await marketplace.installed(),
1119
+ })
1114
1120
  }
1115
1121
 
1116
1122
  if ((action === 'install' || action === 'uninstall') && request.method === 'POST') {
@@ -1460,6 +1466,43 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1460
1466
  : Response.json({ ok: false, message: outcome.message }, { status: outcome.status })
1461
1467
  }
1462
1468
 
1469
+ // Non-contract: approval workflows (story 028) — the page's Workflow tab, the
1470
+ // Workflow section and its designer. Content or Workflow, as either screen is.
1471
+ if (pathname.startsWith(`${paths.pluginPath}/api/approvals/`)) {
1472
+ const principal = await authenticate(request)
1473
+ if (!principal) return new Response('Unauthorized', { status: 401 })
1474
+ if (!hasSection(principal, 'content') && !hasSection(principal, 'workflow'))
1475
+ return new Response('Forbidden', { status: 403 })
1476
+ const approvals = managementDeps.approvals as Approvals | undefined
1477
+ if (!approvals) return new Response('Not Found', { status: 404 })
1478
+ const response = await handleApprovalsApi(
1479
+ request,
1480
+ pathname.slice(`${paths.pluginPath}/api/approvals/`.length),
1481
+ {
1482
+ db,
1483
+ approvals,
1484
+ principal,
1485
+ schemaDir: config.schemaDir,
1486
+ writable: config.schemaWritable,
1487
+ onChanged: config.schemaStore
1488
+ ? async () => {
1489
+ await publishSchema(config.schemaStore as SchemaStore, config.schemaDir)
1490
+ }
1491
+ : undefined,
1492
+ },
1493
+ )
1494
+ if (response) {
1495
+ if (request.method !== 'GET')
1496
+ log.info('{user} {verb} on approvals: {status}', {
1497
+ user: principal.email,
1498
+ verb: pathname.split('/').slice(-1)[0],
1499
+ status: response.status,
1500
+ })
1501
+ return response
1502
+ }
1503
+ return new Response('Not Found', { status: 404 })
1504
+ }
1505
+
1463
1506
  // Non-contract, Packages: answering a content import's conflicts and running
1464
1507
  // it, from the Changes dashboard. Packages, because installing is.
1465
1508
  if (pathname.startsWith(`${paths.pluginPath}/api/transfers/`)) {