@crombolo/server 0.12.1 → 0.13.2
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 +838 -0
- package/src/config.ts +22 -1
- package/src/marketplace.ts +37 -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.2",
|
|
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.2",
|
|
32
|
+
"@crombolo/api-management": "0.13.2",
|
|
33
|
+
"@crombolo/assistant": "0.13.2",
|
|
34
|
+
"@crombolo/auth": "0.13.2",
|
|
35
|
+
"@crombolo/backoffice-host": "0.13.2",
|
|
36
|
+
"@crombolo/contracts": "0.13.2",
|
|
37
|
+
"@crombolo/core": "0.13.2",
|
|
38
|
+
"@crombolo/data": "0.13.2",
|
|
39
|
+
"@crombolo/render": "0.13.2",
|
|
40
|
+
"@crombolo/schema": "0.13.2",
|
|
41
|
+
"@crombolo/transfer": "0.13.2",
|
|
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 Workflows 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
|
+
)
|