@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 +12 -12
- package/src/adapters/bundles.ts +2 -1
- package/src/approvals-api.ts +189 -0
- package/src/approvals.ts +823 -0
- package/src/config.ts +22 -1
- package/src/marketplace.ts +18 -0
- package/src/ports.ts +15 -0
- package/src/server.ts +74 -31
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/server",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
32
|
-
"@crombolo/api-management": "0.
|
|
33
|
-
"@crombolo/assistant": "0.
|
|
34
|
-
"@crombolo/auth": "0.
|
|
35
|
-
"@crombolo/backoffice-host": "0.
|
|
36
|
-
"@crombolo/contracts": "0.
|
|
37
|
-
"@crombolo/core": "0.
|
|
38
|
-
"@crombolo/data": "0.
|
|
39
|
-
"@crombolo/render": "0.
|
|
40
|
-
"@crombolo/schema": "0.
|
|
41
|
-
"@crombolo/transfer": "0.
|
|
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
|
}
|
package/src/adapters/bundles.ts
CHANGED
|
@@ -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:
|
|
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
|
+
)
|
package/src/approvals.ts
ADDED
|
@@ -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.
|
|
621
|
+
export const VERSION = '0.13.1'
|
package/src/marketplace.ts
CHANGED
|
@@ -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
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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({
|
|
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/`)) {
|