@crombolo/server 0.11.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/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,
@@ -48,12 +52,14 @@ import { createDictionaryPort } from './adapters/dictionary.ts'
48
52
  import { createDocumentPort } from './adapters/documents.ts'
49
53
  import { createElementPort } from './adapters/elements.ts'
50
54
  import { createFileSystemPort } from './adapters/file-system.ts'
55
+ import { createDiagnosticsPort } from './adapters/health.ts'
51
56
  import { createMediaPort } from './adapters/media.ts'
52
57
  import { createMemberGroupPort } from './adapters/member-groups.ts'
53
58
  import { createMemberPort } from './adapters/members.ts'
54
59
  import { createPublicAccessPort } from './adapters/public-access.ts'
55
60
  import { createRedirectPort } from './adapters/redirects.ts'
56
61
  import { createReferencePort } from './adapters/references.ts'
62
+ import { createRelationPort } from './adapters/relations.ts'
57
63
  import {
58
64
  createSchemaFileWriter,
59
65
  type SchemaFiles,
@@ -63,10 +69,12 @@ import { createSearchIndexPort } from './adapters/search-index.ts'
63
69
  import { createTemplateFileStore } from './adapters/template-files.ts'
64
70
  import { createTemplateQueryPort } from './adapters/template-query.ts'
65
71
  import { avatarUrls, createUserPorts } from './adapters/users.ts'
72
+ import { createWebhookPort } from './adapters/webhooks.ts'
66
73
  import { COMPONENT_SNIPPETS } from './component-snippets.ts'
67
74
  import { type CromboloConfig, type UserLinkSender, VERSION } from './config.ts'
68
75
  import { type EmailPort, resolveEmailPort } from './email.ts'
69
76
  import { createFileIntake } from './file-intake.ts'
77
+ import { formTypesFor } from './form-types.ts'
70
78
  import { createLogViewerPort } from './log-viewer.ts'
71
79
  import { logger } from './logging.ts'
72
80
  import { MediaFileStore } from './media-files.ts'
@@ -75,6 +83,7 @@ import { createOEmbedService } from './oembed.ts'
75
83
  import { createRedirectTracker } from './redirects.ts'
76
84
  import { componentAliasesIn, type SchemaBoot } from './schema.ts'
77
85
  import { publishSchema } from './schema-store.ts'
86
+ import { createWebhookService, type WebhookService } from './webhooks.ts'
78
87
 
79
88
  /** Re-exported for tests; the canonical list lives in @crombolo/core. */
80
89
  export const CORE_SECTIONS = CORE_SECTION_ALIASES
@@ -111,6 +120,18 @@ function createServerPort(config: CromboloConfig, canSendUserLinks: boolean): Se
111
120
  }
112
121
  }
