@crombolo/server 0.11.0 → 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 -11
- package/src/adapters/bundles.ts +2 -1
- package/src/adapters/content.ts +2 -0
- package/src/adapters/documents.ts +28 -25
- package/src/adapters/elements.ts +3 -0
- package/src/adapters/health.ts +333 -0
- package/src/adapters/media.ts +3 -0
- package/src/adapters/members.ts +10 -2
- package/src/adapters/references.ts +13 -0
- package/src/adapters/relations.ts +48 -0
- package/src/adapters/schema-files.ts +41 -11
- package/src/adapters/schema-proposals.ts +13 -6
- package/src/adapters/webhooks.ts +76 -0
- package/src/approvals-api.ts +189 -0
- package/src/approvals.ts +823 -0
- package/src/boot-failure.ts +147 -0
- package/src/bundle-install.ts +148 -13
- package/src/bundles.ts +14 -0
- package/src/busy.ts +13 -0
- package/src/config.ts +54 -2
- package/src/database.ts +9 -7
- package/src/delivery-members.ts +387 -0
- package/src/editor-types.ts +7 -2
- package/src/extensions-notice.ts +3 -2
- package/src/form-files.ts +38 -3
- package/src/form-submit.ts +62 -5
- package/src/form-types.ts +187 -0
- package/src/form-workflows.ts +66 -6
- package/src/forms.ts +15 -7
- package/src/index.ts +11 -0
- package/src/jobs.ts +17 -0
- package/src/marketplace.ts +18 -0
- package/src/media-files.ts +3 -1
- package/src/member-auth.ts +17 -1
- package/src/models-builder.ts +12 -8
- package/src/ports.ts +66 -2
- package/src/schema.ts +58 -11
- package/src/secrets.ts +36 -0
- package/src/server.ts +388 -57
- package/src/setup.ts +288 -0
- package/src/status.ts +5 -2
- package/src/transfer-dashboard.ts +219 -0
- package/src/upgrade-summary.ts +43 -0
- package/src/url-settings.ts +91 -0
- package/src/webhook-files.ts +138 -0
- package/src/webhooks.ts +544 -0
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
9
9
|
import { join, relative } from 'node:path'
|
|
10
10
|
import type { PortFailure } from '@crombolo/api-management'
|
|
11
|
-
import type { ContentTypeAggregate, DataTypeModel } from '@crombolo/core'
|
|
11
|
+
import type { ContentTypeAggregate, DataTypeModel, FormTypes } from '@crombolo/core'
|
|
12
12
|
import { currentSchemaState, type Db, updateSchemaStateHash } from '@crombolo/data'
|
|
13
13
|
import {
|
|
14
14
|
type ChangeClass,
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
loadSchemaDirectory,
|
|
19
19
|
nextSchemaVersion,
|
|
20
20
|
type SchemaTypeKind,
|
|
21
|
+
type SchemaUrls,
|
|
21
22
|
TYPE_KIND_FILES,
|
|
22
23
|
validateSchemaSet,
|
|
23
24
|
writeContentType,
|
|
@@ -32,6 +33,8 @@ export interface SchemaFiles {
|
|
|
32
33
|
schemaDir: string
|
|
33
34
|
writable: boolean
|
|
34
35
|
componentAliases(): ReadonlySet<string>
|
|
36
|
+
/** The form types the site's wired bundles add. */
|
|
37
|
+
formTypes?: FormTypes
|
|
35
38
|
/**
|
|
36
39
|
* Called after any write, so a shared schema store outlives this container.
|
|
37
40
|
* Absent when the schema is a directory on disk, which needs no publishing.
|
|
@@ -54,6 +57,8 @@ export interface SchemaFileWriter {
|
|
|
54
57
|
writeDataType(model: DataTypeModel): Promise<void>
|
|
55
58
|
removeDataType(alias: string): Promise<void>
|
|
56
59
|
writeLanguages(): Promise<void>
|
|
60
|
+
/** `[urls]` in `schema.toml`, the version left as it is. */
|
|
61
|
+
writeUrlOptions(urls: SchemaUrls): Promise<void>
|
|
57
62
|
/** After a move or a folder change: every file, from the database, canonically. */
|
|
58
63
|
rewriteAll(): Promise<void>
|
|
59
64
|
}
|
|
@@ -82,7 +87,7 @@ export function createSchemaFileWriter(
|
|
|
82
87
|
await updateSchemaStateHash(
|
|
83
88
|
db,
|
|
84
89
|
state.id,
|
|
85
|
-
hashSchemaSet(loadSchemaDirectory(files.schemaDir).set),
|
|
90
|
+
hashSchemaSet(loadSchemaDirectory(files.schemaDir, { formTypes: files.formTypes }).set),
|
|
86
91
|
)
|
|
87
92
|
await files.onChanged?.()
|
|
88
93
|
}
|
|
@@ -100,10 +105,10 @@ export function createSchemaFileWriter(
|
|
|
100
105
|
|
|
101
106
|
async bumpVersion(classification) {
|
|
102
107
|
if (!managed() || !files.writable || classification === 'none') return
|
|
103
|
-
const loaded = loadSchemaDirectory(files.schemaDir)
|
|
108
|
+
const loaded = loadSchemaDirectory(files.schemaDir, { formTypes: files.formTypes })
|
|
104
109
|
const next = nextSchemaVersion(loaded.set.version, classification)
|
|
105
110
|
if (next === loaded.set.version) return
|
|
106
|
-
writeFileSync(join(files.schemaDir, 'schema.toml'), writeSchemaVersion(next))
|
|
111
|
+
writeFileSync(join(files.schemaDir, 'schema.toml'), writeSchemaVersion(next, loaded.set.urls))
|
|
107
112
|
logger('schema').info('Schema version {from} → {to} ({classification})', {
|
|
108
113
|
from: loaded.set.version,
|
|
109
114
|
to: next,
|
|
@@ -113,7 +118,10 @@ export function createSchemaFileWriter(
|
|
|
113
118
|
|
|
114
119
|
async write(aggregate, previousAlias) {
|
|
115
120
|
if (!managed() || !files.writable) return
|
|
116
|
-
const set = await exportSchemaSet(
|
|
121
|
+
const set = await exportSchemaSet(
|
|
122
|
+
db,
|
|
123
|
+
loadSchemaDirectory(files.schemaDir, { formTypes: files.formTypes }).set.version,
|
|
124
|
+
)
|
|
117
125
|
const list =
|
|
118
126
|
kind === 'document'
|
|
119
127
|
? set.contentTypes
|
|
@@ -122,7 +130,10 @@ export function createSchemaFileWriter(
|
|
|
122
130
|
: (set.memberTypes ?? [])
|
|
123
131
|
const type = list.find((t) => t.key === aggregate.key)
|
|
124
132
|
if (!type) return
|
|
125
|
-
const problems = validateSchemaSet(set, {
|
|
133
|
+
const problems = validateSchemaSet(set, {
|
|
134
|
+
componentAliases: files.componentAliases(),
|
|
135
|
+
types: files.formTypes,
|
|
136
|
+
})
|
|
126
137
|
if (problems.length > 0) {
|
|
127
138
|
const detail = problems.map((p) => `${p.file}: ${p.message}`).join('; ')
|
|
128
139
|
logger('schema').error('Not writing {file}: {detail}', {
|
|
@@ -146,7 +157,10 @@ export function createSchemaFileWriter(
|
|
|
146
157
|
|
|
147
158
|
async writeDataType(model) {
|
|
148
159
|
if (!managed() || !files.writable || !model.alias) return
|
|
149
|
-
const set = await exportSchemaSet(
|
|
160
|
+
const set = await exportSchemaSet(
|
|
161
|
+
db,
|
|
162
|
+
loadSchemaDirectory(files.schemaDir, { formTypes: files.formTypes }).set.version,
|
|
163
|
+
)
|
|
150
164
|
const dataType = set.dataTypes.find((d) => d.key === model.key)
|
|
151
165
|
// An unchanged built-in has no file; a changed one does.
|
|
152
166
|
if (!dataType) {
|
|
@@ -157,7 +171,10 @@ export function createSchemaFileWriter(
|
|
|
157
171
|
// The same guard `write` applies to a content type: a set that does not
|
|
158
172
|
// validate never reaches the disk, because the file is what every other
|
|
159
173
|
// node reads and what a deploy boots from.
|
|
160
|
-
const problems = validateSchemaSet(set, {
|
|
174
|
+
const problems = validateSchemaSet(set, {
|
|
175
|
+
componentAliases: files.componentAliases(),
|
|
176
|
+
types: files.formTypes,
|
|
177
|
+
})
|
|
161
178
|
if (problems.length > 0) {
|
|
162
179
|
const detail = problems.map((p) => `${p.file}: ${p.message}`).join('; ')
|
|
163
180
|
logger('schema').error('Not writing {file}: {detail}', {
|
|
@@ -182,16 +199,29 @@ export function createSchemaFileWriter(
|
|
|
182
199
|
|
|
183
200
|
async writeLanguages() {
|
|
184
201
|
if (!managed() || !files.writable) return
|
|
185
|
-
const set = await exportSchemaSet(
|
|
202
|
+
const set = await exportSchemaSet(
|
|
203
|
+
db,
|
|
204
|
+
loadSchemaDirectory(files.schemaDir, { formTypes: files.formTypes }).set.version,
|
|
205
|
+
)
|
|
186
206
|
writeFileSync(join(files.schemaDir, 'languages.toml'), writeLanguages(set.languages))
|
|
187
207
|
await settle()
|
|
188
208
|
},
|
|
189
209
|
|
|
210
|
+
async writeUrlOptions(urls) {
|
|
211
|
+
if (!managed() || !files.writable) return
|
|
212
|
+
const { version } = loadSchemaDirectory(files.schemaDir, { formTypes: files.formTypes }).set
|
|
213
|
+
writeFileSync(join(files.schemaDir, 'schema.toml'), writeSchemaVersion(version, urls))
|
|
214
|
+
await settle()
|
|
215
|
+
},
|
|
216
|
+
|
|
190
217
|
async rewriteAll() {
|
|
191
218
|
if (!managed() || !files.writable) return
|
|
192
|
-
const loaded = loadSchemaDirectory(files.schemaDir)
|
|
219
|
+
const loaded = loadSchemaDirectory(files.schemaDir, { formTypes: files.formTypes })
|
|
193
220
|
const set = await exportSchemaSet(db, loaded.set.version)
|
|
194
|
-
const problems = validateSchemaSet(set, {
|
|
221
|
+
const problems = validateSchemaSet(set, {
|
|
222
|
+
componentAliases: files.componentAliases(),
|
|
223
|
+
types: files.formTypes,
|
|
224
|
+
})
|
|
195
225
|
if (problems.length > 0) {
|
|
196
226
|
const detail = problems.map((p) => `${p.file}: ${p.message}`).join('; ')
|
|
197
227
|
logger('schema').error('Not rewriting the schema files: {detail}', { detail })
|
|
@@ -18,6 +18,7 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
|
18
18
|
import { dirname, join } from 'node:path'
|
|
19
19
|
import type { SchemaProposalPort } from '@crombolo/assistant'
|
|
20
20
|
import type { Db } from '@crombolo/data'
|
|
21
|
+
import type { SyncReport } from '@crombolo/schema'
|
|
21
22
|
import {
|
|
22
23
|
fileNameFor,
|
|
23
24
|
loadSchemaDirectory,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
writeSchemaVersion,
|
|
33
34
|
} from '@crombolo/schema'
|
|
34
35
|
import type { CromboloConfig } from '../config.ts'
|
|
36
|
+
import { formTypesFor } from '../form-types.ts'
|
|
35
37
|
import { logger } from '../logging.ts'
|
|
36
38
|
import { componentAliasesIn, importSchema } from '../schema.ts'
|
|
37
39
|
|
|
@@ -48,6 +50,8 @@ export interface SchemaProposalOptions {
|
|
|
48
50
|
config: CromboloConfig
|
|
49
51
|
/** The node's identity and state, refreshed in place by an import. */
|
|
50
52
|
boot: { nodeId: string; nodeState: { version: string; revision: string } }
|
|
53
|
+
/** After an import applies: what a change to how URLs are built needs. */
|
|
54
|
+
afterSync?(report: SyncReport | undefined): Promise<void>
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
export function createSchemaProposals(options: SchemaProposalOptions): SchemaProposalPort {
|
|
@@ -70,12 +74,12 @@ export function createSchemaProposals(options: SchemaProposalOptions): SchemaPro
|
|
|
70
74
|
? parsed.problems
|
|
71
75
|
: [{ file, path: '', message: 'the file defines no data type' }]
|
|
72
76
|
}
|
|
73
|
-
const set = loadSchemaDirectory(config.schemaDir).set
|
|
77
|
+
const set = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) }).set
|
|
74
78
|
const dataTypes = [
|
|
75
79
|
...set.dataTypes.filter((existing) => existing.alias !== dataType.alias),
|
|
76
80
|
dataType,
|
|
77
81
|
]
|
|
78
|
-
return validateSchemaSet({ ...set, dataTypes })
|
|
82
|
+
return validateSchemaSet({ ...set, dataTypes }, { types: formTypesFor(config) })
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
const parsed = parseContentType(file, toml)
|
|
@@ -85,14 +89,14 @@ export function createSchemaProposals(options: SchemaProposalOptions): SchemaPro
|
|
|
85
89
|
? parsed.problems
|
|
86
90
|
: [{ file, path: '', message: 'the file defines no content type' }]
|
|
87
91
|
}
|
|
88
|
-
const set = loadSchemaDirectory(config.schemaDir).set
|
|
92
|
+
const set = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) }).set
|
|
89
93
|
const contentTypes = [
|
|
90
94
|
...set.contentTypes.filter((existing) => existing.alias !== contentType.alias),
|
|
91
95
|
contentType,
|
|
92
96
|
]
|
|
93
97
|
return validateSchemaSet(
|
|
94
98
|
{ ...set, contentTypes },
|
|
95
|
-
{ componentAliases: componentAliasesIn(config.componentsDir) },
|
|
99
|
+
{ componentAliases: componentAliasesIn(config.componentsDir), types: formTypesFor(config) },
|
|
96
100
|
)
|
|
97
101
|
}
|
|
98
102
|
|
|
@@ -140,9 +144,11 @@ export function createSchemaProposals(options: SchemaProposalOptions): SchemaPro
|
|
|
140
144
|
// import lands at a version the other nodes already have and they never
|
|
141
145
|
// learn they are behind.
|
|
142
146
|
const ahead = await importSchema(options.db, config, options.boot, { dryRun: true })
|
|
143
|
-
const version = loadSchemaDirectory(config.schemaDir
|
|
147
|
+
const { version, urls } = loadSchemaDirectory(config.schemaDir, {
|
|
148
|
+
formTypes: formTypesFor(config),
|
|
149
|
+
}).set
|
|
144
150
|
const next = nextSchemaVersion(version, ahead.classification)
|
|
145
|
-
if (next !== version) await writeFile(versionFile, writeSchemaVersion(next), 'utf8')
|
|
151
|
+
if (next !== version) await writeFile(versionFile, writeSchemaVersion(next, urls), 'utf8')
|
|
146
152
|
|
|
147
153
|
const result = await importSchema(options.db, config, options.boot)
|
|
148
154
|
if (result.report && result.report.action !== 'applied') {
|
|
@@ -154,6 +160,7 @@ export function createSchemaProposals(options: SchemaProposalOptions): SchemaPro
|
|
|
154
160
|
error: result.report.reason ?? `the import was ${result.report.action}`,
|
|
155
161
|
}
|
|
156
162
|
}
|
|
163
|
+
await options.afterSync?.(result.report)
|
|
157
164
|
log.info('{alias} applied from a proposal: {classification}, schema at {version}', {
|
|
158
165
|
alias,
|
|
159
166
|
classification: ahead.classification,
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Webhooks, shaped for the Management API: secrets masked, logs as Umbraco's client reads them. */
|
|
2
|
+
import type { WebhookPort, WebhookPortView } from '@crombolo/api-management'
|
|
3
|
+
import { nameBasedUuid, type WebhookLogEntry } from '@crombolo/data'
|
|
4
|
+
import { envReference } from '../webhook-files.ts'
|
|
5
|
+
import { MASKED, type WebhookService, type WebhookView } from '../webhooks.ts'
|
|
6
|
+
|
|
7
|
+
/** A typed-in secret never leaves the server; an `env:` reference is not a secret. */
|
|
8
|
+
const masked = (headers: Record<string, string>) =>
|
|
9
|
+
Object.fromEntries(
|
|
10
|
+
Object.entries(headers).map(([name, value]) => [name, envReference(value) ? value : MASKED]),
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
function toView(webhook: WebhookView): WebhookPortView {
|
|
14
|
+
return {
|
|
15
|
+
id: webhook.key,
|
|
16
|
+
name: webhook.name,
|
|
17
|
+
description: webhook.description,
|
|
18
|
+
url: webhook.url,
|
|
19
|
+
enabled: webhook.enabled,
|
|
20
|
+
events: webhook.events,
|
|
21
|
+
contentTypeKeys: webhook.contentTypeKeys,
|
|
22
|
+
headers: masked(webhook.headers),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A bundle's handler has no webhook key, so its log rows take a stable one of their own. */
|
|
27
|
+
const subscriberKey = (subscriber: string) =>
|
|
28
|
+
subscriber.startsWith('bundle:') ? nameBasedUuid(subscriber) : subscriber
|
|
29
|
+
|
|
30
|
+
function toLog(entry: WebhookLogEntry) {
|
|
31
|
+
return {
|
|
32
|
+
key: entry.key,
|
|
33
|
+
webhookKey: subscriberKey(entry.subscriber),
|
|
34
|
+
statusCode: entry.exception
|
|
35
|
+
? 'Exception'
|
|
36
|
+
: entry.statusCode === null
|
|
37
|
+
? entry.success
|
|
38
|
+
? 'Handled'
|
|
39
|
+
: 'Failed'
|
|
40
|
+
: String(entry.statusCode),
|
|
41
|
+
httpStatusCode: entry.statusCode,
|
|
42
|
+
isSuccessStatusCode: entry.success,
|
|
43
|
+
date: entry.date.toISOString(),
|
|
44
|
+
eventAlias: entry.eventAlias,
|
|
45
|
+
url: entry.url,
|
|
46
|
+
retryCount: entry.retryCount,
|
|
47
|
+
requestHeaders: entry.requestHeaders,
|
|
48
|
+
requestBody: entry.requestBody,
|
|
49
|
+
responseHeaders: entry.responseHeaders,
|
|
50
|
+
responseBody: entry.responseBody,
|
|
51
|
+
exceptionOccured: entry.exception,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createWebhookPort(service: WebhookService): WebhookPort {
|
|
56
|
+
return {
|
|
57
|
+
list: async () => (await service.list()).map(toView),
|
|
58
|
+
async get(id) {
|
|
59
|
+
const webhook = await service.get(id)
|
|
60
|
+
return webhook ? toView(webhook) : undefined
|
|
61
|
+
},
|
|
62
|
+
async create(input) {
|
|
63
|
+
const result = await service.create({ ...input, key: input.id ?? crypto.randomUUID() })
|
|
64
|
+
return result.ok ? { ok: true, id: result.key } : result
|
|
65
|
+
},
|
|
66
|
+
async update(id, input) {
|
|
67
|
+
const result = await service.update(id, { ...input, key: id })
|
|
68
|
+
return result.ok ? { ok: true, id: result.key } : result
|
|
69
|
+
},
|
|
70
|
+
remove: (id) => service.remove(id),
|
|
71
|
+
async logs(id, paging) {
|
|
72
|
+
const result = await service.logs(id?.toLowerCase(), paging)
|
|
73
|
+
return { total: result.total, items: result.items.map(toLog) }
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -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
|
+
)
|