@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.
Files changed (44) hide show
  1. package/package.json +12 -11
  2. package/src/adapters/audit.ts +186 -0
  3. package/src/adapters/content.ts +2 -0
  4. package/src/adapters/documents.ts +28 -25
  5. package/src/adapters/elements.ts +3 -0
  6. package/src/adapters/health.ts +333 -0
  7. package/src/adapters/media.ts +3 -0
  8. package/src/adapters/members.ts +10 -2
  9. package/src/adapters/references.ts +13 -0
  10. package/src/adapters/relations.ts +48 -0
  11. package/src/adapters/schema-files.ts +41 -11
  12. package/src/adapters/schema-proposals.ts +13 -6
  13. package/src/adapters/search-index.ts +31 -0
  14. package/src/adapters/webhooks.ts +76 -0
  15. package/src/boot-failure.ts +147 -0
  16. package/src/bundle-install.ts +148 -13
  17. package/src/bundles.ts +14 -0
  18. package/src/busy.ts +13 -0
  19. package/src/config.ts +46 -2
  20. package/src/database.ts +9 -7
  21. package/src/delivery-members.ts +387 -0
  22. package/src/editor-types.ts +7 -2
  23. package/src/extensions-notice.ts +3 -2
  24. package/src/form-files.ts +38 -3
  25. package/src/form-submit.ts +62 -5
  26. package/src/form-types.ts +187 -0
  27. package/src/form-workflows.ts +66 -6
  28. package/src/forms.ts +15 -7
  29. package/src/index.ts +12 -0
  30. package/src/jobs.ts +91 -10
  31. package/src/media-files.ts +3 -1
  32. package/src/member-auth.ts +17 -1
  33. package/src/models-builder.ts +12 -8
  34. package/src/ports.ts +57 -2
  35. package/src/schema.ts +58 -11
  36. package/src/secrets.ts +36 -0
  37. package/src/server.ts +388 -29
  38. package/src/setup.ts +288 -0
  39. package/src/status.ts +5 -2
  40. package/src/transfer-dashboard.ts +219 -0
  41. package/src/upgrade-summary.ts +43 -0
  42. package/src/url-settings.ts +91 -0
  43. package/src/webhook-files.ts +138 -0
  44. package/src/webhooks.ts +544 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/server",
3
- "version": "0.10.0",
3
+ "version": "0.12.1",
4
4
  "description": "The crombolo HTTP server: routing, configuration, media stores and background jobs.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -28,16 +28,17 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@crombolo/api-management": "0.10.0",
32
- "@crombolo/assistant": "0.10.0",
33
- "@crombolo/auth": "0.10.0",
34
- "@crombolo/backoffice-host": "0.10.0",
35
- "@crombolo/contracts": "0.10.0",
36
- "@crombolo/core": "0.10.0",
37
- "@crombolo/data": "0.10.0",
38
- "@crombolo/render": "0.10.0",
39
- "@crombolo/schema": "0.10.0",
40
- "@crombolo/transfer": "0.10.0",
31
+ "@crombolo/api-delivery": "0.12.1",
32
+ "@crombolo/api-management": "0.12.1",
33
+ "@crombolo/assistant": "0.12.1",
34
+ "@crombolo/auth": "0.12.1",
35
+ "@crombolo/backoffice-host": "0.12.1",
36
+ "@crombolo/contracts": "0.12.1",
37
+ "@crombolo/core": "0.12.1",
38
+ "@crombolo/data": "0.12.1",
39
+ "@crombolo/render": "0.12.1",
40
+ "@crombolo/schema": "0.12.1",
41
+ "@crombolo/transfer": "0.12.1",
41
42
  "@logtape/logtape": "^2.3.8"
42
43
  }
43
44
  }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The audit log's ports: where the router writes, what an item's Info tab reads,
