@crombolo/server 0.9.1 → 0.11.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/server",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
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,16 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@crombolo/api-management": "0.9.1",
32
- "@crombolo/assistant": "0.9.1",
33
- "@crombolo/auth": "0.9.1",
34
- "@crombolo/backoffice-host": "0.9.1",
35
- "@crombolo/contracts": "0.9.1",
36
- "@crombolo/core": "0.9.1",
37
- "@crombolo/data": "0.9.1",
38
- "@crombolo/render": "0.9.1",
39
- "@crombolo/schema": "0.9.1",
40
- "@crombolo/transfer": "0.9.1",
31
+ "@crombolo/api-management": "0.11.0",
32
+ "@crombolo/assistant": "0.11.0",
33
+ "@crombolo/auth": "0.11.0",
34
+ "@crombolo/backoffice-host": "0.11.0",
35
+ "@crombolo/contracts": "0.11.0",
36
+ "@crombolo/core": "0.11.0",
37
+ "@crombolo/data": "0.11.0",
38
+ "@crombolo/render": "0.11.0",
39
+ "@crombolo/schema": "0.11.0",
40
+ "@crombolo/transfer": "0.11.0",
41
41
  "@logtape/logtape": "^2.3.8"
42
42
  }
43
43
  }
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Bundles: the definitions, and the zip a download builds from one.
3
- * `docs/17-bundles.md`.
2
+ * Bundles: the definitions, and the npm package a download builds from one.
3
+ * `docs/bundles.md`.
4
4
  *
5
5
  * Nothing new is serialized here. Schema travels as the canonical TOML
6
6
  * `@crombolo/schema` already writes, content and media as the bundle
@@ -48,14 +48,27 @@ import {
48
48
  type BundleKind,
49
49
  type BundleNode,
50
50
  type BundleSection,
51
+ bundleIdFor,
51
52
  type ContentSet,
52
53
  exportBundle,
54
+ isPackageName,
55
+ packageNameFor,
53
56
  writeBundle,
57
+ writeBundlePackage,
54
58
  } from '@crombolo/transfer'
59
+ import { VERSION } from '../config.ts'
55
60
  import { dictionaryToUdt, writeDictionaryUdt } from '../dictionary-transfer.ts'
56
61
  import { readBlobs } from '../media-files.ts'
57
62
  import type { MediaStore } from '../media-store.ts'
58
- import { writeZip, type ZipEntry } from '../zip.ts'
63
+ import { writeNpmTarball } from '../tar.ts'
64
+
65
+ /** The npm package a definition is built as: what was set on it, or what its name gives. */
66
+ export function bundlePackageOf(row: Bundle): { name: string; version: string } {
67
+ return {
68
+ name: row.packageName ?? packageNameFor(row.name),
69
+ version: row.packageVersion ?? '1.0.0',
70
+ }
71
+ }
59
72
 
60
73
  /** The content kinds a bundle's picks can name. */
61
74
  const CONTENT_OBJECT_TYPES = [ObjectTypes.Document, ObjectTypes.Media, ObjectTypes.Element]
@@ -376,17 +389,19 @@ export function createPackagePort(db: Db, options: PackagePortOptions): PackageP
376
389
  },
377
390
 
378
391
  /**
379
- * The definition as one bundle, zipped.
392
+ * The definition as one bundle, packed as the npm package it is.
380
393
  *
381
394
  * Everything goes through `writeBundle` so there is a single manifest over
382
- * the whole artifact: it declares the sections, carries the hash that covers
383
- * them, and names the package — a zip of loose directories would have no
384
- * record of what it was or whether it arrived whole.
395
+ * the whole payload: it declares the sections, carries the hash that covers
396
+ * them, and names the bundle. The package around it is what gives it a
397
+ * name, a version and the core it was written for, so the download is
398
+ * something `bun add ./file.tgz` and `bun publish` take as it is.
385
399
  */
