@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/src/jobs.ts
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Background jobs, as Umbraco runs them: scheduled publishing every minute and
|
|
3
|
-
* version cleanup every hour when it is enabled
|
|
3
|
+
* version cleanup every hour when it is enabled, plus the search indexer
|
|
4
|
+
* draining its queue and the audit log ageing out old entries hourly. Each run can also be called directly, which is
|
|
4
5
|
* how tests drive them. On a multi-node Postgres site every node runs them;
|
|
5
|
-
* each schedule is claimed atomically, so it runs once.
|
|
6
|
+
* each schedule and queued item is claimed atomically, so it runs once.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import type { NodeSchemaState } from '@crombolo/data'
|
|
9
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
AuditLog,
|
|
12
|
+
type Db,
|
|
13
|
+
DocumentRepository,
|
|
14
|
+
PublishBlockedError,
|
|
15
|
+
SearchIndex,
|
|
16
|
+
} from '@crombolo/data'
|
|
10
17
|
import type { PublishedCache } from '@crombolo/render'
|
|
18
|
+
import { auditRetainedSince, recordAudit } from './adapters/audit.ts'
|
|
11
19
|
import type { WorkflowRunner, WorkflowRunReport } from './form-workflows.ts'
|
|
12
20
|
import { logger } from './logging.ts'
|
|
13
21
|
import type { MediaFileStore } from './media-files.ts'
|
|
22
|
+
import type { WebhookService } from './webhooks.ts'
|
|
14
23
|
|
|
15
24
|
export interface JobsOptions {
|
|
16
25
|
db: Db
|
|
@@ -33,6 +42,13 @@ export interface JobsOptions {
|
|
|
33
42
|
*/
|
|
34
43
|
formWorkflows?: WorkflowRunner
|
|
35
44
|
formWorkflowsMs?: number
|
|
45
|
+
/** Raises the publishes scheduled publishing makes, and drains the delivery queue. */
|
|
46
|
+
webhooks?: Pick<WebhookService, 'raise' | 'runDue'>
|
|
47
|
+
webhooksMs?: number
|
|
48
|
+
/** How often the search indexer looks for queued items; a write here also starts it at once. */
|
|
49
|
+
searchIndexingMs?: number
|
|
50
|
+
/** Days the audit log keeps an entry; 0 or absent keeps them forever. */
|
|
51
|
+
auditRetentionDays?: number
|
|
36
52
|
}
|
|
37
53
|
|
|
38
54
|
export interface ScheduledRun {
|
|
@@ -45,6 +61,14 @@ export interface BackgroundJobs {
|
|
|
45
61
|
runScheduledPublishing(now?: Date): Promise<ScheduledRun>
|
|
46
62
|
/** Runs the form workflows that are due; a no-op when none are configured. */
|
|
47
63
|
runFormWorkflows(now?: Date): Promise<WorkflowRunReport>
|
|
64
|
+
/** Delivers the webhook and bundle events that are due. */
|
|
65
|
+
runWebhooks(now?: Date): Promise<WorkflowRunReport>
|
|
66
|
+
/** Indexes every queued item, and returns how many. docs/search.md. */
|
|
67
|
+
runSearchIndexing(): Promise<number>
|
|
68
|
+
/** Something was queued here: index it shortly, rather than at the next tick. */
|
|
69
|
+
kickSearchIndexing(): void
|
|
70
|
+
/** Deletes audit entries older than the retention period, and returns how many. */
|
|
71
|
+
runAuditRetention(now?: Date): Promise<number>
|
|
48
72
|
/** Runs whether or not the hourly job is enabled; the policy decides what goes. */
|
|
49
73
|
runVersionCleanup(now?: Date): Promise<{ nodes: number; versionsDeleted: number }>
|
|
50
74
|
start(): void
|
|
@@ -60,6 +84,9 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
|
|
|
60
84
|
})
|
|
61
85
|
const timers: Array<ReturnType<typeof setInterval>> = []
|
|
62
86
|
let running = false
|
|
87
|
+
const search = new SearchIndex(options.db)
|
|
88
|
+
let indexing: Promise<number> | undefined
|
|
89
|
+
let kicked: ReturnType<typeof setTimeout> | undefined
|
|
63
90
|
|
|
64
91
|
async function runScheduledPublishing(now = new Date()): Promise<ScheduledRun> {
|
|
65
92
|
const run: ScheduledRun = { published: [], unpublished: [], failed: [] }
|
|
@@ -67,13 +94,20 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
|
|
|
67
94
|
if (!(await documents.claimSchedule(due.id))) continue
|
|
68
95
|
const cultures = due.culture ? [due.culture] : null
|
|
69
96
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
97
|
+
const release = due.action === 'Release'
|
|
98
|
+
const changed = release
|
|
99
|
+
? await documents.publish(due.key, cultures)
|
|
100
|
+
: await documents.unpublish(due.key, cultures)
|
|
101
|
+
;(release ? run.published : run.unpublished).push(due.key)
|
|
102
|
+
await recordAudit(options.db, {
|
|
103
|
+
category: 'content',
|
|
104
|
+
action: release ? 'Publish' : 'Unpublish',
|
|
105
|
+
source: 'job',
|
|
106
|
+
entityType: 'Document',
|
|
107
|
+
entityKey: due.key,
|
|
108
|
+
entityName: changed?.variants[0]?.name ?? null,
|
|
109
|
+
detail: `Scheduled${due.culture ? ` (${due.culture})` : ''}`,
|
|
110
|
+
})
|
|
77
111
|
options.onDocumentChanged?.(due.key)
|
|
78
112
|
} catch (error) {
|
|
79
113
|
if (!(error instanceof PublishBlockedError)) throw error
|
|
@@ -81,13 +115,26 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
|
|
|
81
115
|
}
|
|
82
116
|
}
|
|
83
117
|
if (run.published.length + run.unpublished.length > 0) options.cache.invalidate()
|
|
118
|
+
for (const key of run.published)
|
|
119
|
+
await options.webhooks?.raise({ alias: 'Umbraco.ContentPublish', key })
|
|
120
|
+
for (const key of run.unpublished)
|
|
121
|
+
await options.webhooks?.raise({ alias: 'Umbraco.ContentUnpublish', key })
|
|
84
122
|
return run
|
|
85
123
|
}
|
|
86
124
|
|
|
125
|
+
async function runAuditRetention(now = new Date()): Promise<number> {
|
|
126
|
+
const before = auditRetainedSince(options.auditRetentionDays ?? 0, now)
|
|
127
|
+
return before ? new AuditLog(options.db).prune(before) : 0
|
|
128
|
+
}
|
|
129
|
+
|
|
87
130
|
async function runVersionCleanup(now = new Date()) {
|
|
88
131
|
return documents.cleanupVersions(now, options.versionCleanup)
|
|
89
132
|
}
|
|
90
133
|
|
|
134
|
+
async function runWebhooks(now = new Date()): Promise<WorkflowRunReport> {
|
|
135
|
+
return (await options.webhooks?.runDue(now)) ?? { claimed: 0, done: 0, retrying: 0, failed: 0 }
|
|
136
|
+
}
|
|
137
|
+
|
|
91
138
|
async function runFormWorkflows(now = new Date()): Promise<WorkflowRunReport> {
|
|
92
139
|
return (
|
|
93
140
|
(await options.formWorkflows?.runDue(now)) ?? {
|
|
@@ -99,6 +146,20 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
|
|
|
99
146
|
)
|
|
100
147
|
}
|
|
101
148
|
|
|
149
|
+
// Its own guard rather than `guarded`: indexing a big rebuild must not hold up
|
|
150
|
+
// a scheduled publish, and a kick while it runs joins the run in progress.
|
|
151
|
+
function runSearchIndexing(): Promise<number> {
|
|
152
|
+
indexing ??= search.indexAll().finally(() => {
|
|
153
|
+
indexing = undefined
|
|
154
|
+
})
|
|
155
|
+
return indexing
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const indexQuietly = () =>
|
|
159
|
+
runSearchIndexing().catch((error: unknown) => {
|
|
160
|
+
logger('jobs').error('Search indexing failed', { error })
|
|
161
|
+
})
|
|
162
|
+
|
|
102
163
|
const guarded = (job: () => Promise<unknown>) => async () => {
|
|
103
164
|
if (running) return
|
|
104
165
|
running = true
|
|
@@ -120,18 +181,38 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
|
|
|
120
181
|
return {
|
|
121
182
|
runScheduledPublishing,
|
|
122
183
|
runVersionCleanup,
|
|
184
|
+
runAuditRetention,
|
|
123
185
|
runFormWorkflows,
|
|
186
|
+
runWebhooks,
|
|
187
|
+
runSearchIndexing,
|
|
188
|
+
kickSearchIndexing() {
|
|
189
|
+
if (timers.length === 0 || kicked) return
|
|
190
|
+
kicked = setTimeout(() => {
|
|
191
|
+
kicked = undefined
|
|
192
|
+
void indexQuietly()
|
|
193
|
+
}, 250)
|
|
194
|
+
kicked.unref()
|
|
195
|
+
},
|
|
124
196
|
start() {
|
|
125
197
|
every(options.scheduledPublishingMs ?? 60_000, runScheduledPublishing)
|
|
198
|
+
// The queue is shared, so this also picks up what other nodes wrote.
|
|
199
|
+
const searchTimer = setInterval(indexQuietly, options.searchIndexingMs ?? 5_000)
|
|
200
|
+
searchTimer.unref()
|
|
201
|
+
timers.push(searchTimer)
|
|
126
202
|
// More often than the other jobs: somebody waiting on a form notification
|
|
127
203
|
// notices a minute, and a queue row is cheap to look for.
|
|
128
204
|
if (options.formWorkflows) every(options.formWorkflowsMs ?? 15_000, runFormWorkflows)
|
|
205
|
+
// As often, for the same reason: a receiver that is waiting notices a minute.
|
|
206
|
+
if (options.webhooks) every(options.webhooksMs ?? 15_000, runWebhooks)
|
|
129
207
|
if (options.versionCleanup.enabled) every(60 * 60_000, runVersionCleanup)
|
|
208
|
+
if (options.auditRetentionDays) every(60 * 60_000, runAuditRetention)
|
|
130
209
|
const files = options.mediaFiles
|
|
131
210
|
if (files) every(60 * 60_000, () => files.cleanupExpired())
|
|
132
211
|
},
|
|
133
212
|
stop() {
|
|
134
213
|
for (const timer of timers.splice(0)) clearInterval(timer)
|
|
214
|
+
if (kicked) clearTimeout(kicked)
|
|
215
|
+
kicked = undefined
|
|
135
216
|
},
|
|
136
217
|
started() {
|
|
137
218
|
return timers.length > 0
|
package/src/media-files.ts
CHANGED
|
@@ -262,12 +262,14 @@ export async function readBlobs(
|
|
|
262
262
|
export async function placeBlobs(
|
|
263
263
|
store: MediaStore,
|
|
264
264
|
blobs: ReadonlyMap<string, string>,
|
|
265
|
+
/** Turns a carried file's bytes into the media file's: opens a sealed one. */
|
|
266
|
+
open: (key: string, bytes: Uint8Array) => Uint8Array = (_key, bytes) => bytes,
|
|
265
267
|
): Promise<{ placed: number; bytes: number }> {
|
|
266
268
|
let placed = 0
|
|
267
269
|
let bytes = 0
|
|
268
270
|
for (const [key, path] of blobs) {
|
|
269
271
|
const file = Bun.file(path)
|
|
270
|
-
const content = new Uint8Array(await file.arrayBuffer())
|
|
272
|
+
const content = open(key, new Uint8Array(await file.arrayBuffer()))
|
|
271
273
|
await store.put(key, content, { contentType: contentTypeFor(key) })
|
|
272
274
|
placed += 1
|
|
273
275
|
bytes += content.length
|
package/src/member-auth.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import {
|
|
23
23
|
expireCookie,
|
|
24
24
|
hashPassword,
|
|
25
|
+
isNativeHash,
|
|
25
26
|
memberCookieName,
|
|
26
27
|
passwordConfigJson,
|
|
27
28
|
readCookie,
|
|
@@ -69,6 +70,8 @@ export interface MemberAuth {
|
|
|
69
70
|
decide(content: PublishedContent, member: RequestMember | undefined): Promise<AccessDecision>
|
|
70
71
|
/** Handles the member endpoints, or undefined when the path is not one. */
|
|
71
72
|
handle(request: Request, pathname: string): Promise<Response | undefined>
|
|
73
|
+
/** The `Set-Cookie` value that ends a member's site session. */
|
|
74
|
+
signOutCookie(): string
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
const isFormPost = (request: Request) =>
|
|
@@ -249,10 +252,22 @@ export function createMemberAuth(
|
|
|
249
252
|
await verifyPassword(input.password ?? '', await hashPassword(crypto.randomUUID()))
|
|
250
253
|
return failed()
|
|
251
254
|
}
|
|
252
|
-
if (
|
|
255
|
+
if (
|
|
256
|
+
!(await verifyPassword(input.password ?? '', member.passwordHash, {
|
|
257
|
+
passwordConfig: member.passwordConfig,
|
|
258
|
+
forMember: true,
|
|
259
|
+
}))
|
|
260
|
+
) {
|
|
253
261
|
await members.recordFailedLogin(member.key, config.maxFailedPasswordAttempts)
|
|
254
262
|
return failed()
|
|
255
263
|
}
|
|
264
|
+
// An imported Umbraco hash is replaced now the password is known to match it.
|
|
265
|
+
if (!isNativeHash(member.passwordHash))
|
|
266
|
+
await members.rehashPassword(
|
|
267
|
+
member.key,
|
|
268
|
+
await hashPassword(input.password ?? ''),
|
|
269
|
+
passwordConfigJson(),
|
|
270
|
+
)
|
|
256
271
|
await members.recordLogin(member.key)
|
|
257
272
|
return answer(
|
|
258
273
|
request,
|
|
@@ -359,6 +374,7 @@ export function createMemberAuth(
|
|
|
359
374
|
return {
|
|
360
375
|
resolve,
|
|
361
376
|
decide,
|
|
377
|
+
signOutCookie: () => expireCookie(cookie, config.secureCookies),
|
|
362
378
|
async handle(request, pathname) {
|
|
363
379
|
if (!pathname.startsWith(`${MEMBER_ROUTE_PREFIX}/`)) return undefined
|
|
364
380
|
const action = pathname.slice(MEMBER_ROUTE_PREFIX.length + 1)
|
package/src/models-builder.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* "out of date" is answered by generating the types and comparing them with
|
|
8
8
|
* what is on disk, which cannot drift from the question being asked.
|
|
9
9
|
*/
|
|
10
|
+
|
|
10
11
|
import { existsSync } from 'node:fs'
|
|
11
12
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
12
13
|
import { join, relative } from 'node:path'
|
|
@@ -15,9 +16,11 @@ import type {
|
|
|
15
16
|
ModelsBuilderPort,
|
|
16
17
|
OutOfDateStatus,
|
|
17
18
|
} from '@crombolo/api-management'
|
|
19
|
+
import type { FormTypes } from '@crombolo/core'
|
|
18
20
|
import { generateTypes, loadSchemaDirectory, validateSchemaSet } from '@crombolo/schema'
|
|
19
21
|
import type { CromboloConfig } from './config.ts'
|
|
20
22
|
import { VERSION } from './config.ts'
|
|
23
|
+
import { formTypesFor } from './form-types.ts'
|
|
21
24
|
import { logger } from './logging.ts'
|
|
22
25
|
|
|
23
26
|
/** The file `crombolo generate` writes, inside the schema directory. */
|
|
@@ -26,10 +29,10 @@ export const GENERATED_FILE = 'content-types.d.ts'
|
|
|
26
29
|
export type Intended = { types: string } | { problems: string[] }
|
|
27
30
|
|
|
28
31
|
/** What `crombolo generate` would write now, or the problems stopping it. */
|
|
29
|
-
export function intendedTypes(schemaDir: string): Intended {
|
|
32
|
+
export function intendedTypes(schemaDir: string, formTypes?: FormTypes): Intended {
|
|
30
33
|
if (!existsSync(schemaDir)) return { problems: [`No schema directory at ${schemaDir}`] }
|
|
31
|
-
const loaded = loadSchemaDirectory(schemaDir)
|
|
32
|
-
const problems = [...loaded.problems, ...validateSchemaSet(loaded.set)]
|
|
34
|
+
const loaded = loadSchemaDirectory(schemaDir, { formTypes })
|
|
35
|
+
const problems = [...loaded.problems, ...validateSchemaSet(loaded.set, { types: formTypes })]
|
|
33
36
|
if (problems.length > 0)
|
|
34
37
|
return {
|
|
35
38
|
problems: problems.map((p) => `${p.file}${p.path ? ` (${p.path})` : ''}: ${p.message}`),
|
|
@@ -40,8 +43,9 @@ export function intendedTypes(schemaDir: string): Intended {
|
|
|
40
43
|
/** Undefined when the schema does not generate, so staleness is unknowable. */
|
|
41
44
|
export async function compareModels(
|
|
42
45
|
schemaDir: string,
|
|
46
|
+
formTypes?: FormTypes,
|
|
43
47
|
): Promise<{ outOfDate: boolean } | undefined> {
|
|
44
|
-
const intended = intendedTypes(schemaDir)
|
|
48
|
+
const intended = intendedTypes(schemaDir, formTypes)
|
|
45
49
|
if ('problems' in intended) return undefined
|
|
46
50
|
const target = join(schemaDir, GENERATED_FILE)
|
|
47
51
|
if (!existsSync(target)) return { outOfDate: true }
|
|
@@ -50,7 +54,7 @@ export async function compareModels(
|
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
export function createModelsBuilderPort(
|
|
53
|
-
config: Pick<CromboloConfig, 'schemaDir'
|
|
57
|
+
config: Pick<CromboloConfig, 'schemaDir'> & Partial<Pick<CromboloConfig, 'bundles'>>,
|
|
54
58
|
): ModelsBuilderPort {
|
|
55
59
|
// Umbraco keeps the last generation error in memory and shows it on the
|
|
56
60
|
// dashboard; a build that succeeds clears it.
|
|
@@ -58,7 +62,7 @@ export function createModelsBuilderPort(
|
|
|
58
62
|
const log = logger('models-builder')
|
|
59
63
|
|
|
60
64
|
const describe = async (): Promise<ModelsBuilderInfo> => {
|
|
61
|
-
const compared = await compareModels(config.schemaDir)
|
|
65
|
+
const compared = await compareModels(config.schemaDir, formTypesFor(config))
|
|
62
66
|
return {
|
|
63
67
|
// The models are TypeScript source, written on request: Umbraco's mode for
|
|
64
68
|
// exactly that. The client only explains its own four mode strings.
|
|
@@ -78,13 +82,13 @@ export function createModelsBuilderPort(
|
|
|
78
82
|
info: describe,
|
|
79
83
|
|
|
80
84
|
async status(): Promise<OutOfDateStatus> {
|
|
81
|
-
const compared = await compareModels(config.schemaDir)
|
|
85
|
+
const compared = await compareModels(config.schemaDir, formTypesFor(config))
|
|
82
86
|
if (!compared) return 'Unknown'
|
|
83
87
|
return compared.outOfDate ? 'OutOfDate' : 'Current'
|
|
84
88
|
},
|
|
85
89
|
|
|
86
90
|
async build() {
|
|
87
|
-
const intended = intendedTypes(config.schemaDir)
|
|
91
|
+
const intended = intendedTypes(config.schemaDir, formTypesFor(config))
|
|
88
92
|
if ('problems' in intended) {
|
|
89
93
|
lastError = intended.problems.join('\n')
|
|
90
94
|
log.warn('Models were not generated: {problems}', { problems: intended.problems.length })
|
package/src/ports.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
/** Implementations of the Management API ports. */
|
|
2
|
+
|
|
3
|
+
import { resolveDeliveryApiSettings } from '@crombolo/api-delivery'
|
|
2
4
|
import type {
|
|
3
5
|
CurrentUserPort,
|
|
4
6
|
LocalizationPort,
|
|
5
7
|
ManagementApiDeps,
|
|
6
8
|
ManifestPort,
|
|
7
9
|
NodeLookup,
|
|
10
|
+
PreviewPort,
|
|
8
11
|
Principal,
|
|
9
12
|
ServerPort,
|
|
10
13
|
TemporaryFilePort,
|
|
11
14
|
UserItemPort,
|
|
12
15
|
} from '@crombolo/api-management'
|
|
13
|
-
import { AuthStore } from '@crombolo/auth'
|
|
16
|
+
import { AuthStore, expireCookie, serializeCookie } from '@crombolo/auth'
|
|
14
17
|
import {
|
|
15
18
|
ASSISTANT_MANIFEST_ID,
|
|
16
19
|
type BackOfficePaths,
|
|
@@ -28,6 +31,7 @@ import {
|
|
|
28
31
|
import {
|
|
29
32
|
ContentTypeRepository,
|
|
30
33
|
type Db,
|
|
34
|
+
DynamicRootRepository,
|
|
31
35
|
LanguageRepository,
|
|
32
36
|
RedirectRepository,
|
|
33
37
|
TagRepository,
|
|
@@ -35,6 +39,7 @@ import {
|
|
|
35
39
|
} from '@crombolo/data'
|
|
36
40
|
import type { PublishedCache } from '@crombolo/render'
|
|
37
41
|
import { createNodeLookup } from './access.ts'
|
|
42
|
+
import { createAuditHistory } from './adapters/audit.ts'
|
|
38
43
|
import { createBlueprintPort } from './adapters/blueprints.ts'
|
|
39
44
|
import { createPackagePort } from './adapters/bundles.ts'
|
|
40
45
|
import {
|
|
@@ -47,24 +52,29 @@ import { createDictionaryPort } from './adapters/dictionary.ts'
|
|
|
47
52
|
import { createDocumentPort } from './adapters/documents.ts'
|
|
48
53
|
import { createElementPort } from './adapters/elements.ts'
|
|
49
54
|
import { createFileSystemPort } from './adapters/file-system.ts'
|
|
55
|
+
import { createDiagnosticsPort } from './adapters/health.ts'
|
|
50
56
|
import { createMediaPort } from './adapters/media.ts'
|
|
51
57
|
import { createMemberGroupPort } from './adapters/member-groups.ts'
|
|
52
58
|
import { createMemberPort } from './adapters/members.ts'
|
|
53
59
|
import { createPublicAccessPort } from './adapters/public-access.ts'
|
|
54
60
|
import { createRedirectPort } from './adapters/redirects.ts'
|
|
55
61
|
import { createReferencePort } from './adapters/references.ts'
|
|
62
|
+
import { createRelationPort } from './adapters/relations.ts'
|
|
56
63
|
import {
|
|
57
64
|
createSchemaFileWriter,
|
|
58
65
|
type SchemaFiles,
|
|
59
66
|
type SchemaFileWriter,
|
|
60
67
|
} from './adapters/schema-files.ts'
|
|
68
|
+
import { createSearchIndexPort } from './adapters/search-index.ts'
|
|
61
69
|
import { createTemplateFileStore } from './adapters/template-files.ts'
|
|
62
70
|
import { createTemplateQueryPort } from './adapters/template-query.ts'
|
|
63
71
|
import { avatarUrls, createUserPorts } from './adapters/users.ts'
|
|
72
|
+
import { createWebhookPort } from './adapters/webhooks.ts'
|
|
64
73
|
import { COMPONENT_SNIPPETS } from './component-snippets.ts'
|
|
65
74
|
import { type CromboloConfig, type UserLinkSender, VERSION } from './config.ts'
|
|
66
75
|
import { type EmailPort, resolveEmailPort } from './email.ts'
|
|
67
76
|
import { createFileIntake } from './file-intake.ts'
|
|
77
|
+
import { formTypesFor } from './form-types.ts'
|
|
68
78
|
import { createLogViewerPort } from './log-viewer.ts'
|
|
69
79
|
import { logger } from './logging.ts'
|
|
70
80
|
import { MediaFileStore } from './media-files.ts'
|
|
@@ -73,6 +83,7 @@ import { createOEmbedService } from './oembed.ts'
|
|
|
73
83
|
import { createRedirectTracker } from './redirects.ts'
|
|
74
84
|
import { componentAliasesIn, type SchemaBoot } from './schema.ts'
|
|
75
85
|
import { publishSchema } from './schema-store.ts'
|
|
86
|
+
import { createWebhookService, type WebhookService } from './webhooks.ts'
|
|
76
87
|
|
|
77
88
|
/** Re-exported for tests; the canonical list lives in @crombolo/core. */
|
|
78
89
|
export const CORE_SECTIONS = CORE_SECTION_ALIASES
|
|
@@ -109,6 +120,18 @@ function createServerPort(config: CromboloConfig, canSendUserLinks: boolean): Se
|
|
|
109
120
|
}
|
|
110
121
|
}
|
|
111
122
|
|
|
123
|
+
/** Umbraco's preview cookie; with a signed-in editor it turns on draft rendering. */
|
|
124
|
+
export const PREVIEW_COOKIE = 'UMB_PREVIEW'
|
|
125
|
+
|
|
126
|
+
function createPreviewPort(config: CromboloConfig): PreviewPort {
|
|
127
|
+
return {
|
|
128
|
+
enter: () => [
|
|
129
|
+
serializeCookie({ name: PREVIEW_COOKIE, value: 'preview', secure: config.secureCookies }),
|
|
130
|
+
],
|
|
131
|
+
exit: () => [expireCookie(PREVIEW_COOKIE, config.secureCookies)],
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
112
135
|
function createManifestPort(
|
|
113
136
|
paths: BackOfficePaths,
|
|
114
137
|
extensions: ExtensionRegistry,
|
|
@@ -324,12 +347,13 @@ export function resolveUserLinkSender(
|
|
|
324
347
|
}
|
|
325
348
|
|
|
326
349
|
/** Where the backoffice writes schema files, and whether it may. */
|
|
327
|
-
function schemaFiles(config: CromboloConfig): SchemaFiles {
|
|
350
|
+
export function schemaFiles(config: CromboloConfig): SchemaFiles {
|
|
328
351
|
const store = config.schemaStore
|
|
329
352
|
return {
|
|
330
353
|
schemaDir: config.schemaDir,
|
|
331
354
|
writable: config.schemaWritable,
|
|
332
355
|
componentAliases: () => componentAliasesIn(config.componentsDir),
|
|
356
|
+
formTypes: formTypesFor(config),
|
|
333
357
|
// Only when a store is configured: a schema directory in a repository is
|
|
334
358
|
// already where the files durably live.
|
|
335
359
|
onChanged: store
|
|
@@ -357,6 +381,8 @@ export interface DepsOptions {
|
|
|
357
381
|
extensions: ExtensionRegistry
|
|
358
382
|
/** Omitted only by tests that exercise the contract without a database. */
|
|
359
383
|
db?: Db
|
|
384
|
+
/** The site's webhooks, shared with the delivery job and the event source. */
|
|
385
|
+
webhooks?: WebhookService
|
|
360
386
|
/** Required alongside `db` to serve documents, which invalidate the cache. */
|
|
361
387
|
cache?: PublishedCache
|
|
362
388
|
schema?: SchemaBoot
|
|
@@ -374,6 +400,8 @@ export interface DepsOptions {
|
|
|
374
400
|
* tells the other nodes to look; without it they keep the view they imported.
|
|
375
401
|
*/
|
|
376
402
|
onViewsChange?: (alias: string) => void | Promise<void>
|
|
403
|
+
/** Items were queued for the search index; the indexer can start on them now. */
|
|
404
|
+
onSearchQueued?: () => void
|
|
377
405
|
}
|
|
378
406
|
|
|
379
407
|
function createTemporaryFilePort(files: MediaFileStore): TemporaryFilePort {
|
|
@@ -402,6 +430,7 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
|
|
|
402
430
|
const sendUserLink = resolveUserLinkSender(options.config, email)
|
|
403
431
|
const deps: ManagementApiDeps = {
|
|
404
432
|
server: createServerPort(options.config, Boolean(sendUserLink)),
|
|
433
|
+
preview: createPreviewPort(options.config),
|
|
405
434
|
// An unconfigured assistant contributes no extension, so the backoffice has
|
|
406
435
|
// no drawer, no button and nothing to load.
|
|
407
436
|
manifests: createManifestPort(
|
|
@@ -437,6 +466,16 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
|
|
|
437
466
|
deps.stylesheets = createFileSystemPort(options.config.stylesheetsDir, { extension: '.css' })
|
|
438
467
|
deps.scripts = createFileSystemPort(options.config.scriptsDir, { extension: '.js' })
|
|
439
468
|
if (options.db) {
|
|
469
|
+
const dynamicRoots = new DynamicRootRepository(options.db)
|
|
470
|
+
deps.dynamicRoots = { resolve: (query) => dynamicRoots.resolve(query) }
|
|
471
|
+
deps.diagnostics = createDiagnosticsPort({
|
|
472
|
+
config: options.config,
|
|
473
|
+
db: options.db,
|
|
474
|
+
media: mediaFiles.store,
|
|
475
|
+
email,
|
|
476
|
+
nodeState: options.schema?.nodeState,
|
|
477
|
+
fetch: options.fetch,
|
|
478
|
+
})
|
|
440
479
|
const tags = new TagRepository(options.db)
|
|
441
480
|
deps.tags = { list: (filter) => tags.all(filter) }
|
|
442
481
|
deps.users = createUserItemPort(options.db)
|
|
@@ -489,6 +528,9 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
|
|
|
489
528
|
nodeState: options.schema?.nodeState,
|
|
490
529
|
nodeId: options.schema?.nodeId,
|
|
491
530
|
valueIntake,
|
|
531
|
+
apiMemberUserNames: resolveDeliveryApiSettings(
|
|
532
|
+
options.config.deliveryApi,
|
|
533
|
+
).memberAuthorization.clientCredentialsFlow.associatedMembers.map((m) => m.userName),
|
|
492
534
|
})
|
|
493
535
|
deps.publicAccess = createPublicAccessPort(options.db, {
|
|
494
536
|
onChange: () => options.cache?.invalidate(),
|
|
@@ -500,6 +542,17 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
|
|
|
500
542
|
onChange: () => options.cache?.invalidate(),
|
|
501
543
|
})
|
|
502
544
|
deps.references = createReferencePort(options.db)
|
|
545
|
+
deps.relations = createRelationPort(options.db)
|
|
546
|
+
deps.webhooks = createWebhookPort(
|
|
547
|
+
options.webhooks ??
|
|
548
|
+
createWebhookService({
|
|
549
|
+
db: options.db,
|
|
550
|
+
schemaDir: options.config.schemaDir,
|
|
551
|
+
filesWritable: options.config.schemaWritable,
|
|
552
|
+
secretKey: options.config.secretKey,
|
|
553
|
+
bundles: options.config.bundles,
|
|
554
|
+
}),
|
|
555
|
+
)
|
|
503
556
|
deps.redirects = createRedirectPort(options.db, options.cache, {
|
|
504
557
|
isTracking: options.config.trackRedirects,
|
|
505
558
|
onChange: () => options.cache?.invalidate(),
|
|
@@ -514,12 +567,14 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
|
|
|
514
567
|
stylesheets: deps.stylesheets,
|
|
515
568
|
scripts: deps.scripts,
|
|
516
569
|
})
|
|
570
|
+
deps.auditHistory = createAuditHistory(options.db, options.config.auditRetentionDays)
|
|
517
571
|
if (options.cache) {
|
|
518
572
|
const cache = options.cache
|
|
519
573
|
deps.templateQuery = createTemplateQueryPort({
|
|
520
574
|
cache,
|
|
521
575
|
types: new ContentTypeRepository(options.db),
|
|
522
576
|
})
|
|
577
|
+
deps.searchIndex = createSearchIndexPort(options.db, options.onSearchQueued)
|
|
523
578
|
deps.publishedCache = {
|
|
524
579
|
reload: () => cache.invalidate(),
|
|
525
580
|
rebuild: async () => {
|
package/src/schema.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { existsSync, watch } from 'node:fs'
|
|
8
8
|
import {
|
|
9
|
+
CACHE_INSTRUCTION_CHANNEL,
|
|
9
10
|
type CacheInstructionKind,
|
|
10
11
|
cacheInstructionsAfter,
|
|
11
12
|
compareStates,
|
|
@@ -39,7 +40,8 @@ import {
|
|
|
39
40
|
writeKeysBack,
|
|
40
41
|
} from '@crombolo/schema'
|
|
41
42
|
import { listComponents } from './components.ts'
|
|
42
|
-
import type
|
|
43
|
+
import { type CromboloConfig, VERSION } from './config.ts'
|
|
44
|
+
import { formTypesFor } from './form-types.ts'
|
|
43
45
|
import { logger } from './logging.ts'
|
|
44
46
|
import { materialiseSchema } from './schema-store.ts'
|
|
45
47
|
|
|
@@ -72,7 +74,7 @@ export async function checkSchemaDirectory(
|
|
|
72
74
|
node: { nodeId: string; revision: string },
|
|
73
75
|
options: { set?: Record<string, unknown> } = {},
|
|
74
76
|
): Promise<CheckReport> {
|
|
75
|
-
const loaded = loadSchemaDirectory(config.schemaDir)
|
|
77
|
+
const loaded = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
|
|
76
78
|
return runCheck(
|
|
77
79
|
db,
|
|
78
80
|
loaded,
|
|
@@ -108,6 +110,7 @@ export function syncOptionsFor(config: CromboloConfig): SyncOptions {
|
|
|
108
110
|
return {
|
|
109
111
|
mode: config.development ? 'development' : 'production',
|
|
110
112
|
componentAliases: componentAliasesIn(config.componentsDir),
|
|
113
|
+
formTypes: formTypesFor(config),
|
|
111
114
|
}
|
|
112
115
|
}
|
|
113
116
|
|
|
@@ -121,7 +124,7 @@ export async function syncSchemaDirectory(
|
|
|
121
124
|
// The files are read under the lock the backoffice's schema writes hold, so a
|
|
122
125
|
// sync never applies a snapshot that a later backoffice change has overtaken.
|
|
123
126
|
return db.locks.withLock(Locks.Schema, async () => {
|
|
124
|
-
const loaded = loadSchemaDirectory(config.schemaDir)
|
|
127
|
+
const loaded = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
|
|
125
128
|
const report = await syncSchema(
|
|
126
129
|
db,
|
|
127
130
|
loaded,
|
|
@@ -145,15 +148,15 @@ export async function bootSchema(db: Db, config: CromboloConfig): Promise<Schema
|
|
|
145
148
|
const nodeId = config.nodeId
|
|
146
149
|
if (!existsSync(config.schemaDir)) {
|
|
147
150
|
const nodeState = { version: '0', revision: config.schemaRevision }
|
|
148
|
-
await touchServer(db, { nodeId, ...nodeState, role: config.role })
|
|
151
|
+
await touchServer(db, { nodeId, ...nodeState, role: config.role, cromboloVersion: VERSION })
|
|
149
152
|
await ensureSystemMediaTypes(db, { linkFolderChildren: true })
|
|
150
153
|
await ensureSystemMemberTypes(db)
|
|
151
154
|
await ensureSystemContentTypes(db)
|
|
152
155
|
return { nodeState, nodeId, report: undefined, compatibilityMode: false }
|
|
153
156
|
}
|
|
154
|
-
const loaded = loadSchemaDirectory(config.schemaDir)
|
|
157
|
+
const loaded = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
|
|
155
158
|
const nodeState = { version: loaded.set.version, revision: config.schemaRevision }
|
|
156
|
-
await touchServer(db, { nodeId, ...nodeState, role: config.role })
|
|
159
|
+
await touchServer(db, { nodeId, ...nodeState, role: config.role, cromboloVersion: VERSION })
|
|
157
160
|
if (!config.syncSchemaAtBoot)
|
|
158
161
|
return { nodeState, nodeId, report: undefined, compatibilityMode: false }
|
|
159
162
|
|
|
@@ -246,11 +249,18 @@ export interface CachePollerOptions {
|
|
|
246
249
|
onInstruction(kind: CacheInstructionKind, payload: Record<string, unknown>): void
|
|
247
250
|
}
|
|
248
251
|
|
|
249
|
-
/**
|
|
252
|
+
/**
|
|
253
|
+
* Polls `cache_instruction` from where this node came in; own instructions are
|
|
254
|
+
* skipped. Where the database can notify, a write wakes it at once and the
|
|
255
|
+
* timer is only the fallback.
|
|
256
|
+
*/
|
|
250
257
|
export class CacheInstructionPoller {
|
|
251
258
|
#options: CachePollerOptions
|
|
252
259
|
#cursor = 0
|
|
253
260
|
#timer: ReturnType<typeof setInterval> | undefined
|
|
261
|
+
#unlisten: (() => Promise<void>) | undefined
|
|
262
|
+
#running: Promise<number> | undefined
|
|
263
|
+
#queued: Promise<number> | undefined
|
|
254
264
|
#behind = false
|
|
255
265
|
#bootLedgerId = 0
|
|
256
266
|
#readsUnsafe = false
|
|
@@ -287,11 +297,39 @@ export class CacheInstructionPoller {
|
|
|
287
297
|
if (intervalMs > 0) {
|
|
288
298
|
this.#timer = setInterval(() => void this.poll().catch(() => {}), intervalMs)
|
|
289
299
|
this.#timer.unref()
|
|
300
|
+
// Without it the timer still delivers everything, only later.
|
|
301
|
+
this.#unlisten = await this.#options.db
|
|
302
|
+
.listen?.(CACHE_INSTRUCTION_CHANNEL, () => void this.poll().catch(() => {}))
|
|
303
|
+
.catch(() => undefined)
|
|
290
304
|
}
|
|
291
305
|
}
|
|
292
306
|
|
|
293
|
-
/**
|
|
294
|
-
|
|
307
|
+
/**
|
|
308
|
+
* Returns how many foreign instructions were applied. One poll at a time: a
|
|
309
|
+
* call while one runs waits for a single follow-up, so a burst of wake-ups
|
|
310
|
+
* reads the table twice, not once each.
|
|
311
|
+
*/
|
|
312
|
+
poll(): Promise<number> {
|
|
313
|
+
if (this.#queued) return this.#queued
|
|
314
|
+
if (!this.#running) return this.#start()
|
|
315
|
+
this.#queued = this.#running
|
|
316
|
+
.catch(() => 0)
|
|
317
|
+
.then(() => {
|
|
318
|
+
this.#queued = undefined
|
|
319
|
+
return this.#start()
|
|
320
|
+
})
|
|
321
|
+
return this.#queued
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
#start(): Promise<number> {
|
|
325
|
+
const run = this.#pollOnce().finally(() => {
|
|
326
|
+
if (this.#running === run) this.#running = undefined
|
|
327
|
+
})
|
|
328
|
+
this.#running = run
|
|
329
|
+
return run
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async #pollOnce(): Promise<number> {
|
|
295
333
|
const { db, nodeId, nodeState, onInstruction } = this.#options
|
|
296
334
|
const instructions = await cacheInstructionsAfter(db, this.#cursor)
|
|
297
335
|
let applied = 0
|
|
@@ -301,7 +339,12 @@ export class CacheInstructionPoller {
|
|
|
301
339
|
onInstruction(instruction.kind, instruction.payload)
|
|
302
340
|
applied += 1
|
|
303
341
|
}
|
|
304
|
-
await touchServer(db, {
|
|
342
|
+
await touchServer(db, {
|
|
343
|
+
nodeId,
|
|
344
|
+
...nodeState,
|
|
345
|
+
role: this.#options.role,
|
|
346
|
+
cromboloVersion: VERSION,
|
|
347
|
+
})
|
|
305
348
|
const current = await currentSchemaState(db)
|
|
306
349
|
const behind = compareStates(nodeState, current) < 0
|
|
307
350
|
if (behind && !this.#behind) onInstruction('schema', { stateId: current.id })
|
|
@@ -317,6 +360,8 @@ export class CacheInstructionPoller {
|
|
|
317
360
|
stop(): void {
|
|
318
361
|
if (this.#timer) clearInterval(this.#timer)
|
|
319
362
|
this.#timer = undefined
|
|
363
|
+
void this.#unlisten?.().catch(() => {})
|
|
364
|
+
this.#unlisten = undefined
|
|
320
365
|
}
|
|
321
366
|
}
|
|
322
367
|
|
|
@@ -376,7 +421,9 @@ export async function importSchema(
|
|
|
376
421
|
|
|
377
422
|
const report = await syncSchemaDirectory(db, config, node)
|
|
378
423
|
if (report.action === 'applied') {
|
|
379
|
-
boot.nodeState.version = loadSchemaDirectory(config.schemaDir
|
|
424
|
+
boot.nodeState.version = loadSchemaDirectory(config.schemaDir, {
|
|
425
|
+
formTypes: formTypesFor(config),
|
|
426
|
+
}).set.version
|
|
380
427
|
boot.nodeState.revision = config.schemaRevision
|
|
381
428
|
}
|
|
382
429
|
return {
|