@crombolo/server 0.10.0 → 0.12.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/audit.ts +186 -0
- 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/search-index.ts +31 -0
- package/src/adapters/webhooks.ts +76 -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 +46 -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 +12 -0
- package/src/jobs.ts +91 -10
- 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 +57 -2
- package/src/schema.ts +58 -11
- package/src/secrets.ts +36 -0
- package/src/server.ts +388 -29
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "The crombolo HTTP server: routing, configuration, media stores and background jobs.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,16 +28,17 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/api-
|
|
32
|
-
"@crombolo/
|
|
33
|
-
"@crombolo/
|
|
34
|
-
"@crombolo/
|
|
35
|
-
"@crombolo/
|
|
36
|
-
"@crombolo/
|
|
37
|
-
"@crombolo/
|
|
38
|
-
"@crombolo/
|
|
39
|
-
"@crombolo/
|
|
40
|
-
"@crombolo/
|
|
31
|
+
"@crombolo/api-delivery": "0.12.1",
|
|
32
|
+
"@crombolo/api-management": "0.12.1",
|
|
33
|
+
"@crombolo/assistant": "0.12.1",
|
|
34
|
+
"@crombolo/auth": "0.12.1",
|
|
35
|
+
"@crombolo/backoffice-host": "0.12.1",
|
|
36
|
+
"@crombolo/contracts": "0.12.1",
|
|
37
|
+
"@crombolo/core": "0.12.1",
|
|
38
|
+
"@crombolo/data": "0.12.1",
|
|
39
|
+
"@crombolo/render": "0.12.1",
|
|
40
|
+
"@crombolo/schema": "0.12.1",
|
|
41
|
+
"@crombolo/transfer": "0.12.1",
|
|
41
42
|
"@logtape/logtape": "^2.3.8"
|
|
42
43
|
}
|
|
43
44
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The audit log's ports: where the router writes, what an item's Info tab reads,
|
|
3
|
+
* and the Settings dashboard's endpoint. docs/audit-log.md.
|
|
4
|
+
*/
|
|
5
|
+
import type { AuditHistoryPort, AuditSink } from '@crombolo/api-management'
|
|
6
|
+
import {
|
|
7
|
+
AUDIT_CATEGORIES,
|
|
8
|
+
type AuditCategory,
|
|
9
|
+
type AuditEntry,
|
|
10
|
+
type AuditEntryInput,
|
|
11
|
+
AuditLog,
|
|
12
|
+
type Db,
|
|
13
|
+
} from '@crombolo/data'
|
|
14
|
+
import { logger } from '../logging.ts'
|
|
15
|
+
|
|
16
|
+
const log = logger('Audit')
|
|
17
|
+
|
|
18
|
+
/** The oldest moment the log still keeps, or undefined when it keeps everything. */
|
|
19
|
+
export function auditRetainedSince(retentionDays: number, now = new Date()): Date | undefined {
|
|
20
|
+
return retentionDays > 0 ? new Date(now.getTime() - retentionDays * 86_400_000) : undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Writes an entry without ever failing what it records: the change has already
|
|
25
|
+
* been made by the time it is written, so a failure here is logged, not raised.
|
|
26
|
+
*/
|
|
27
|
+
export async function recordAudit(
|
|
28
|
+
db: Db,
|
|
29
|
+
entries: AuditEntryInput | readonly AuditEntryInput[],
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
try {
|
|
32
|
+
await new AuditLog(db).record(entries)
|
|
33
|
+
} catch (error) {
|
|
34
|
+
log.error('Could not write to the audit log: {error}', { error })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createAuditSink(db: Db): AuditSink {
|
|
39
|
+
const audit = new AuditLog(db)
|
|
40
|
+
return {
|
|
41
|
+
async describe(entityType, key) {
|
|
42
|
+
try {
|
|
43
|
+
return await audit.describe(entityType, key)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
log.warning('Could not name {entityType} {key} for the audit log: {error}', {
|
|
46
|
+
entityType,
|
|
47
|
+
key,
|
|
48
|
+
error,
|
|
49
|
+
})
|
|
50
|
+
return { entityType, entityKey: key, entityName: null }
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
record: (entries) =>
|
|
54
|
+
recordAudit(
|
|
55
|
+
db,
|
|
56
|
+
entries.map((entry) => ({ ...entry, source: 'request' as const })),
|
|
57
|
+
),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createAuditHistory(db: Db, retentionDays: number): AuditHistoryPort {
|
|
62
|
+
const audit = new AuditLog(db)
|
|
63
|
+
let started: Date | undefined
|
|
64
|
+
return {
|
|
65
|
+
async forItem(key) {
|
|
66
|
+
const { items } = await audit.query({ entityKey: key }, { skip: 0, take: 10_000 })
|
|
67
|
+
return items
|
|
68
|
+
},
|
|
69
|
+
async startedAt() {
|
|
70
|
+
started ??= await audit.startedAt()
|
|
71
|
+
return started
|
|
72
|
+
},
|
|
73
|
+
retainedSince: () => auditRetainedSince(retentionDays),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function toResponse(entry: AuditEntry) {
|
|
78
|
+
return {
|
|
79
|
+
id: entry.id,
|
|
80
|
+
occurredAt: entry.occurredAt.toISOString(),
|
|
81
|
+
category: entry.category,
|
|
82
|
+
action: entry.action,
|
|
83
|
+
source: entry.source,
|
|
84
|
+
user: entry.userKey || entry.userName ? { key: entry.userKey, name: entry.userName } : null,
|
|
85
|
+
item: entry.entityType
|
|
86
|
+
? { type: entry.entityType, key: entry.entityKey, name: entry.entityName }
|
|
87
|
+
: null,
|
|
88
|
+
ipAddress: entry.ipAddress,
|
|
89
|
+
forwardedFor: entry.forwardedFor,
|
|
90
|
+
detail: entry.detail,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function dateParam(value: string | null): Date | undefined | null {
|
|
95
|
+
if (!value) return undefined
|
|
96
|
+
const parsed = new Date(value)
|
|
97
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const badRequest = (detail: string) =>
|
|
101
|
+
Response.json({ title: 'Invalid request', status: 400, detail }, { status: 400 })
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The Settings dashboard's reads: `GET /` lists entries, newest first, filtered
|
|
105
|
+
* by user, item, category, action, date and text; `GET /users` names everyone in
|
|
106
|
+
* the log. Nothing here writes, so an entry cannot be changed through it.
|
|
107
|
+
*/
|
|
108
|
+
export function createAuditHost(db: Db, retentionDays: number) {
|
|
109
|
+
const audit = new AuditLog(db)
|
|
110
|
+
return {
|
|
111
|
+
async handle(request: Request, pathname: string): Promise<Response | undefined> {
|
|
112
|
+
if (pathname !== '' && pathname !== '/' && pathname !== '/users') return undefined
|
|
113
|
+
if (request.method !== 'GET')
|
|
114
|
+
return new Response('Method Not Allowed', { status: 405, headers: { allow: 'GET' } })
|
|
115
|
+
if (pathname === '/users') return Response.json({ items: await audit.users() })
|
|
116
|
+
|
|
117
|
+
const search = new URL(request.url).searchParams
|
|
118
|
+
const skip = Number(search.get('skip') ?? 0)
|
|
119
|
+
const take = Number(search.get('take') ?? 50)
|
|
120
|
+
if (!Number.isInteger(skip) || skip < 0 || !Number.isInteger(take) || take < 1 || take > 500)
|
|
121
|
+
return badRequest('skip must be 0 or more, and take between 1 and 500.')
|
|
122
|
+
const category = search.get('category') ?? undefined
|
|
123
|
+
if (category && !AUDIT_CATEGORIES.includes(category as AuditCategory))
|
|
124
|
+
return badRequest(`There is no category "${category}".`)
|
|
125
|
+
const since = dateParam(search.get('since'))
|
|
126
|
+
const until = dateParam(search.get('until'))
|
|
127
|
+
if (since === null || until === null) return badRequest('since and until must be dates.')
|
|
128
|
+
const result = await audit.query(
|
|
129
|
+
{
|
|
130
|
+
userKey: search.get('user') ?? undefined,
|
|
131
|
+
entityKey: search.get('item') ?? undefined,
|
|
132
|
+
category: category as AuditCategory | undefined,
|
|
133
|
+
action: search.get('action') ?? undefined,
|
|
134
|
+
since,
|
|
135
|
+
until,
|
|
136
|
+
text: search.get('text') ?? undefined,
|
|
137
|
+
},
|
|
138
|
+
{ skip, take },
|
|
139
|
+
)
|
|
140
|
+
return Response.json({
|
|
141
|
+
total: result.total,
|
|
142
|
+
retentionDays,
|
|
143
|
+
items: result.items.map(toResponse),
|
|
144
|
+
})
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const VERB: Record<string, string> = {
|
|
150
|
+
POST: 'Create',
|
|
151
|
+
PUT: 'Save',
|
|
152
|
+
PATCH: 'Save',
|
|
153
|
+
DELETE: 'Delete',
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Crombolo's own endpoints that change something, by their first segment. */
|
|
157
|
+
const PLUGIN_AREAS: Record<string, { category: AuditCategory; entityType: string }> = {
|
|
158
|
+
'schema-import': { category: 'schema', entityType: 'Schema' },
|
|
159
|
+
git: { category: 'schema', entityType: 'Schema' },
|
|
160
|
+
seo: { category: 'settings', entityType: 'SeoSettings' },
|
|
161
|
+
bundles: { category: 'system', entityType: 'Bundle' },
|
|
162
|
+
forms: { category: 'content', entityType: 'Form' },
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const PLUGIN_ACTIONS: Record<string, string> = {
|
|
166
|
+
'schema-import': 'Import',
|
|
167
|
+
'git/commit': 'Commit',
|
|
168
|
+
'bundles/install': 'Install',
|
|
169
|
+
'bundles/uninstall': 'Uninstall',
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The entry for a write to `<plugin>/api/<subpath>`: the area it is in, and what it did there. */
|
|
173
|
+
export function pluginAuditPlan(
|
|
174
|
+
method: string,
|
|
175
|
+
subpath: string,
|
|
176
|
+
): Pick<AuditEntryInput, 'category' | 'action' | 'entityType' | 'entityKey' | 'detail'> {
|
|
177
|
+
const [area = '', ...rest] = subpath.split('/')
|
|
178
|
+
const known = PLUGIN_AREAS[area]
|
|
179
|
+
return {
|
|
180
|
+
category: known?.category ?? 'settings',
|
|
181
|
+
entityType: known?.entityType ?? area,
|
|
182
|
+
action: PLUGIN_ACTIONS[subpath] ?? VERB[method] ?? method,
|
|
183
|
+
entityKey: rest.length > 0 ? rest.join('/') : null,
|
|
184
|
+
detail: `${method} ${subpath}`,
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/adapters/content.ts
CHANGED
|
@@ -589,6 +589,8 @@ export function createDataTypePort(db: Db, schemaFiles?: SchemaFiles): DataTypeP
|
|
|
589
589
|
filter: (criteria, paging) => repo.filter(criteria, paging),
|
|
590
590
|
search: (query, paging) => repo.filter({ name: query }, paging),
|
|
591
591
|
referencedBy: (key) => repo.referencedBy(key),
|
|
592
|
+
isUsed: (key) => repo.isUsed(key),
|
|
593
|
+
propertyIsUsed: (contentTypeKey, alias) => repo.isPropertyUsed(contentTypeKey, alias),
|
|
592
594
|
async move(key, parentKey) {
|
|
593
595
|
const moved = await repo.move(key, parentKey)
|
|
594
596
|
if (moved) await rewrite()
|
|
@@ -22,12 +22,14 @@ import {
|
|
|
22
22
|
DocumentRepository,
|
|
23
23
|
type DocumentRepositoryOptions,
|
|
24
24
|
DomainRepository,
|
|
25
|
+
InvalidParentError,
|
|
25
26
|
LanguageRepository,
|
|
26
27
|
NOTIFIABLE_ACTIONS,
|
|
27
28
|
type NodeRow,
|
|
28
29
|
NotificationRepository,
|
|
29
30
|
PublishBlockedError,
|
|
30
31
|
RedirectRepository,
|
|
32
|
+
readUrlOptions,
|
|
31
33
|
WriteRejectedError,
|
|
32
34
|
} from '@crombolo/data'
|
|
33
35
|
import type { PublishedCache, PublishedContentSource, PublishedNode } from '@crombolo/render'
|
|
@@ -91,6 +93,8 @@ export function createDocumentPort(
|
|
|
91
93
|
const failure = (error: unknown) => {
|
|
92
94
|
if (error instanceof WriteRejectedError)
|
|
93
95
|
return { ok: false as const, reason: error.message, status: 409 }
|
|
96
|
+
if (error instanceof InvalidParentError)
|
|
97
|
+
return { ok: false as const, reason: error.message, status: 400 }
|
|
94
98
|
if (error instanceof PublishBlockedError) return { ok: false as const, reason: error.message }
|
|
95
99
|
if (error instanceof TemporaryFileMissingError)
|
|
96
100
|
return { ok: false as const, reason: error.message, status: 400 }
|
|
@@ -109,6 +113,7 @@ export function createDocumentPort(
|
|
|
109
113
|
|
|
110
114
|
return {
|
|
111
115
|
byKey: (key) => repo.byKey(key),
|
|
116
|
+
propertyAliases: (key) => repo.propertyAliases(key),
|
|
112
117
|
byKeyPublished: (key) => repo.byKeyPublished(key),
|
|
113
118
|
validate: (input, cultures) => repo.validate(input, cultures),
|
|
114
119
|
|
|
@@ -159,7 +164,9 @@ export function createDocumentPort(
|
|
|
159
164
|
),
|
|
160
165
|
),
|
|
161
166
|
async documentItems(keys: readonly string[]) {
|
|
162
|
-
|
|
167
|
+
// One read for all of them, answered in the order asked.
|
|
168
|
+
const found = new Map((await repo.nodes.byKeys(keys)).map((row) => [row.key, row]))
|
|
169
|
+
const rows = keys.map((key) => found.get(normaliseUuid(key)))
|
|
163
170
|
return repo.treeItems(
|
|
164
171
|
rows.filter((row): row is NodeRow => row?.objectType === ObjectTypes.Document),
|
|
165
172
|
)
|
|
@@ -496,30 +503,24 @@ export function createPublishedContentSource(
|
|
|
496
503
|
},
|
|
497
504
|
async loadMedia() {
|
|
498
505
|
const media = new DocumentRepository(db, { ...options, kind: 'media' })
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
value: toPublishedValue(value.editorAlias, value.value),
|
|
518
|
-
config: value.config,
|
|
519
|
-
})),
|
|
520
|
-
})
|
|
521
|
-
}
|
|
522
|
-
return nodes
|
|
506
|
+
return (await media.loadCurrent()).map((item) => ({
|
|
507
|
+
key: item.key,
|
|
508
|
+
id: item.id,
|
|
509
|
+
name: item.name,
|
|
510
|
+
mediaTypeAlias: item.contentTypeAlias,
|
|
511
|
+
parentId: item.parentId,
|
|
512
|
+
sortOrder: item.sortOrder,
|
|
513
|
+
createDate: item.createDate,
|
|
514
|
+
updateDate: item.updateDate,
|
|
515
|
+
properties: item.values.map((value) => ({
|
|
516
|
+
alias: value.alias,
|
|
517
|
+
editorAlias: value.editorAlias ?? '',
|
|
518
|
+
culture: value.culture,
|
|
519
|
+
segment: value.segment,
|
|
520
|
+
value: toPublishedValue(value.editorAlias, value.value),
|
|
521
|
+
config: value.config,
|
|
522
|
+
})),
|
|
523
|
+
}))
|
|
523
524
|
},
|
|
524
525
|
async loadLanguages() {
|
|
525
526
|
return (await new LanguageRepository(db).all()).map((l) => ({
|
|
@@ -529,6 +530,7 @@ export function createPublishedContentSource(
|
|
|
529
530
|
}))
|
|
530
531
|
},
|
|
531
532
|
loadDictionary: () => new DictionaryRepository(db).translations(),
|
|
533
|
+
loadDictionaryOutline: () => new DictionaryRepository(db).outline(),
|
|
532
534
|
async loadDomains() {
|
|
533
535
|
return (await new DomainRepository(db).all()).map((row) => ({
|
|
534
536
|
nodeId: row.nodeId,
|
|
@@ -572,6 +574,7 @@ export function createPublishedContentSource(
|
|
|
572
574
|
return out
|
|
573
575
|
},
|
|
574
576
|
loadContentTypeAliases: () => new ContentTypeRepository(db).aliasesByKey(),
|
|
577
|
+
loadUrlOptions: () => readUrlOptions(db),
|
|
575
578
|
|
|
576
579
|
async loadRedirects() {
|
|
577
580
|
return (await new RedirectRepository(db).all()).map((row) => ({
|
package/src/adapters/elements.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type Db,
|
|
19
19
|
DocumentRepository,
|
|
20
20
|
FolderRepository,
|
|
21
|
+
InvalidParentError,
|
|
21
22
|
NodeRepository,
|
|
22
23
|
type NodeRow,
|
|
23
24
|
type NodeSchemaState,
|
|
@@ -66,6 +67,8 @@ export function createElementPort(
|
|
|
66
67
|
const failure = (error: unknown) => {
|
|
67
68
|
if (error instanceof WriteRejectedError)
|
|
68
69
|
return { ok: false as const, reason: error.message, status: 409 }
|
|
70
|
+
if (error instanceof InvalidParentError)
|
|
71
|
+
return { ok: false as const, reason: error.message, status: 400 }
|
|
69
72
|
if (error instanceof PublishBlockedError) return { ok: false as const, reason: error.message }
|
|
70
73
|
if (error instanceof TemporaryFileMissingError)
|
|
71
74
|
return { ok: false as const, reason: error.message, status: 400 }
|