@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
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** The Examine dashboard's port over the search index. docs/search.md. */
|
|
2
|
+
import type { SearchIndexInfo, SearchIndexPort } from '@crombolo/api-management'
|
|
3
|
+
import { type Db, SEARCH_INDEXES, SearchIndex, type SearchIndexDefinition } from '@crombolo/data'
|
|
4
|
+
|
|
5
|
+
export function createSearchIndexPort(db: Db, onQueued?: () => void): SearchIndexPort {
|
|
6
|
+
const search = new SearchIndex(db)
|
|
7
|
+
const info = async (index: SearchIndexDefinition): Promise<SearchIndexInfo> => ({
|
|
8
|
+
name: index.name,
|
|
9
|
+
searcherName: index.searcherName,
|
|
10
|
+
...(await search.stats(index)),
|
|
11
|
+
})
|
|
12
|
+
return {
|
|
13
|
+
indexes: () => Promise.all(SEARCH_INDEXES.map(info)),
|
|
14
|
+
async index(name) {
|
|
15
|
+
const index = SearchIndex.definition(name)
|
|
16
|
+
return index ? info(index) : undefined
|
|
17
|
+
},
|
|
18
|
+
async rebuild(name) {
|
|
19
|
+
const index = SearchIndex.definition(name)
|
|
20
|
+
if (!index) return false
|
|
21
|
+
await search.rebuild(index)
|
|
22
|
+
onQueued?.()
|
|
23
|
+
return true
|
|
24
|
+
},
|
|
25
|
+
searchers: () => SEARCH_INDEXES.map((index) => index.searcherName),
|
|
26
|
+
async query(searcherName, term, paging) {
|
|
27
|
+
const index = SearchIndex.bySearcher(searcherName)
|
|
28
|
+
return index ? search.query(index, term, paging) : undefined
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -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,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a server could not start, said so a person can fix it, and how long to
|
|
3
|
+
* wait before trying again. Pure: `crombolo start` serves the page and does the
|
|
4
|
+
* waiting. Nothing here may carry a secret — the database is named by host and
|
|
5
|
+
* database only, never by its URL.
|
|
6
|
+
*/
|
|
7
|
+
import { MigrationStepError } from '@crombolo/data'
|
|
8
|
+
import { DatabaseNotReadyError, UpgradePendingError } from './database.ts'
|
|
9
|
+
import { SchemaBootError } from './schema.ts'
|
|
10
|
+
|
|
11
|
+
export interface BootFailure {
|
|
12
|
+
title: string
|
|
13
|
+
detail: string
|
|
14
|
+
/** The setting or command that fixes it. */
|
|
15
|
+
remedy: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const sqlState = (error: { code?: unknown; errno?: unknown }) =>
|
|
19
|
+
typeof error.errno === 'string' ? error.errno : typeof error.code === 'string' ? error.code : ''
|
|
20
|
+
|
|
21
|
+
export function describeBootFailure(error: unknown, database: string): BootFailure {
|
|
22
|
+
if (error instanceof UpgradePendingError)
|
|
23
|
+
return {
|
|
24
|
+
title: 'An upgrade is waiting',
|
|
25
|
+
detail: `The database at ${database} needs upgrading before this version can use it.`,
|
|
26
|
+
remedy: 'Run `crombolo upgrade check`, then `crombolo upgrade`. This page retries by itself.',
|
|
27
|
+
}
|
|
28
|
+
if (error instanceof DatabaseNotReadyError)
|
|
29
|
+
return {
|
|
30
|
+
title: 'The database has not been prepared yet',
|
|
31
|
+
detail: `The database at ${database} is behind this version, and this server only reads.`,
|
|
32
|
+
remedy: 'Start the server that edits the site first, or run `crombolo upgrade`.',
|
|
33
|
+
}
|
|
34
|
+
if (error instanceof MigrationStepError)
|
|
35
|
+
return {
|
|
36
|
+
title: 'An upgrade step failed',
|
|
37
|
+
detail: error.message,
|
|
38
|
+
remedy: 'Fix the cause named above; the next attempt carries on from that step.',
|
|
39
|
+
}
|
|
40
|
+
if (error instanceof SchemaBootError)
|
|
41
|
+
return {
|
|
42
|
+
title: "The site's content types could not be loaded",
|
|
43
|
+
detail: error.message,
|
|
44
|
+
remedy: 'Correct the files named above in `schema/`, then deploy again.',
|
|
45
|
+
}
|
|
46
|
+
const e = (error ?? {}) as { code?: unknown; errno?: unknown; message?: unknown }
|
|
47
|
+
const state = sqlState(e)
|
|
48
|
+
if (state === 'ERR_POSTGRES_CONNECTION_REFUSED' || state === 'ECONNREFUSED')
|
|
49
|
+
return {
|
|
50
|
+
title: "Can't reach the database",
|
|
51
|
+
detail: `Nothing answered at ${database}.`,
|
|
52
|
+
remedy:
|
|
53
|
+
'Check the host and port in `CROMBOLO_POSTGRES_URL`, and that the database server is running and reachable from here.',
|
|
54
|
+
}
|
|
55
|
+
if (state === '28P01' || state === '28000')
|
|
56
|
+
return {
|
|
57
|
+
title: 'The database refused the sign-in',
|
|
58
|
+
detail: `${database} did not accept the user name and password it was given.`,
|
|
59
|
+
remedy: 'Check the user name and password in `CROMBOLO_POSTGRES_URL`.',
|
|
60
|
+
}
|
|
61
|
+
if (state === '3D000')
|
|
62
|
+
return {
|
|
63
|
+
title: 'The database does not exist',
|
|
64
|
+
detail: `The server at ${database} has no database by that name.`,
|
|
65
|
+
remedy: 'Create the database, or correct its name in `CROMBOLO_POSTGRES_URL`.',
|
|
66
|
+
}
|
|
67
|
+
if (state === 'SQLITE_CANTOPEN')
|
|
68
|
+
return {
|
|
69
|
+
title: "Can't open the database file",
|
|
70
|
+
detail: `${database} could not be opened or created.`,
|
|
71
|
+
remedy:
|
|
72
|
+
'Check `CROMBOLO_SQLITE_FILE`, and that its folder exists and this server may write to it.',
|
|
73
|
+
}
|
|
74
|
+
if (/no connection string/i.test(String(e.message ?? '')))
|
|
75
|
+
return {
|
|
76
|
+
title: 'No database is configured',
|
|
77
|
+
detail: 'Postgres was selected, but no connection string was given.',
|
|
78
|
+
remedy: 'Set `CROMBOLO_POSTGRES_URL`, or unset `CROMBOLO_DB` to use SQLite.',
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
title: 'The site could not start',
|
|
82
|
+
detail: 'Something unexpected stopped it. The server log has the details.',
|
|
83
|
+
remedy: 'Read the latest error in the server log.',
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Waits between attempts: doubling from `baseMs`, never longer than `capMs`. */
|
|
88
|
+
export function bootRetryDelays(attempts: number, baseMs = 2_000, capMs = 60_000): number[] {
|
|
89
|
+
return Array.from({ length: Math.max(0, attempts - 1) }, (_, i) =>
|
|
90
|
+
Math.min(capMs, baseMs * 2 ** i),
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const escapeHtml = (value: string): string =>
|
|
95
|
+
value.replace(
|
|
96
|
+
/[&<>"']/g,
|
|
97
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] as string,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const withCode = (value: string) =>
|
|
101
|
+
escapeHtml(value).replace(/`([^`]+)`/g, (_, code: string) => `<code>${code}</code>`)
|
|
102
|
+
|
|
103
|
+
/** The page a starting server serves while it cannot start. */
|
|
104
|
+
export function bootFailurePage(
|
|
105
|
+
failure: BootFailure | undefined,
|
|
106
|
+
progress: { attempt: number; attempts: number; retryInSeconds: number | undefined },
|
|
107
|
+
): string {
|
|
108
|
+
const title = failure?.title ?? 'The site is starting'
|
|
109
|
+
const next =
|
|
110
|
+
progress.retryInSeconds === undefined
|
|
111
|
+
? 'It will not try again; the server is stopping.'
|
|
112
|
+
: `Trying again in ${progress.retryInSeconds} seconds (attempt ${progress.attempt} of ${progress.attempts}).`
|
|
113
|
+
return `<!doctype html>
|
|
114
|
+
<html lang="en">
|
|
115
|
+
<head>
|
|
116
|
+
<meta charset="utf-8">
|
|
117
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
118
|
+
<meta name="robots" content="noindex">
|
|
119
|
+
<meta http-equiv="refresh" content="10">
|
|
120
|
+
<title>${escapeHtml(title)}</title>
|
|
121
|
+
<style>
|
|
122
|
+
:root { color-scheme: light dark; }
|
|
123
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center;
|
|
124
|
+
font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
125
|
+
background: #f6f5f3; color: #1b1b1b; }
|
|
126
|
+
main { max-width: 34rem; padding: 2.5rem 1rem; }
|
|
127
|
+
h1 { font-size: 1.6rem; margin: 0 0 .75rem; }
|
|
128
|
+
p { margin: 0 0 1rem; white-space: pre-line; }
|
|
129
|
+
code { font-size: .9em; background: #e9e7e2; padding: .1em .35em; border-radius: 3px; }
|
|
130
|
+
.fine { font-size: .85rem; color: #6b6b63; }
|
|
131
|
+
@media (prefers-color-scheme: dark) {
|
|
132
|
+
body { background: #16171a; color: #e8e6e3; }
|
|
133
|
+
code { background: #26282c; }
|
|
134
|
+
.fine { color: #9a9a93; }
|
|
135
|
+
}
|
|
136
|
+
</style>
|
|
137
|
+
</head>
|
|
138
|
+
<body>
|
|
139
|
+
<main>
|
|
140
|
+
<h1>${escapeHtml(title)}</h1>
|
|
141
|
+
${failure ? `<p>${withCode(failure.detail)}</p>\n <p><strong>To fix it:</strong> ${withCode(failure.remedy)}</p>` : '<p>This takes a few seconds.</p>'}
|
|
142
|
+
<p class="fine">${escapeHtml(next)}</p>
|
|
143
|
+
</main>
|
|
144
|
+
</body>
|
|
145
|
+
</html>
|
|
146
|
+
`
|
|
147
|
+
}
|
package/src/bundle-install.ts
CHANGED
|
@@ -25,11 +25,13 @@ import {
|
|
|
25
25
|
} from '@crombolo/backoffice-host'
|
|
26
26
|
import { ObjectTypes } from '@crombolo/core'
|
|
27
27
|
import {
|
|
28
|
+
answersByNode,
|
|
28
29
|
type Db,
|
|
29
30
|
describeRefFailure,
|
|
30
31
|
type NodeRow,
|
|
31
32
|
recordInLedger,
|
|
32
33
|
resolveNodeRef,
|
|
34
|
+
TransferAnswerRepository,
|
|
33
35
|
writeReport,
|
|
34
36
|
} from '@crombolo/data'
|
|
35
37
|
import {
|
|
@@ -40,8 +42,15 @@ import {
|
|
|
40
42
|
isPackageName,
|
|
41
43
|
loadBundle,
|
|
42
44
|
loadResolutions,
|
|
45
|
+
mergeResolutions,
|
|
46
|
+
type NodeResolutions,
|
|
47
|
+
openBundle,
|
|
43
48
|
type Resolution,
|
|
49
|
+
rehearsePublish,
|
|
50
|
+
SealedFileError,
|
|
44
51
|
type TransferCheck,
|
|
52
|
+
WrongPassphraseError,
|
|
53
|
+
writeResolutions,
|
|
45
54
|
} from '@crombolo/transfer'
|
|
46
55
|
import { type BackupResult, backupBefore } from './backup.ts'
|
|
47
56
|
import {
|
|
@@ -75,6 +84,9 @@ export class CommandError extends Error {
|
|
|
75
84
|
}
|
|
76
85
|
}
|
|
77
86
|
|
|
87
|
+
/** The bundle is encrypted, and the passphrase given does not open it, or none was. */
|
|
88
|
+
export class PassphraseError extends CommandError {}
|
|
89
|
+
|
|
78
90
|
export interface BundleSummary {
|
|
79
91
|
id: string
|
|
80
92
|
from: string
|
|
@@ -95,10 +107,14 @@ export interface ResolvedBundle {
|
|
|
95
107
|
payloadDir: string
|
|
96
108
|
/** Whether it was found as one of the site's dependencies rather than a directory. */
|
|
97
109
|
dependency: boolean
|
|
110
|
+
/** The package's directory. */
|
|
111
|
+
directory: string
|
|
98
112
|
set: ContentSet
|
|
99
113
|
blobs: ReadonlyMap<string, string>
|
|
100
114
|
/** The sections it carries beyond its content; empty for a content-only bundle. */
|
|
101
115
|
carried: CarriedSections
|
|
116
|
+
/** A carried media file's bytes as exported: opened, when the bundle sealed them. */
|
|
117
|
+
openBlob: (key: string, bytes: Uint8Array) => Uint8Array
|
|
102
118
|
}
|
|
103
119
|
|
|
104
120
|
/** Where a package directory or a dependency's name points, before anything is read. */
|
|
@@ -122,7 +138,7 @@ function locate(target: string, siteDir: string): { directory: string; dependenc
|
|
|
122
138
|
* The bundle a path or a package name names, read whole, or a refusal: a
|
|
123
139
|
* half-copied bundle must never be checked as though complete.
|
|
124
140
|
*/
|
|
125
|
-
export function readBundle(target: string, siteDir: string): ResolvedBundle {
|
|
141
|
+
export function readBundle(target: string, siteDir: string, passphrase?: string): ResolvedBundle {
|
|
126
142
|
const { directory, dependency } = locate(target, siteDir)
|
|
127
143
|
const json = readPackageJson(join(directory, 'package.json'))
|
|
128
144
|
if (!json) {
|
|
@@ -154,13 +170,34 @@ export function readBundle(target: string, siteDir: string): ResolvedBundle {
|
|
|
154
170
|
'The bundle is incomplete or damaged; nothing was read from it.',
|
|
155
171
|
problems,
|
|
156
172
|
)
|
|
173
|
+
// Opened only when given the passphrase: without one the set stays sealed,
|
|
174
|
+
// and the check says what it needs rather than comparing what it cannot read.
|
|
175
|
+
let opened: ReturnType<typeof openBundle> = {
|
|
176
|
+
set: loaded.set,
|
|
177
|
+
people: undefined,
|
|
178
|
+
openBlob: (_key, bytes) => bytes,
|
|
179
|
+
}
|
|
180
|
+
if (passphrase !== undefined && passphrase !== '')
|
|
181
|
+
try {
|
|
182
|
+
opened = openBundle(loaded, passphrase)
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error instanceof WrongPassphraseError)
|
|
185
|
+
throw new PassphraseError(
|
|
186
|
+
'The passphrase does not open this bundle; nothing was read from it.',
|
|
187
|
+
)
|
|
188
|
+
if (error instanceof SealedFileError)
|
|
189
|
+
throw new CommandError('The bundle is damaged; nothing was read from it.', [error.message])
|
|
190
|
+
throw error
|
|
191
|
+
}
|
|
157
192
|
return {
|
|
158
193
|
package: pkg,
|
|
159
194
|
payloadDir: pkg.contentDir,
|
|
160
195
|
dependency,
|
|
161
|
-
|
|
196
|
+
directory,
|
|
197
|
+
set: opened.set,
|
|
162
198
|
blobs: loaded.blobs,
|
|
163
199
|
carried: carriedSections(loaded),
|
|
200
|
+
openBlob: opened.openBlob,
|
|
164
201
|
}
|
|
165
202
|
}
|
|
166
203
|
|
|
@@ -200,13 +237,22 @@ export interface TransferInput extends SectionOptions {
|
|
|
200
237
|
bundle: string
|
|
201
238
|
/** Where the bundle's roots go: a path or a key. */
|
|
202
239
|
under?: string
|
|
203
|
-
/**
|
|
204
|
-
resolutions?: Record<string,
|
|
240
|
+
/** Decisions by node key, on top of any the bundle carries: `"*"` and per property. */
|
|
241
|
+
resolutions?: Record<string, NodeResolutions>
|
|
205
242
|
resolveAll?: Resolution
|
|
206
243
|
allowMissingBlobs?: boolean
|
|
244
|
+
/** Publish what was live at the source; the check then says which pages would be refused. */
|
|
245
|
+
publish?: boolean
|
|
246
|
+
/** Who asked, recorded on the pending import. */
|
|
247
|
+
by?: string
|
|
248
|
+
/** Opens what the bundle sealed; used for this request only, never stored. */
|
|
249
|
+
passphrase?: string
|
|
207
250
|
}
|
|
208
251
|
|
|
209
|
-
/**
|
|
252
|
+
/**
|
|
253
|
+
* The options `check` and `import` share: the bundle's saved decisions, then
|
|
254
|
+
* those given in the backoffice, then the caller's; then placement.
|
|
255
|
+
*/
|
|
210
256
|
export async function transferOptions(
|
|
211
257
|
db: Db,
|
|
212
258
|
config: CromboloConfig,
|
|
@@ -214,7 +260,16 @@ export async function transferOptions(
|
|
|
214
260
|
input: Omit<TransferInput, 'bundle'>,
|
|
215
261
|
): Promise<{ options: CheckOptions; under: NodeRow | undefined; problems: string[] }> {
|
|
216
262
|
const saved = loadResolutions(bundle.payloadDir, bundle.set.manifest.id)
|
|
217
|
-
const
|
|
263
|
+
const answers = new TransferAnswerRepository(db)
|
|
264
|
+
const given = answersByNode(await answers.answers(bundle.set.manifest.id)) as Record<
|
|
265
|
+
string,
|
|
266
|
+
NodeResolutions
|
|
267
|
+
>
|
|
268
|
+
const pending = await answers.pending(bundle.set.manifest.id)
|
|
269
|
+
const resolutions = mergeResolutions(
|
|
270
|
+
mergeResolutions(saved.file?.byNode ?? {}, given),
|
|
271
|
+
input.resolutions ?? {},
|
|
272
|
+
)
|
|
218
273
|
const resolveAll = input.resolveAll ?? saved.file?.all ?? undefined
|
|
219
274
|
const underRef = input.under ?? saved.file?.under ?? undefined
|
|
220
275
|
let under: NodeRow | undefined
|
|
@@ -232,7 +287,7 @@ export async function transferOptions(
|
|
|
232
287
|
resolutions,
|
|
233
288
|
resolveAll,
|
|
234
289
|
componentAliases: componentAliasesIn(config.componentsDir),
|
|
235
|
-
allowMissingBlobs: input.allowMissingBlobs ?? false,
|
|
290
|
+
allowMissingBlobs: input.allowMissingBlobs ?? pending?.allowMissingBlobs ?? false,
|
|
236
291
|
hasBlob: async (key: string) => Boolean(await store.get(key)),
|
|
237
292
|
backOfficePath: config.backOfficePath,
|
|
238
293
|
},
|
|
@@ -251,8 +306,10 @@ export interface ContentCheckResult {
|
|
|
251
306
|
sections: SectionPlan | undefined
|
|
252
307
|
/** Nothing outstanding: the bundle would import cleanly. */
|
|
253
308
|
clean: boolean
|
|
309
|
+
/** With `publish`: the pages the import would not publish, and the reason publish gives. */
|
|
310
|
+
publishRefusals: { key: string; reason: string }[]
|
|
254
311
|
/** The decisions this check ran with — the bundle's saved ones under the caller's. */
|
|
255
|
-
decisions: { all: Resolution | null; byNode: Record<string,
|
|
312
|
+
decisions: { all: Resolution | null; byNode: Record<string, NodeResolutions> }
|
|
256
313
|
problems: string[]
|
|
257
314
|
}
|
|
258
315
|
|
|
@@ -262,10 +319,30 @@ export async function checkBundleContent(
|
|
|
262
319
|
config: CromboloConfig,
|
|
263
320
|
input: TransferInput,
|
|
264
321
|
): Promise<ContentCheckResult> {
|
|
265
|
-
const bundle = readBundle(input.bundle, config.siteDir)
|
|
322
|
+
const bundle = readBundle(input.bundle, config.siteDir, input.passphrase)
|
|
266
323
|
const set = bundle.set
|
|
267
324
|
const prepared = await transferOptions(db, config, bundle, input)
|
|
268
325
|
const check = await checkBundle(db, set, prepared.options)
|
|
326
|
+
await recordPending(db, bundle, input, prepared.options.allowMissingBlobs ?? false)
|
|
327
|
+
// Rehearsed rather than predicted, so the refusals are publish's own, word
|
|
328
|
+
// for word; only once nothing is outstanding, since the import stops there.
|
|
329
|
+
const refusals =
|
|
330
|
+
input.publish && check.outstanding.length === 0
|
|
331
|
+
? ((await rehearsePublish(db, set, prepared.options)) ?? [])
|
|
332
|
+
: []
|
|
333
|
+
const names = new Map(set.nodes.map((node) => [node.key, node.variants[0]?.name ?? node.key]))
|
|
334
|
+
for (const refusal of refusals)
|
|
335
|
+
check.findings.push({
|
|
336
|
+
kind: 'auto',
|
|
337
|
+
code: 'publish-refused',
|
|
338
|
+
subjectType: 'document',
|
|
339
|
+
subjectKey: refusal.key,
|
|
340
|
+
subjectName: names.get(refusal.key) ?? refusal.key,
|
|
341
|
+
propertyAlias: null,
|
|
342
|
+
culture: null,
|
|
343
|
+
message: `"${names.get(refusal.key) ?? refusal.key}" would not be published: ${refusal.reason}`,
|
|
344
|
+
link: null,
|
|
345
|
+
})
|
|
269
346
|
// Under this bundle's own scope, so two bundles in flight do not resolve each other's.
|
|
270
347
|
await writeReport(db, check.findings, { source: 'transfer', scope: set.manifest.id })
|
|
271
348
|
return {
|
|
@@ -277,9 +354,10 @@ export async function checkBundleContent(
|
|
|
277
354
|
? await planSections(db, config, bundle.carried, input)
|
|
278
355
|
: undefined,
|
|
279
356
|
clean: check.outstanding.length === 0,
|
|
357
|
+
publishRefusals: refusals,
|
|
280
358
|
decisions: {
|
|
281
359
|
all: prepared.options.resolveAll ?? null,
|
|
282
|
-
byNode: prepared.options.resolutions ?? {},
|
|
360
|
+
byNode: { ...(prepared.options.resolutions ?? {}) },
|
|
283
361
|
},
|
|
284
362
|
problems: prepared.problems,
|
|
285
363
|
}
|
|
@@ -314,14 +392,66 @@ export interface ContentImportResult {
|
|
|
314
392
|
problems: string[]
|
|
315
393
|
}
|
|
316
394
|
|
|
395
|
+
/**
|
|
396
|
+
* What the dashboard needs to run this bundle's import again: where it was
|
|
397
|
+
* found and what it was asked to do. A directory is this server's path.
|
|
398
|
+
*/
|
|
399
|
+
async function recordPending(
|
|
400
|
+
db: Db,
|
|
401
|
+
bundle: ResolvedBundle,
|
|
402
|
+
input: TransferInput,
|
|
403
|
+
allowMissingBlobs: boolean,
|
|
404
|
+
): Promise<void> {
|
|
405
|
+
const manifest = bundle.set.manifest
|
|
406
|
+
await new TransferAnswerRepository(db).recordCheck({
|
|
407
|
+
bundleId: manifest.id,
|
|
408
|
+
label: manifest.label ?? (bundle.package.name || bundle.package.packageName),
|
|
409
|
+
target: bundle.dependency ? bundle.package.packageName : bundle.directory,
|
|
410
|
+
targetKind: bundle.dependency ? 'package' : 'directory',
|
|
411
|
+
under: input.under ?? null,
|
|
412
|
+
publish: input.publish ?? false,
|
|
413
|
+
allowMissingBlobs,
|
|
414
|
+
checkedAt: new Date(),
|
|
415
|
+
checkedBy: input.by ?? null,
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The decisions an import of this bundle would run with — saved, then given
|
|
421
|
+
* in the backoffice — as the `resolutions.json` the CLI's `--save` writes.
|
|
422
|
+
*/
|
|
423
|
+
export async function pendingResolutionsFile(
|
|
424
|
+
db: Db,
|
|
425
|
+
config: CromboloConfig,
|
|
426
|
+
bundleId: string,
|
|
427
|
+
): Promise<string | undefined> {
|
|
428
|
+
const pending = await new TransferAnswerRepository(db).pending(bundleId)
|
|
429
|
+
if (!pending) return undefined
|
|
430
|
+
const bundle = readBundle(pending.target, config.siteDir)
|
|
431
|
+
const saved = loadResolutions(bundle.payloadDir, bundleId)
|
|
432
|
+
const given = answersByNode(await new TransferAnswerRepository(db).answers(bundleId))
|
|
433
|
+
return writeResolutions({
|
|
434
|
+
bundleId,
|
|
435
|
+
under: pending.under ?? saved.file?.under ?? null,
|
|
436
|
+
all: saved.file?.all ?? null,
|
|
437
|
+
byNode: mergeResolutions(saved.file?.byNode ?? {}, given as Record<string, NodeResolutions>),
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
|
|
317
441
|
/** Back up, re-check, then apply in one transaction and record the run. */
|
|
318
442
|
export async function installBundleContent(
|
|
319
443
|
db: Db,
|
|
320
444
|
config: CromboloConfig,
|
|
321
445
|
input: ContentImportInput,
|
|
322
446
|
): Promise<ContentImportResult> {
|
|
323
|
-
const bundle = readBundle(input.bundle, config.siteDir)
|
|
447
|
+
const bundle = readBundle(input.bundle, config.siteDir, input.passphrase)
|
|
324
448
|
const { set, blobs, carried } = bundle
|
|
449
|
+
// Before the backup and the sections: nothing of a bundle that cannot be
|
|
450
|
+
// opened is applied.
|
|
451
|
+
if (set.sealed)
|
|
452
|
+
throw new PassphraseError(
|
|
453
|
+
'This bundle is encrypted: give the passphrase it was exported with. Nothing was written.',
|
|
454
|
+
)
|
|
325
455
|
// The coarse layer, under the fine-grained revert: in-place conversion is not
|
|
326
456
|
// reversible by itself, so the same rule as `upgrade` applies here.
|
|
327
457
|
const backup = await backupBefore(config, {
|
|
@@ -402,10 +532,15 @@ export async function installBundleContent(
|
|
|
402
532
|
problems: prepared.problems,
|
|
403
533
|
sections,
|
|
404
534
|
}
|
|
405
|
-
if (!result.runId)
|
|
535
|
+
if (!result.runId) {
|
|
536
|
+
await recordPending(db, bundle, input, prepared.options.allowMissingBlobs ?? false)
|
|
537
|
+
return outcome
|
|
538
|
+
}
|
|
539
|
+
await new TransferAnswerRepository(db).clear(set.manifest.id)
|
|
406
540
|
// The backups become the run's, so `content revert` can find them.
|
|
407
541
|
if (staging) outcome.replacedFilesKept = promoteBackups(config, staging, result.runId)
|
|
408
|
-
if (blobs.size > 0)
|
|
542
|
+
if (blobs.size > 0)
|
|
543
|
+
outcome.blobs = await placeBlobs(await mediaStoreFor(config), blobs, bundle.openBlob)
|
|
409
544
|
await recordInLedger(db, {
|
|
410
545
|
name: `content import ${set.manifest.id}`,
|
|
411
546
|
kind: 'transfer',
|
package/src/bundles.ts
CHANGED
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
* Deliberately not offered: raw SQL, a `Db`, and anything that writes the
|
|
41
41
|
* site's files.
|
|
42
42
|
*/
|
|
43
|
+
|
|
43
44
|
import { hasCultureAccess, hasSection, type Principal } from '@crombolo/api-management'
|
|
44
45
|
import { BUNDLE_ID, type BundlePackage, peerRangeProblem } from '@crombolo/backoffice-host'
|
|
45
46
|
import {
|
|
@@ -60,6 +61,8 @@ import {
|
|
|
60
61
|
type RedirectTargetKind,
|
|
61
62
|
} from '@crombolo/data'
|
|
62
63
|
import { normaliseRedirectPath, type PublishedCache } from '@crombolo/render'
|
|
64
|
+
import type { BundleForms } from './form-types.ts'
|
|
65
|
+
import type { BundleEvents } from './webhooks.ts'
|
|
63
66
|
|
|
64
67
|
/** The segment every bundle's routes hang below, inside the plugin API. */
|
|
65
68
|
export const BUNDLE_API_SEGMENT = 'bundle'
|
|
@@ -232,6 +235,17 @@ export interface ServerBundle<
|
|
|
232
235
|
routes: readonly ServerBundleRoute<C, T>[]
|
|
233
236
|
/** Tables the bundle owns, from `defineBundleTables`, under its own id. */
|
|
234
237
|
tables?: T
|
|
238
|
+
/**
|
|
239
|
+
* Form field and workflow types it adds. Unlike its routes and tables these
|
|
240
|
+
* are honoured on every node, because forms render and submit on `web` nodes.
|
|
241
|
+
*/
|
|
242
|
+
forms?: BundleForms
|
|
243
|
+
/**
|
|
244
|
+
* Handlers for webhook events, by alias. Each event reaches each handler
|
|
245
|
+
* through the webhook delivery queue: once per cluster, after the change,
|
|
246
|
+
* retried on failure, and in the delivery log (docs/webhooks.md).
|
|
247
|
+
*/
|
|
248
|
+
events?: BundleEvents
|
|
235
249
|
}
|
|
236
250
|
|
|
237
251
|
/** A bundle as mounted, with the prefix its routes answer below. */
|
package/src/busy.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Contention a write could not get past, answered as a retryable 503 rather than a 500. */
|
|
2
|
+
import { problemResponse, serviceBusy } from '@crombolo/core'
|
|
3
|
+
import { ContentBusyError, classifyContention } from '@crombolo/data'
|
|
4
|
+
|
|
5
|
+
/** The response for an error that is lock contention; undefined for anything else. */
|
|
6
|
+
export function busyResponse(error: unknown): Response | undefined {
|
|
7
|
+
const contention = classifyContention(error)
|
|
8
|
+
if (!contention) return undefined
|
|
9
|
+
const busy = error instanceof ContentBusyError ? error : new ContentBusyError(contention)
|
|
10
|
+
return problemResponse(serviceBusy(busy.message), {
|
|
11
|
+
'retry-after': String(busy.retryAfterSeconds),
|
|
12
|
+
})
|
|
13
|
+
}
|