113
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
+
114
135
  function createManifestPort(
115
136
  paths: BackOfficePaths,
116
137
  extensions: ExtensionRegistry,
@@ -326,12 +347,13 @@ export function resolveUserLinkSender(
326
347
  }
327
348
 
328
349
  /** Where the backoffice writes schema files, and whether it may. */
329
- function schemaFiles(config: CromboloConfig): SchemaFiles {
350
+ export function schemaFiles(config: CromboloConfig): SchemaFiles {
330
351
  const store = config.schemaStore
331
352
  return {
332
353
  schemaDir: config.schemaDir,
333
354
  writable: config.schemaWritable,
334
355
  componentAliases: () => componentAliasesIn(config.componentsDir),
356
+ formTypes: formTypesFor(config),
335
357
  // Only when a store is configured: a schema directory in a repository is
336
358
  // already where the files durably live.
337
359
  onChanged: store
@@ -359,6 +381,8 @@ export interface DepsOptions {
359
381
  extensions: ExtensionRegistry
360
382
  /** Omitted only by tests that exercise the contract without a database. */
361
383
  db?: Db
384
+ /** The site's webhooks, shared with the delivery job and the event source. */
385
+ webhooks?: WebhookService
362
386
  /** Required alongside `db` to serve documents, which invalidate the cache. */
363
387
  cache?: PublishedCache
364
388
  schema?: SchemaBoot
@@ -406,6 +430,7 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
406
430
  const sendUserLink = resolveUserLinkSender(options.config, email)
407
431
  const deps: ManagementApiDeps = {
408
432
  server: createServerPort(options.config, Boolean(sendUserLink)),
433
+ preview: createPreviewPort(options.config),
409
434
  // An unconfigured assistant contributes no extension, so the backoffice has
410
435
  // no drawer, no button and nothing to load.
411
436
  manifests: createManifestPort(
@@ -441,6 +466,16 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
441
466
  deps.stylesheets = createFileSystemPort(options.config.stylesheetsDir, { extension: '.css' })
442
467
  deps.scripts = createFileSystemPort(options.config.scriptsDir, { extension: '.js' })
443
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
+ })
444
479
  const tags = new TagRepository(options.db)
445
480
  deps.tags = { list: (filter) => tags.all(filter) }
446
481
  deps.users = createUserItemPort(options.db)
@@ -493,6 +528,9 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
493
528
  nodeState: options.schema?.nodeState,
494
529
  nodeId: options.schema?.nodeId,
495
530
  valueIntake,
531
+ apiMemberUserNames: resolveDeliveryApiSettings(
532
+ options.config.deliveryApi,
533
+ ).memberAuthorization.clientCredentialsFlow.associatedMembers.map((m) => m.userName),
496
534
  })
497
535
  deps.publicAccess = createPublicAccessPort(options.db, {
498
536
  onChange: () => options.cache?.invalidate(),
@@ -504,6 +542,17 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
504
542
  onChange: () => options.cache?.invalidate(),
505
543
  })
506
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
+ )
507
556
  deps.redirects = createRedirectPort(options.db, options.cache, {
508
557
  isTracking: options.config.trackRedirects,
509
558
  onChange: () => options.cache?.invalidate(),
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 { CromboloConfig } from './config.ts'
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
- /** Polls `cache_instruction` from where this node came in; own instructions are skipped. */
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
- /** Returns how many foreign instructions were applied. */
294
- async poll(): Promise<number> {
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, { nodeId, ...nodeState, role: this.#options.role })
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).set.version
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 {
package/src/secrets.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Secrets kept in the database, sealed with `CROMBOLO_SECRET_KEY`: AES-256-GCM,
3
+ * so a value cannot be read or quietly altered without the key. The key lives
4
+ * only in the environment, so a database dump alone reveals nothing.
5
+ * docs/webhooks.md.
6
+ */
7
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
8
+
9
+ export const SECRET_KEY_VARIABLE = 'CROMBOLO_SECRET_KEY'
10
+ export const MIN_SECRET_KEY_LENGTH = 32
11
+
12
+ const VERSION = 'v1'
13
+
14
+ /** The configured key, or undefined when it is missing or too short to trust. */
15
+ export function usableSecretKey(value: string | undefined): string | undefined {
16
+ return value && value.length >= MIN_SECRET_KEY_LENGTH ? value : undefined
17
+ }
18
+
19
+ const keyBytes = (key: string) => createHash('sha256').update(key, 'utf8').digest()
20
+
21
+ export function sealSecret(plain: string, key: string): string {
22
+ const iv = randomBytes(12)
23
+ const cipher = createCipheriv('aes-256-gcm', keyBytes(key), iv)
24
+ const body = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()])
25
+ return `${VERSION}:${Buffer.concat([iv, cipher.getAuthTag(), body]).toString('base64')}`
26
+ }
27
+
28
+ /** Throws when the value was sealed with another key, or has been altered. */
29
+ export function openSecret(sealed: string, key: string): string {
30
+ const [version, encoded] = sealed.split(':', 2)
31
+ if (version !== VERSION || !encoded) throw new Error('Not a sealed secret.')
32
+ const raw = Buffer.from(encoded, 'base64')
33
+ const decipher = createDecipheriv('aes-256-gcm', keyBytes(key), raw.subarray(0, 12))
34
+ decipher.setAuthTag(raw.subarray(12, 28))
35
+ return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8')
36
+ }