@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
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,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.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",
|
|
41
42
|
"@logtape/logtape": "^2.3.8"
|
|
42
43
|
}
|
|
43
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,
|
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 }
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The diagnostics port: health checks for what actually goes wrong on a site,
|
|
3
|
+
* the troubleshooting report and the upgrade check. docs/diagnostics.md.
|
|
4
|
+
*/
|
|
5
|
+
import type { DiagnosticsPort, HealthCheckGroup, HealthCheckResult } from '@crombolo/api-management'
|
|
6
|
+
import {
|
|
7
|
+
compareStates,
|
|
8
|
+
cromboloPlan,
|
|
9
|
+
currentSchemaState,
|
|
10
|
+
type Db,
|
|
11
|
+
liveNodes,
|
|
12
|
+
type NodeSchemaState,
|
|
13
|
+
pendingMigrations,
|
|
14
|
+
} from '@crombolo/data'
|
|
15
|
+
import { BundleConfigurationError, bundleChainsFor, pendingBundleSteps } from '../bundle-tables.ts'
|
|
16
|
+
import { type CromboloConfig, describeDatabase, VERSION } from '../config.ts'
|
|
17
|
+
import { type EmailPort, emailAvailability } from '../email.ts'
|
|
18
|
+
import type { MediaStore } from '../media-store.ts'
|
|
19
|
+
import { backofficeVersion } from '../status.ts'
|
|
20
|
+
|
|
21
|
+
export const UPGRADE_PACKAGE = '@crombolo/crombolo'
|
|
22
|
+
|
|
23
|
+
/** How recently a node must have polled to count as part of the cluster. */
|
|
24
|
+
const LIVE_WITHIN_MS = 60_000
|
|
25
|
+
|
|
26
|
+
const CHECKS = {
|
|
27
|
+
database: { id: '6f1d4a8e-0047-4c1a-9e7d-000000000001', name: 'Database connection' },
|
|
28
|
+
migrations: { id: '6f1d4a8e-0047-4c1a-9e7d-000000000002', name: 'Database upgrades' },
|
|
29
|
+
media: { id: '6f1d4a8e-0047-4c1a-9e7d-000000000003', name: 'Media storage' },
|
|
30
|
+
email: { id: '6f1d4a8e-0047-4c1a-9e7d-000000000004', name: 'E-mail delivery' },
|
|
31
|
+
nodes: { id: '6f1d4a8e-0047-4c1a-9e7d-000000000005', name: 'Servers' },
|
|
32
|
+
} as const
|
|
33
|
+
|
|
34
|
+
const GROUPS: HealthCheckGroup[] = [
|
|
35
|
+
{
|
|
36
|
+
name: 'Database',
|
|
37
|
+
checks: [
|
|
38
|
+
{ ...CHECKS.database, description: 'Whether the database answers, and which one it is.' },
|
|
39
|
+
{
|
|
40
|
+
...CHECKS.migrations,
|
|
41
|
+
description: 'Whether the database is up to date with this release and its schema files.',
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'Media',
|
|
47
|
+
checks: [{ ...CHECKS.media, description: 'Whether uploaded files can be written and read.' }],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'E-mail',
|
|
51
|
+
checks: [{ ...CHECKS.email, description: 'Whether this site can send e-mail.' }],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'Servers',
|
|
55
|
+
checks: [
|
|
56
|
+
{
|
|
57
|
+
...CHECKS.nodes,
|
|
58
|
+
description: 'The servers running this site, and whether they run the same release.',
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
export interface HealthCheckOptions {
|
|
65
|
+
config: CromboloConfig
|
|
66
|
+
db: Db
|
|
67
|
+
media: MediaStore
|
|
68
|
+
email?: EmailPort
|
|
69
|
+
/** What this node deploys at; read from the database's own state when omitted. */
|
|
70
|
+
nodeState?: NodeSchemaState
|
|
71
|
+
fetch?: typeof fetch
|
|
72
|
+
now?: () => Date
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const result = (
|
|
76
|
+
resultType: HealthCheckResult['resultType'],
|
|
77
|
+
message: string,
|
|
78
|
+
): HealthCheckResult => ({ resultType, message })
|
|
79
|
+
|
|
80
|
+
const failure = (error: unknown) => (error instanceof Error ? error.message : String(error))
|
|
81
|
+
|
|
82
|
+
/** `0.13.0` against `0.12.0`: which part moved, or None when the first is not newer. */
|
|
83
|
+
export function upgradeType(latest: string, current: string): 'None' | 'Patch' | 'Minor' | 'Major' {
|
|
84
|
+
const parts = (value: string) =>
|
|
85
|
+
(value.split('-')[0] ?? '').split('.').map((part) => Number(part) || 0)
|
|
86
|
+
const [a, b] = [parts(latest), parts(current)]
|
|
87
|
+
for (const [index, type] of (['Major', 'Minor', 'Patch'] as const).entries()) {
|
|
88
|
+
const difference = (a[index] ?? 0) - (b[index] ?? 0)
|
|
89
|
+
if (difference > 0) return type
|
|
90
|
+
if (difference < 0) return 'None'
|
|
91
|
+
}
|
|
92
|
+
return 'None'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function ago(then: Date, now: Date): string {
|
|
96
|
+
const seconds = Math.max(0, Math.round((now.getTime() - then.getTime()) / 1000))
|
|
97
|
+
return seconds < 2 ? 'just now' : `${seconds} seconds ago`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createDiagnosticsPort(options: HealthCheckOptions): DiagnosticsPort {
|
|
101
|
+
const { config, db, media } = options
|
|
102
|
+
const now = options.now ?? (() => new Date())
|
|
103
|
+
const call = options.fetch ?? fetch
|
|
104
|
+
let checked:
|
|
105
|
+
| { at: number; answer: Awaited<ReturnType<DiagnosticsPort['upgradeCheck']>> }
|
|
106
|
+
| undefined
|
|
107
|
+
|
|
108
|
+
async function database(): Promise<HealthCheckResult[]> {
|
|
109
|
+
const dialect = db.dialect.name === 'postgres' ? 'PostgreSQL' : 'SQLite'
|
|
110
|
+
try {
|
|
111
|
+
await db.query('SELECT 1 AS ok')
|
|
112
|
+
return [result('Success', `${dialect} is answering (${describeDatabase(config)}).`)]
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return [result('Error', `${dialect} is not answering: ${failure(error)}`)]
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function migrations(): Promise<HealthCheckResult[]> {
|
|
119
|
+
const results: HealthCheckResult[] = []
|
|
120
|
+
try {
|
|
121
|
+
const pending = await pendingMigrations(db, cromboloPlan)
|
|
122
|
+
results.push(
|
|
123
|
+
pending.length === 0
|
|
124
|
+
? result('Success', `The database is up to date with Crombolo ${VERSION}.`)
|
|
125
|
+
: result(
|
|
126
|
+
'Error',
|
|
127
|
+
`${pending.length} database upgrade(s) for Crombolo ${VERSION} have not run yet: ${pending.map((m) => m.name).join(', ')}. Run \`crombolo upgrade\`.`,
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
for (const bundle of await pendingBundleSteps(db, bundleChainsFor(config)))
|
|
131
|
+
if (bundle.pending.length > 0)
|
|
132
|
+
results.push(
|
|
133
|
+
result(
|
|
134
|
+
'Error',
|
|
135
|
+
`The bundle "${bundle.name}" has ${bundle.pending.length} upgrade step(s) that have not run yet. Run \`crombolo upgrade\`.`,
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
if (options.nodeState) {
|
|
139
|
+
const state = await currentSchemaState(db)
|
|
140
|
+
const difference = compareStates(options.nodeState, state)
|
|
141
|
+
if (difference < 0)
|
|
142
|
+
results.push(
|
|
143
|
+
result(
|
|
144
|
+
'Warning',
|
|
145
|
+
`The database's schema is ${state.version} (revision ${state.revision}), newer than this server's ${options.nodeState.version} (revision ${options.nodeState.revision}): this server reads but refuses changes until it is replaced.`,
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
else if (difference > 0)
|
|
149
|
+
results.push(
|
|
150
|
+
result(
|
|
151
|
+
'Warning',
|
|
152
|
+
`This server's schema files are ${options.nodeState.version} (revision ${options.nodeState.revision}), ahead of the database's ${state.version} (revision ${state.revision}).`,
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
results.push(
|
|
158
|
+
result(
|
|
159
|
+
'Error',
|
|
160
|
+
error instanceof BundleConfigurationError
|
|
161
|
+
? error.problems.join(' ')
|
|
162
|
+
: `The upgrade state could not be read: ${failure(error)}`,
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
return results
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function mediaStorage(): Promise<HealthCheckResult[]> {
|
|
170
|
+
const key = `health-check/${crypto.randomUUID()}.txt`
|
|
171
|
+
const written = new TextEncoder().encode('ok')
|
|
172
|
+
try {
|
|
173
|
+
await media.put(key, written, { contentType: 'text/plain' })
|
|
174
|
+
const read = await media.get(key)
|
|
175
|
+
const bytes = read ? await read.bytes() : undefined
|
|
176
|
+
await media.delete(key)
|
|
177
|
+
if (!bytes || new TextDecoder().decode(bytes) !== 'ok')
|
|
178
|
+
return [result('Error', `A file written to ${media.description} could not be read back.`)]
|
|
179
|
+
return [result('Success', `Files can be written to and read from ${media.description}.`)]
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return [result('Error', `${media.description} could not be used: ${failure(error)}`)]
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function email(): HealthCheckResult[] {
|
|
186
|
+
const availability = emailAvailability(options.email)
|
|
187
|
+
if (availability.available)
|
|
188
|
+
return [result('Success', `E-mail is sent through ${availability.description}.`)]
|
|
189
|
+
const affects =
|
|
190
|
+
availability.affects.length > 0
|
|
191
|
+
? ` Unavailable until it is: ${availability.affects.join(', ')}.`
|
|
192
|
+
: ''
|
|
193
|
+
return [
|
|
194
|
+
result(
|
|
195
|
+
availability.provider === 'log' ? 'Info' : 'Warning',
|
|
196
|
+
`${availability.reason}${affects}`,
|
|
197
|
+
),
|
|
198
|
+
]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function nodes(): Promise<HealthCheckResult[]> {
|
|
202
|
+
const live = await liveNodes(db, { seenWithinMs: LIVE_WITHIN_MS })
|
|
203
|
+
if (live.length === 0) return [result('Warning', 'No server has reported in the last minute.')]
|
|
204
|
+
const at = now()
|
|
205
|
+
const results = live.map((node) =>
|
|
206
|
+
result(
|
|
207
|
+
'Info',
|
|
208
|
+
`${node.nodeId}${node.role ? ` (${node.role})` : ''} runs Crombolo ${node.cromboloVersion ?? 'of an unknown release'} with schema ${node.version ?? '?'} revision ${node.revision ?? '?'}, last seen ${ago(node.lastSeen, at)}.`,
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
const releases = new Set(live.map((node) => node.cromboloVersion ?? 'unknown'))
|
|
212
|
+
const schemas = new Set(live.map((node) => `${node.version}/${node.revision}`))
|
|
213
|
+
if (releases.size > 1)
|
|
214
|
+
results.unshift(
|
|
215
|
+
result(
|
|
216
|
+
'Warning',
|
|
217
|
+
`The servers run different releases (${[...releases].join(', ')}). That is expected only while an upgrade rolls out.`,
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
else if (schemas.size > 1)
|
|
221
|
+
results.unshift(
|
|
222
|
+
result(
|
|
223
|
+
'Warning',
|
|
224
|
+
'The servers deploy different schema files. That is expected only while a deploy rolls out.',
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
else
|
|
228
|
+
results.unshift(
|
|
229
|
+
result('Success', `${live.length} server(s) running Crombolo ${[...releases][0]}.`),
|
|
230
|
+
)
|
|
231
|
+
return results
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const runners: Record<string, () => HealthCheckResult[] | Promise<HealthCheckResult[]>> = {
|
|
235
|
+
[CHECKS.database.id]: database,
|
|
236
|
+
[CHECKS.migrations.id]: migrations,
|
|
237
|
+
[CHECKS.media.id]: mediaStorage,
|
|
238
|
+
[CHECKS.email.id]: email,
|
|
239
|
+
[CHECKS.nodes.id]: nodes,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
groups: () => GROUPS,
|
|
244
|
+
|
|
245
|
+
async run(name) {
|
|
246
|
+
const group = GROUPS.find((candidate) => candidate.name === name)
|
|
247
|
+
if (!group) return undefined
|
|
248
|
+
return Promise.all(
|
|
249
|
+
group.checks.map(async (check) => {
|
|
250
|
+
try {
|
|
251
|
+
return {
|
|
252
|
+
id: check.id,
|
|
253
|
+
results: await (runners[check.id] as () => Promise<HealthCheckResult[]>)(),
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
256
|
+
return {
|
|
257
|
+
id: check.id,
|
|
258
|
+
results: [result('Error', `The check failed: ${failure(error)}`)],
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}),
|
|
262
|
+
)
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
async troubleshooting() {
|
|
266
|
+
const availability = emailAvailability(options.email)
|
|
267
|
+
const state = options.nodeState
|
|
268
|
+
const items: Array<[string, string | undefined]> = [
|
|
269
|
+
['Crombolo version', VERSION],
|
|
270
|
+
['Backoffice version', backofficeVersion()],
|
|
271
|
+
['Runtime', `Bun ${Bun.version}`],
|
|
272
|
+
['Operating system', `${process.platform} ${process.arch}`],
|
|
273
|
+
['Mode', config.development ? 'Development' : 'Production'],
|
|
274
|
+
['Server', `${config.nodeId} (${config.role})`],
|
|
275
|
+
['Database', db.dialect.name === 'postgres' ? 'PostgreSQL' : 'SQLite'],
|
|
276
|
+
['Database location', describeDatabase(config)],
|
|
277
|
+
['Schema', state ? `${state.version} revision ${state.revision}` : undefined],
|
|
278
|
+
['Upgrade policy', config.upgradePolicy],
|
|
279
|
+
['Media storage', media.description],
|
|
280
|
+
[
|
|
281
|
+
'E-mail',
|
|
282
|
+
availability.available ? (availability.description ?? undefined) : 'Not configured',
|
|
283
|
+
],
|
|
284
|
+
['Log level', config.logLevel],
|
|
285
|
+
['Time zone', Intl.DateTimeFormat().resolvedOptions().timeZone],
|
|
286
|
+
['Server time', now().toISOString()],
|
|
287
|
+
]
|
|
288
|
+
return {
|
|
289
|
+
items: items
|
|
290
|
+
.filter((item): item is [string, string] => item[1] !== undefined)
|
|
291
|
+
.map(([name, data]) => ({ name, data })),
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
async upgradeCheck() {
|
|
296
|
+
if (config.versionCheckPeriod <= 0)
|
|
297
|
+
return { type: 'None', comment: 'Checking for new releases is turned off.', url: '' }
|
|
298
|
+
const periodMs = config.versionCheckPeriod * 86_400_000
|
|
299
|
+
if (checked && now().getTime() - checked.at < periodMs) return checked.answer
|
|
300
|
+
let answer: Awaited<ReturnType<DiagnosticsPort['upgradeCheck']>>
|
|
301
|
+
try {
|
|
302
|
+
const response = await call(
|
|
303
|
+
`${config.npmRegistry.replace(/\/$/, '')}/${encodeURIComponent(UPGRADE_PACKAGE)}`,
|
|
304
|
+
{ headers: { accept: 'application/json' } },
|
|
305
|
+
)
|
|
306
|
+
if (!response.ok) throw new Error(`the registry answered ${response.status}`)
|
|
307
|
+
const latest = ((await response.json()) as { 'dist-tags'?: { latest?: string } })[
|
|
308
|
+
'dist-tags'
|
|
309
|
+
]?.latest
|
|
310
|
+
if (!latest) throw new Error('the registry named no latest release')
|
|
311
|
+
const type = upgradeType(latest, VERSION)
|
|
312
|
+
answer =
|
|
313
|
+
type === 'None'
|
|
314
|
+
? { type, comment: `Crombolo ${VERSION} is the latest release.`, url: '' }
|
|
315
|
+
: {
|
|
316
|
+
type,
|
|
317
|
+
comment: `Crombolo ${latest} is available; this site runs ${VERSION}.`,
|
|
318
|
+
url: `https://www.npmjs.com/package/${UPGRADE_PACKAGE}/v/${latest}`,
|
|
319
|
+
}
|
|
320
|
+
} catch (error) {
|
|
321
|
+
// Not cached: an air-gapped site asks again next time rather than
|
|
322
|
+
// being told nothing for a week.
|
|
323
|
+
return {
|
|
324
|
+
type: 'None',
|
|
325
|
+
comment: `The package registry could not be reached to check for a newer release (${failure(error)}).`,
|
|
326
|
+
url: '',
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
checked = { at: now().getTime(), answer }
|
|
330
|
+
return answer
|
|
331
|
+
},
|
|
332
|
+
}
|
|
333
|
+
}
|
package/src/adapters/media.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
DataTypeRepository,
|
|
20
20
|
type Db,
|
|
21
21
|
DocumentRepository,
|
|
22
|
+
InvalidParentError,
|
|
22
23
|
type NodeRow,
|
|
23
24
|
type NodeSchemaState,
|
|
24
25
|
type ValueIntake,
|
|
@@ -65,6 +66,8 @@ export function createMediaPort(
|
|
|
65
66
|
const failure = (error: unknown) => {
|
|
66
67
|
if (error instanceof WriteRejectedError)
|
|
67
68
|
return { ok: false as const, reason: error.message, status: 409 }
|
|
69
|
+
if (error instanceof InvalidParentError)
|
|
70
|
+
return { ok: false as const, reason: error.message, status: 400 }
|
|
68
71
|
if (error instanceof TemporaryFileMissingError)
|
|
69
72
|
return { ok: false as const, reason: error.message, status: 400 }
|
|
70
73
|
throw error
|
package/src/adapters/members.ts
CHANGED
|
@@ -45,6 +45,11 @@ export interface MemberPortOptions extends DocumentRepositoryOptions {
|
|
|
45
45
|
valueIntake?: ValueIntake
|
|
46
46
|
/** How many failed sign-ins lock an account out; Umbraco's default is 5. */
|
|
47
47
|
maxFailedPasswordAttempts?: number
|
|
48
|
+
/**
|
|
49
|
+
* The members the Delivery API's client credentials sign in as: Umbraco shows
|
|
50
|
+
* them as kind `Api`, derived from config rather than stored.
|
|
51
|
+
*/
|
|
52
|
+
apiMemberUserNames?: readonly string[]
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
const notFound = () => ({
|
|
@@ -54,6 +59,9 @@ const notFound = () => ({
|
|
|
54
59
|
})
|
|
55
60
|
|
|
56
61
|
export function createMemberPort(db: Db, options: MemberPortOptions = {}): MemberPort {
|
|
62
|
+
const apiMembers = new Set((options.apiMemberUserNames ?? []).map((n) => n.toLowerCase()))
|
|
63
|
+
const kindOf = (userName: string): 'Api' | 'Default' =>
|
|
64
|
+
apiMembers.has(userName.toLowerCase()) ? 'Api' : 'Default'
|
|
57
65
|
const members = new MemberRepository(db, options)
|
|
58
66
|
const memberTypes = new ContentTypeRepository(db, { kind: 'member' })
|
|
59
67
|
|
|
@@ -110,7 +118,7 @@ export function createMemberPort(db: Db, options: MemberPortOptions = {}): Membe
|
|
|
110
118
|
lastLockoutDate: member.lastLockoutDate?.toISOString() ?? null,
|
|
111
119
|
lastPasswordChangeDate: member.lastPasswordChangeDate?.toISOString() ?? null,
|
|
112
120
|
groups: await members.groupKeys(member.key),
|
|
113
|
-
kind:
|
|
121
|
+
kind: kindOf(member.username),
|
|
114
122
|
profileData: null,
|
|
115
123
|
flags: [],
|
|
116
124
|
values: content.values
|
|
@@ -136,7 +144,7 @@ export function createMemberPort(db: Db, options: MemberPortOptions = {}): Membe
|
|
|
136
144
|
id: member.key,
|
|
137
145
|
memberType: { id: member.contentTypeKey, icon: member.contentTypeIcon ?? 'icon-user' },
|
|
138
146
|
variants: [{ name: member.name, culture: null }],
|
|
139
|
-
kind:
|
|
147
|
+
kind: kindOf(member.username),
|
|
140
148
|
flags: [],
|
|
141
149
|
})
|
|
142
150
|
|
|
@@ -50,7 +50,20 @@ function toReference(node: ReferencingNode): unknown {
|
|
|
50
50
|
|
|
51
51
|
export function createReferencePort(db: Db): ReferencePort {
|
|
52
52
|
const references = new ReferenceRepository(db)
|
|
53
|
+
const binTypes = {
|
|
54
|
+
Document: ObjectTypes.Document,
|
|
55
|
+
Media: ObjectTypes.Media,
|
|
56
|
+
Element: ObjectTypes.Element,
|
|
57
|
+
} as const
|
|
53
58
|
return {
|
|
59
|
+
async recycleBinReferencedBy(area, paging) {
|
|
60
|
+
const result = await references.referencingRecycleBin(
|
|
61
|
+
binTypes[area],
|
|
62
|
+
paging.skip,
|
|
63
|
+
paging.take,
|
|
64
|
+
)
|
|
65
|
+
return { total: result.total, items: result.items.map(toReference) }
|
|
66
|
+
},
|
|
54
67
|
async referencedBy(key, paging) {
|
|
55
68
|
const result = await references.referencedBy(key, paging.skip, paging.take)
|
|
56
69
|
return { total: result.total, items: result.items.map(toReference) }
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Relation types and their derived relations, shaped for the wire. */
|
|
2
|
+
import type { RelationPort, RelationTypeView } from '@crombolo/api-management'
|
|
3
|
+
import { ObjectTypes } from '@crombolo/core'
|
|
4
|
+
import { type Db, RELATION_TYPES, RelationRepository, relationTypeByKey } from '@crombolo/data'
|
|
5
|
+
|
|
6
|
+
/** Umbraco's friendly names for the object types a relation type joins. */
|
|
7
|
+
const OBJECT_TYPE_NAMES: Record<string, string> = {
|
|
8
|
+
[ObjectTypes.Document]: 'Document',
|
|
9
|
+
[ObjectTypes.Media]: 'Media',
|
|
10
|
+
[ObjectTypes.Member]: 'Member',
|
|
11
|
+
[ObjectTypes.Element]: 'Element',
|
|
12
|
+
[ObjectTypes.ElementContainer]: 'Element Container',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const objectType = (id: string | null) => (id ? { id, name: OBJECT_TYPE_NAMES[id] ?? id } : null)
|
|
16
|
+
|
|
17
|
+
const VIEWS: readonly RelationTypeView[] = RELATION_TYPES.map((type) => ({
|
|
18
|
+
id: type.key,
|
|
19
|
+
alias: type.alias,
|
|
20
|
+
name: type.name,
|
|
21
|
+
parentObject: objectType(type.parentObjectType),
|
|
22
|
+
childObject: objectType(type.childObjectType),
|
|
23
|
+
isBidirectional: type.isBidirectional,
|
|
24
|
+
isDependency: type.isDependency,
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
export function createRelationPort(db: Db): RelationPort {
|
|
28
|
+
const relations = new RelationRepository(db)
|
|
29
|
+
return {
|
|
30
|
+
types: () => VIEWS,
|
|
31
|
+
async relations(typeKey, paging) {
|
|
32
|
+
const type = relationTypeByKey(typeKey)
|
|
33
|
+
if (!type) return undefined
|
|
34
|
+
const result = await relations.relations(type, paging.skip, paging.take)
|
|
35
|
+
return {
|
|
36
|
+
total: result.total,
|
|
37
|
+
items: result.items.map((relation) => ({
|
|
38
|
+
id: relation.key,
|
|
39
|
+
relationType: { id: relation.typeKey },
|
|
40
|
+
parent: { id: relation.parent.key, name: relation.parent.name },
|
|
41
|
+
child: { id: relation.child.key, name: relation.child.name },
|
|
42
|
+
createDate: relation.createDate.toISOString(),
|
|
43
|
+
comment: null,
|
|
44
|
+
})),
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|