3
+ * and the Settings dashboard's endpoint. docs/audit-log.md.
4
+ */
5
+ import type { AuditHistoryPort, AuditSink } from '@crombolo/api-management'
6
+ import {
7
+ AUDIT_CATEGORIES,
8
+ type AuditCategory,
9
+ type AuditEntry,
10
+ type AuditEntryInput,
11
+ AuditLog,
12
+ type Db,
13
+ } from '@crombolo/data'
14
+ import { logger } from '../logging.ts'
15
+
16
+ const log = logger('Audit')
17
+
18
+ /** The oldest moment the log still keeps, or undefined when it keeps everything. */
19
+ export function auditRetainedSince(retentionDays: number, now = new Date()): Date | undefined {
20
+ return retentionDays > 0 ? new Date(now.getTime() - retentionDays * 86_400_000) : undefined
21
+ }
22
+
23
+ /**
24
+ * Writes an entry without ever failing what it records: the change has already
25
+ * been made by the time it is written, so a failure here is logged, not raised.
26
+ */
27
+ export async function recordAudit(
28
+ db: Db,
29
+ entries: AuditEntryInput | readonly AuditEntryInput[],
30
+ ): Promise<void> {
31
+ try {
32
+ await new AuditLog(db).record(entries)
33
+ } catch (error) {
34
+ log.error('Could not write to the audit log: {error}', { error })
35
+ }
36
+ }
37
+
38
+ export function createAuditSink(db: Db): AuditSink {
39
+ const audit = new AuditLog(db)
40
+ return {
41
+ async describe(entityType, key) {
42
+ try {
43
+ return await audit.describe(entityType, key)
44
+ } catch (error) {
45
+ log.warning('Could not name {entityType} {key} for the audit log: {error}', {
46
+ entityType,
47
+ key,
48
+ error,
49
+ })
50
+ return { entityType, entityKey: key, entityName: null }
51
+ }
52
+ },
53
+ record: (entries) =>
54
+ recordAudit(
55
+ db,
56
+ entries.map((entry) => ({ ...entry, source: 'request' as const })),
57
+ ),
58
+ }
59
+ }
60
+
61
+ export function createAuditHistory(db: Db, retentionDays: number): AuditHistoryPort {
62
+ const audit = new AuditLog(db)
63
+ let started: Date | undefined
64
+ return {
65
+ async forItem(key) {
66
+ const { items } = await audit.query({ entityKey: key }, { skip: 0, take: 10_000 })
67
+ return items
68
+ },
69
+ async startedAt() {
70
+ started ??= await audit.startedAt()
71
+ return started
72
+ },
73
+ retainedSince: () => auditRetainedSince(retentionDays),
74
+ }
75
+ }
76
+
77
+ function toResponse(entry: AuditEntry) {
78
+ return {
79
+ id: entry.id,
80
+ occurredAt: entry.occurredAt.toISOString(),
81
+ category: entry.category,
82
+ action: entry.action,
83
+ source: entry.source,
84
+ user: entry.userKey || entry.userName ? { key: entry.userKey, name: entry.userName } : null,
85
+ item: entry.entityType
86
+ ? { type: entry.entityType, key: entry.entityKey, name: entry.entityName }
87
+ : null,
88
+ ipAddress: entry.ipAddress,
89
+ forwardedFor: entry.forwardedFor,
90
+ detail: entry.detail,
91
+ }
92
+ }
93
+
94
+ function dateParam(value: string | null): Date | undefined | null {
95
+ if (!value) return undefined
96
+ const parsed = new Date(value)
97
+ return Number.isNaN(parsed.getTime()) ? null : parsed
98
+ }
99
+
100
+ const badRequest = (detail: string) =>
101
+ Response.json({ title: 'Invalid request', status: 400, detail }, { status: 400 })
102
+
103
+ /**
104
+ * The Settings dashboard's reads: `GET /` lists entries, newest first, filtered
105
+ * by user, item, category, action, date and text; `GET /users` names everyone in
106
+ * the log. Nothing here writes, so an entry cannot be changed through it.
107
+ */
108
+ export function createAuditHost(db: Db, retentionDays: number) {
109
+ const audit = new AuditLog(db)
110
+ return {
111
+ async handle(request: Request, pathname: string): Promise<Response | undefined> {
112
+ if (pathname !== '' && pathname !== '/' && pathname !== '/users') return undefined
113
+ if (request.method !== 'GET')
114
+ return new Response('Method Not Allowed', { status: 405, headers: { allow: 'GET' } })
115
+ if (pathname === '/users') return Response.json({ items: await audit.users() })
116
+
117
+ const search = new URL(request.url).searchParams
118
+ const skip = Number(search.get('skip') ?? 0)
119
+ const take = Number(search.get('take') ?? 50)
120
+ if (!Number.isInteger(skip) || skip < 0 || !Number.isInteger(take) || take < 1 || take > 500)
121
+ return badRequest('skip must be 0 or more, and take between 1 and 500.')
122
+ const category = search.get('category') ?? undefined
123
+ if (category && !AUDIT_CATEGORIES.includes(category as AuditCategory))
124
+ return badRequest(`There is no category "${category}".`)
125
+ const since = dateParam(search.get('since'))
126
+ const until = dateParam(search.get('until'))
127
+ if (since === null || until === null) return badRequest('since and until must be dates.')
128
+ const result = await audit.query(
129
+ {
130
+ userKey: search.get('user') ?? undefined,
131
+ entityKey: search.get('item') ?? undefined,
132
+ category: category as AuditCategory | undefined,
133
+ action: search.get('action') ?? undefined,
134
+ since,
135
+ until,
136
+ text: search.get('text') ?? undefined,
137
+ },
138
+ { skip, take },
139
+ )
140
+ return Response.json({
141
+ total: result.total,
142
+ retentionDays,
143
+ items: result.items.map(toResponse),
144
+ })
145
+ },
146
+ }
147
+ }
148
+
149
+ const VERB: Record<string, string> = {
150
+ POST: 'Create',
151
+ PUT: 'Save',
152
+ PATCH: 'Save',
153
+ DELETE: 'Delete',
154
+ }
155
+
156
+ /** Crombolo's own endpoints that change something, by their first segment. */
157
+ const PLUGIN_AREAS: Record<string, { category: AuditCategory; entityType: string }> = {
158
+ 'schema-import': { category: 'schema', entityType: 'Schema' },
159
+ git: { category: 'schema', entityType: 'Schema' },
160
+ seo: { category: 'settings', entityType: 'SeoSettings' },
161
+ bundles: { category: 'system', entityType: 'Bundle' },
162
+ forms: { category: 'content', entityType: 'Form' },
163
+ }
164
+
165
+ const PLUGIN_ACTIONS: Record<string, string> = {
166
+ 'schema-import': 'Import',
167
+ 'git/commit': 'Commit',
168
+ 'bundles/install': 'Install',
169
+ 'bundles/uninstall': 'Uninstall',
170
+ }
171
+
172
+ /** The entry for a write to `<plugin>/api/<subpath>`: the area it is in, and what it did there. */
173
+ export function pluginAuditPlan(
174
+ method: string,
175
+ subpath: string,
176
+ ): Pick<AuditEntryInput, 'category' | 'action' | 'entityType' | 'entityKey' | 'detail'> {
177
+ const [area = '', ...rest] = subpath.split('/')
178
+ const known = PLUGIN_AREAS[area]
179
+ return {
180
+ category: known?.category ?? 'settings',
181
+ entityType: known?.entityType ?? area,
182
+ action: PLUGIN_ACTIONS[subpath] ?? VERB[method] ?? method,
183
+ entityKey: rest.length > 0 ? rest.join('/') : null,
184
+ detail: `${method} ${subpath}`,
185
+ }
186
+ }
@@ -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
- const rows = await Promise.all(keys.map((key) => repo.nodes.byKey(key)))
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
- const rows = await db.query<{ unique_id: string }>(
500
- 'SELECT unique_id FROM node WHERE node_object_type = ? AND trashed = ?',
501
- [ObjectTypes.Media, db.dialect.boolValue(false)],
502
- )
503
- const nodes = []
504
- for (const row of rows) {
505
- const item = await media.byKey(normaliseUuid(String(row.unique_id)))
506
- if (!item) continue
507
- nodes.push({
508
- key: item.key,
509
- id: (await media.nodes.byKey(item.key))?.id ?? 0,
510
- name: item.variants[0]?.name ?? '',
511
- mediaTypeAlias: item.contentTypeAlias,
512
- properties: item.values.map((value) => ({
513
- alias: value.alias,
514
- editorAlias: value.editorAlias ?? '',
515
- culture: value.culture,
516
- segment: value.segment,
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) => ({
@@ -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 }