386
400
  async build(id): Promise<BuiltPackage | undefined> {
387
401
  const row = await bundles.byId(id)
388
402
  if (!row) return undefined
389
403
  const from = definition(row)
404
+ const pkg = bundlePackageOf(row)
390
405
 
391
406
  // The schema version the site is actually at, so the files a package
392
407
  // carries say where they came from rather than claiming version 0.
@@ -422,11 +437,71 @@ export function createPackagePort(db: Db, options: PackagePortOptions): PackageP
422
437
 
423
438
  const labelled: ContentSet = { ...set, manifest: { ...set.manifest, label: from.name } }
424
439
  const encoder = new TextEncoder()
425
- const entries: ZipEntry[] = writeBundle(labelled, content.blobs, sections).map((file) => ({
440
+ const files = writeBundlePackage(writeBundle(labelled, content.blobs, sections), {
441
+ name: pkg.name,
442
+ version: pkg.version,
443
+ id: bundleIdFor(pkg.name.replace(/^@[^/]+\//, '')),
444
+ label: from.name,
445
+ peerRange: `^${VERSION}`,
446
+ })
447
+ const entries = files.map((file) => ({
426
448
  path: file.path,
427
449
  bytes: file.bytes ?? encoder.encode(file.text ?? ''),
428
450
  }))
429
- return { fileName: packageFileName(from.name), bytes: writeZip(entries) }
451
+ return {
452
+ fileName: packageFileName(pkg.name, pkg.version),
453
+ contentType: 'application/gzip',
454
+ bytes: writeNpmTarball(entries),
455
+ }
430
456
  },
431
457
  }
432
458
  }
459
+
460
+ /** A created bundle's npm package as the Created view edits it. */
461
+ export interface CreatedBundlePackage {
462
+ name: string
463
+ version: string
464
+ /** Whether each was set, rather than derived from the definition's name. */
465
+ set: { name: boolean; version: boolean }
466
+ }
467
+
468
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
469
+
470
+ /** The npm package a definition builds as, or undefined when it is gone. */
471
+ export async function readCreatedBundlePackage(
472
+ db: Db,
473
+ id: string,
474
+ ): Promise<CreatedBundlePackage | undefined> {
475
+ const row = await new BundleRepository(db).byId(id)
476
+ if (!row) return undefined
477
+ return {
478
+ ...bundlePackageOf(row),
479
+ set: { name: row.packageName !== null, version: row.packageVersion !== null },
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Sets the npm package a definition builds as. An empty value goes back to its
485
+ * default; anything else must be what npm itself would accept.
486
+ */
487
+ export async function writeCreatedBundlePackage(
488
+ db: Db,
489
+ id: string,
490
+ input: { name?: unknown; version?: unknown },
491
+ ): Promise<{ ok: true; package: CreatedBundlePackage } | { ok: false; message: string }> {
492
+ const bundles = new BundleRepository(db)
493
+ if (!(await bundles.byId(id))) return { ok: false, message: 'That bundle no longer exists.' }
494
+ const text = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
495
+ const name = text(input.name)
496
+ const version = text(input.version)
497
+ if (name && !isPackageName(name))
498
+ return {
499
+ ok: false,
500
+ message:
501
+ 'A package name is lowercase letters, digits, dots, hyphens and underscores, optionally with an @scope/ in front.',
502
+ }
503
+ if (version && !SEMVER.test(version))
504
+ return { ok: false, message: 'A version is three numbers, like 1.0.0.' }
505
+ await bundles.setPackage(id, { name: name || null, version: version || null })
506
+ return { ok: true, package: (await readCreatedBundlePackage(db, id)) as CreatedBundlePackage }
507
+ }
@@ -2,7 +2,7 @@
2
2
  * The backoffice writes schema files. A document-type save lands in the
3
3
  * database as before and, when the directory is writable, in
4
4
  * `schema/content-types/<alias>.toml` — the file the next boot will sync.
5
- * docs/09-schema-as-code.md, "The backoffice writes files".
5
+ * docs/schema-as-code.md, "The backoffice writes files".
6
6
  */
7
7
 
8
8
  import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
@@ -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
+ }
package/src/backup.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Rollback is a backup (docs/10-packaging-and-upgrades.md): SQLite is copied
2
+ * Rollback is a backup (docs/packaging-and-upgrades.md): SQLite is copied
3
3
  * beside the database; Postgres runs the operator's `CROMBOLO_PG_DUMP` command
4
4
  * or refuses unless the caller vouches with `--backup-taken`.
5
5
  */