@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 +11 -11
- package/src/adapters/audit.ts +186 -0
- package/src/adapters/bundles.ts +84 -9
- package/src/adapters/schema-files.ts +1 -1
- package/src/adapters/search-index.ts +31 -0
- package/src/backup.ts +1 -1
- package/src/bundle-install.ts +417 -0
- package/src/bundle-sections.ts +424 -0
- package/src/bundle-tables.ts +161 -0
- package/src/bundles.ts +139 -38
- package/src/config.ts +24 -3
- package/src/database.ts +50 -11
- package/src/dictionary-transfer.ts +1 -1
- package/src/email.ts +1 -1
- package/src/extensions-notice.ts +2 -2
- package/src/form-files.ts +2 -2
- package/src/form-submit.ts +1 -1
- package/src/form-workflows.ts +2 -2
- package/src/forms.ts +1 -1
- package/src/index.ts +4 -0
- package/src/jobs.ts +74 -10
- package/src/marketplace.ts +117 -32
- package/src/no-nodes.ts +1 -1
- package/src/ports.ts +7 -1
- package/src/schema.ts +1 -1
- package/src/server.ts +146 -12
- package/src/status.ts +29 -0
- package/src/tar.ts +85 -0
- package/src/zip.ts +1 -1
package/src/server.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Crombolo server.
|
|
3
3
|
*
|
|
4
|
-
* Route order matters and mirrors docs/
|
|
4
|
+
* Route order matters and mirrors docs/architecture.md: static assets, the
|
|
5
5
|
* non-contract security and branding endpoints, the contract-first Management
|
|
6
6
|
* API, the SPA shells, then the front-end site.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { join } from 'node:path'
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
type ClientAddress,
|
|
12
|
+
createManagementApiRouter,
|
|
13
|
+
hasSection,
|
|
14
|
+
type Principal,
|
|
15
|
+
} from '@crombolo/api-management'
|
|
11
16
|
import {
|
|
12
17
|
AuthService,
|
|
13
18
|
AuthStore,
|
|
@@ -41,11 +46,13 @@ import {
|
|
|
41
46
|
} from '@crombolo/core'
|
|
42
47
|
import {
|
|
43
48
|
appendCacheInstruction,
|
|
49
|
+
createTablesCapability,
|
|
44
50
|
type Db,
|
|
45
51
|
FormEntryRepository,
|
|
46
52
|
FormWorkflowRepository,
|
|
47
53
|
RedirectRepository,
|
|
48
54
|
readReport,
|
|
55
|
+
TransferRunRepository,
|
|
49
56
|
type WritesPaused,
|
|
50
57
|
} from '@crombolo/data'
|
|
51
58
|
import {
|
|
@@ -63,9 +70,13 @@ import {
|
|
|
63
70
|
import type { SyncReport } from '@crombolo/schema'
|
|
64
71
|
import type { Server } from 'bun'
|
|
65
72
|
import { createNodeLookup, loadAccess } from './access.ts'
|
|
73
|
+
import { createAuditHost, createAuditSink, pluginAuditPlan, recordAudit } from './adapters/audit.ts'
|
|
74
|
+
import { readCreatedBundlePackage, writeCreatedBundlePackage } from './adapters/bundles.ts'
|
|
66
75
|
import { createPublishedContentSource, loadDraftNode } from './adapters/documents.ts'
|
|
67
76
|
import { createSchemaProposals } from './adapters/schema-proposals.ts'
|
|
68
|
-
import {
|
|
77
|
+
import { installBundleContent } from './bundle-install.ts'
|
|
78
|
+
import { bundleChains, bundleTablesReport } from './bundle-tables.ts'
|
|
79
|
+
import { createBundleRedirects, createServerBundles, validateServerBundles } from './bundles.ts'
|
|
69
80
|
import { type CromboloConfig, loadConfig, type ServerRole, VERSION } from './config.ts'
|
|
70
81
|
import { attachDatabase, bootstrapDatabase } from './database.ts'
|
|
71
82
|
import { createDiagnostics } from './diagnostics.ts'
|
|
@@ -198,7 +209,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
198
209
|
}
|
|
199
210
|
}
|
|
200
211
|
const domainLog = logger('domains')
|
|
201
|
-
// Extensions are the site's own npm dependencies (`docs/
|
|
212
|
+
// Extensions are the site's own npm dependencies (`docs/bundles.md`).
|
|
202
213
|
// Uncached in development so an author editing one sees a reload pick it up.
|
|
203
214
|
const extensions = createExtensionRegistry(config.siteDir, { cache: !config.development })
|
|
204
215
|
const marketplace = createMarketplace({
|
|
@@ -207,6 +218,26 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
207
218
|
marketplaceUrl: config.marketplaceUrl,
|
|
208
219
|
extensions,
|
|
209
220
|
siteDir: config.siteDir,
|
|
221
|
+
wired: () => bundles?.mounted.map((bundle) => bundle.id) ?? [],
|
|
222
|
+
contentApplied: async (packageName) =>
|
|
223
|
+
(await new TransferRunRepository(db).appliedForPackage(packageName))?.packageVersion ?? null,
|
|
224
|
+
applyContentOnInstall: config.applyBundleContentOnInstall,
|
|
225
|
+
applyContent: async (packageName) => {
|
|
226
|
+
const result = await installBundleContent(db, config, { bundle: packageName })
|
|
227
|
+
if (!result.runId) {
|
|
228
|
+
const reasons = [
|
|
229
|
+
...result.problems,
|
|
230
|
+
...result.check.outstanding.map((finding) => finding.message),
|
|
231
|
+
]
|
|
232
|
+
return {
|
|
233
|
+
applied: false,
|
|
234
|
+
message: `Its content was not applied: ${reasons.join('; ') || 'the check refused it'}. Resolve that, then run \`crombolo bundle install ${packageName}\`.`,
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// The content landed under a cache this process had already constructed.
|
|
238
|
+
cache.invalidate()
|
|
239
|
+
return { applied: true, runId: result.runId, message: 'Its content was applied.' }
|
|
240
|
+
},
|
|
210
241
|
})
|
|
211
242
|
const paths = createBackOfficePaths({ backOfficePath: config.backOfficePath })
|
|
212
243
|
// Two questions, not one: what this process answers, and what it is
|
|
@@ -214,8 +245,17 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
214
245
|
// `all` and `web` serve the public site.
|
|
215
246
|
const servesBackOffice = config.role !== 'web'
|
|
216
247
|
const owns = config.role !== 'web'
|
|
248
|
+
// Bundles are checked before the database is touched, because a bundle that
|
|
249
|
+
// owns tables is migrated at boot, and a bad declaration must not get that far.
|
|
250
|
+
const bundlePackages = servesBackOffice ? extensions.bundles().bundles : []
|
|
251
|
+
if (servesBackOffice) {
|
|
252
|
+
const problems = validateServerBundles(config.bundles, bundlePackages, VERSION)
|
|
253
|
+
if (problems.length > 0)
|
|
254
|
+
throw new Error(`The configured bundles cannot be mounted:\n- ${problems.join('\n- ')}`)
|
|
255
|
+
}
|
|
256
|
+
const chains = servesBackOffice ? bundleChains(config.bundles, bundlePackages) : []
|
|
217
257
|
const { db, seededAdminPassword } = owns
|
|
218
|
-
? await bootstrapDatabase(config)
|
|
258
|
+
? await bootstrapDatabase(config, chains)
|
|
219
259
|
: await attachDatabase(config)
|
|
220
260
|
// A configured schema store is copied down before anything reads `schema/`, and
|
|
221
261
|
// becomes the schema directory for this boot. Everything after here — loading,
|
|
@@ -236,10 +276,30 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
236
276
|
const auth = new AuthService(new AuthStore(db))
|
|
237
277
|
const cookies = { siteName: config.siteName, securePrefix: config.secureCookies }
|
|
238
278
|
const names = cookieNames(cookies)
|
|
279
|
+
// The socket's peer for each request in flight, which only `Bun.serve` knows;
|
|
280
|
+
// a request that came some other way (a test, the assistant) has none.
|
|
281
|
+
const peers = new WeakMap<Request, string>()
|
|
282
|
+
const clientAddress = (request: Request): ClientAddress => ({
|
|
283
|
+
ipAddress: peers.get(request) ?? null,
|
|
284
|
+
forwardedFor: request.headers.get('x-forwarded-for'),
|
|
285
|
+
})
|
|
239
286
|
const authRoutes = createAuthRoutes({
|
|
240
287
|
backOfficePath: paths.backOfficePath,
|
|
241
288
|
service: auth,
|
|
242
289
|
cookies,
|
|
290
|
+
onAudit: (event, request) =>
|
|
291
|
+
recordAudit(db, {
|
|
292
|
+
category: 'sign-in',
|
|
293
|
+
action: event.action,
|
|
294
|
+
source: 'request',
|
|
295
|
+
userKey: event.user?.key ?? null,
|
|
296
|
+
userName: event.user?.userName ?? event.login ?? null,
|
|
297
|
+
entityType: event.user ? 'User' : null,
|
|
298
|
+
entityKey: event.user?.key ?? null,
|
|
299
|
+
entityName: event.user?.userName ?? null,
|
|
300
|
+
detail: event.detail ?? null,
|
|
301
|
+
...clientAddress(request),
|
|
302
|
+
}),
|
|
243
303
|
})
|
|
244
304
|
|
|
245
305
|
/**
|
|
@@ -351,6 +411,12 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
351
411
|
const bundles = servesBackOffice
|
|
352
412
|
? createServerBundles({
|
|
353
413
|
bundles: config.bundles,
|
|
414
|
+
packages: bundlePackages,
|
|
415
|
+
coreVersion: VERSION,
|
|
416
|
+
tables: (bundle) => {
|
|
417
|
+
const chain = chains.find((candidate) => candidate.id === bundle.id)
|
|
418
|
+
return chain ? createTablesCapability(db, chain.compiled.schema) : undefined
|
|
419
|
+
},
|
|
354
420
|
pluginApiPath: `${paths.pluginPath}/api`,
|
|
355
421
|
host: {
|
|
356
422
|
redirects: createBundleRedirects({ db, cache }),
|
|
@@ -495,6 +561,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
495
561
|
schema,
|
|
496
562
|
mediaFiles,
|
|
497
563
|
email,
|
|
564
|
+
onSearchQueued: () => jobs.kickSearchIndexing(),
|
|
498
565
|
// This node looks again at once; the others are told to. The write has
|
|
499
566
|
// already landed wherever `componentsDir` points, so there is nothing to
|
|
500
567
|
// distribute — only the fact that it happened.
|
|
@@ -517,11 +584,15 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
517
584
|
authenticate,
|
|
518
585
|
nodes: createNodeLookup(db),
|
|
519
586
|
allowLocalLogin: config.allowLocalLogin,
|
|
587
|
+
audit: createAuditSink(db),
|
|
588
|
+
clientAddress,
|
|
520
589
|
onNotImplemented: config.development
|
|
521
590
|
? (operation, request) => notImplemented.record(operation, request)
|
|
522
591
|
: undefined,
|
|
523
592
|
onServerEvent: (event) => {
|
|
524
593
|
events.broadcast('notify', [event])
|
|
594
|
+
// Saves, deletes and moves all announce themselves; whatever they queued gets indexed now.
|
|
595
|
+
jobs.kickSearchIndexing()
|
|
525
596
|
// An open preview of the document reloads when it changes.
|
|
526
597
|
if (event.eventSource === 'Umbraco:CMS:Document' && event.eventType !== 'Deleted')
|
|
527
598
|
previewHub.broadcast('refreshed', [event.key])
|
|
@@ -566,6 +637,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
566
637
|
nodeState: schema.nodeState,
|
|
567
638
|
nodeId: schema.nodeId,
|
|
568
639
|
versionCleanup: config.versionCleanup,
|
|
640
|
+
auditRetentionDays: config.auditRetentionDays,
|
|
569
641
|
mediaFiles,
|
|
570
642
|
// The workflow queue drains here rather than in the request: a submission is
|
|
571
643
|
// stored first, and an e-mail that cannot be sent yet must not cost it.
|
|
@@ -592,7 +664,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
592
664
|
nodeId: schema.nodeId,
|
|
593
665
|
role: config.role,
|
|
594
666
|
})
|
|
595
|
-
// App_Plugins is gone (`docs/
|
|
667
|
+
// App_Plugins is gone (`docs/bundles.md`). Saying so beats a site whose
|
|
596
668
|
// extensions silently stopped loading after an upgrade; remove at 1.0.
|
|
597
669
|
warnAboutAppPlugins(config.siteDir, log)
|
|
598
670
|
noticeAboutEmail(emailStatus, logger('email'), { development: config.development })
|
|
@@ -640,6 +712,9 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
640
712
|
|
|
641
713
|
// Imported only when configured, like the assistant: an unconfigured site has no
|
|
642
714
|
// git route and no credentials anywhere near it.
|
|
715
|
+
const auditPath = `${paths.pluginPath}/api/audit`
|
|
716
|
+
const auditHost = createAuditHost(db, config.auditRetentionDays)
|
|
717
|
+
|
|
643
718
|
const gitPath = `${paths.pluginPath}/api/git`
|
|
644
719
|
const git =
|
|
645
720
|
config.git && servesBackOffice
|
|
@@ -674,7 +749,10 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
674
749
|
// there — they are not routes at all, and 404 like any other unknown path.
|
|
675
750
|
if (servesBackOffice) {
|
|
676
751
|
const response = await serveBackOffice(request, url)
|
|
677
|
-
if (response)
|
|
752
|
+
if (response) {
|
|
753
|
+
if (response.ok) await auditPluginWrite(request, pathname)
|
|
754
|
+
return response
|
|
755
|
+
}
|
|
678
756
|
}
|
|
679
757
|
|
|
680
758
|
if (pathname.startsWith(`${MEMBER_ROUTE_PREFIX}/`)) {
|
|
@@ -709,6 +787,27 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
709
787
|
return renderSite(request, pathname, url.host)
|
|
710
788
|
}
|
|
711
789
|
|
|
790
|
+
/**
|
|
791
|
+
* The audit entry for a successful write to one of Crombolo's own endpoints,
|
|
792
|
+
* which the contract router does not see. The assistant's are left out: what it
|
|
793
|
+
* changes goes through the router, and is recorded there. docs/audit-log.md.
|
|
794
|
+
*/
|
|
795
|
+
async function auditPluginWrite(request: Request, pathname: string): Promise<void> {
|
|
796
|
+
const prefix = `${paths.pluginPath}/api/`
|
|
797
|
+
if (request.method === 'GET' || request.method === 'HEAD') return
|
|
798
|
+
if (!pathname.startsWith(prefix) || pathname.startsWith(`${assistantPath}/`)) return
|
|
799
|
+
const principal = await authenticate(request)
|
|
800
|
+
if (!principal) return
|
|
801
|
+
const plan = pluginAuditPlan(request.method, pathname.slice(prefix.length))
|
|
802
|
+
await recordAudit(db, {
|
|
803
|
+
...plan,
|
|
804
|
+
source: 'request',
|
|
805
|
+
userKey: principal.id,
|
|
806
|
+
userName: principal.name,
|
|
807
|
+
...clientAddress(request),
|
|
808
|
+
})
|
|
809
|
+
}
|
|
810
|
+
|
|
712
811
|
/**
|
|
713
812
|
* The backoffice, the Management API and everything that serves an editor.
|
|
714
813
|
*
|
|
@@ -847,24 +946,44 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
847
946
|
return Response.json({
|
|
848
947
|
url: marketplace.url(),
|
|
849
948
|
keyword: config.packageKeyword,
|
|
949
|
+
appliesContent: config.applyBundleContentOnInstall,
|
|
850
950
|
items: await marketplace.search(url.searchParams.get('q') ?? undefined),
|
|
851
951
|
})
|
|
852
952
|
}
|
|
853
953
|
|
|
954
|
+
// A created bundle's npm name and version: not in Umbraco's definition, so
|
|
955
|
+
// not in the contract, and set from the Created view beside it.
|
|
956
|
+
const created = /^created\/([0-9a-f-]{36})\/package$/i.exec(action)
|
|
957
|
+
if (created) {
|
|
958
|
+
const id = created[1] as string
|
|
959
|
+
if (request.method === 'GET') {
|
|
960
|
+
const found = await readCreatedBundlePackage(db, id)
|
|
961
|
+
return found ? Response.json(found) : new Response('Not Found', { status: 404 })
|
|
962
|
+
}
|
|
963
|
+
if (request.method === 'PUT') {
|
|
964
|
+
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>
|
|
965
|
+
const outcome = await writeCreatedBundlePackage(db, id, body)
|
|
966
|
+
return Response.json(outcome, { status: outcome.ok ? 200 : 400 })
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
854
970
|
if (action === 'installed' && request.method === 'GET') {
|
|
855
|
-
return Response.json({ items: marketplace.installed() })
|
|
971
|
+
return Response.json({ items: await marketplace.installed() })
|
|
856
972
|
}
|
|
857
973
|
|
|
858
974
|
if ((action === 'install' || action === 'uninstall') && request.method === 'POST') {
|
|
859
975
|
const body = (await request.json().catch(() => ({}))) as {
|
|
860
976
|
name?: unknown
|
|
861
977
|
version?: unknown
|
|
978
|
+
applyContent?: unknown
|
|
862
979
|
}
|
|
863
980
|
const name = typeof body.name === 'string' ? body.name : ''
|
|
864
981
|
const version = typeof body.version === 'string' ? body.version : undefined
|
|
865
982
|
const outcome =
|
|
866
983
|
action === 'install'
|
|
867
|
-
? await marketplace.install(name, version
|
|
984
|
+
? await marketplace.install(name, version, {
|
|
985
|
+
applyContent: body.applyContent !== false,
|
|
986
|
+
})
|
|
868
987
|
: await marketplace.uninstall(name)
|
|
869
988
|
log.info('{user} {action} the package {name}: {outcome}', {
|
|
870
989
|
user: principal.email,
|
|
@@ -901,7 +1020,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
901
1020
|
/**
|
|
902
1021
|
* Non-contract: forms and their entries. There is no form operation in the
|
|
903
1022
|
* vendored contract — Umbraco Forms ships its own API and its own client,
|
|
904
|
-
* and neither is here — so this is ours (`docs/
|
|
1023
|
+
* and neither is here — so this is ours (`docs/forms.md`).
|
|
905
1024
|
*
|
|
906
1025
|
* Definitions are read from `schema/` per request rather than cached: the
|
|
907
1026
|
* files are the truth, the backoffice may have just rewritten one, and a
|
|
@@ -924,7 +1043,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
924
1043
|
const byKey = formsByKey(definitions)
|
|
925
1044
|
// Submissions are personal data, so reading them asks for more than a
|
|
926
1045
|
// session. Four verbs, because designing a form and reading what people
|
|
927
|
-
// sent it are different jobs (`docs/
|
|
1046
|
+
// sent it are different jobs (`docs/forms.md`).
|
|
928
1047
|
const may = (verb: string) => principal.isAdmin || principal.permissions.includes(verb)
|
|
929
1048
|
const formFiles = {
|
|
930
1049
|
schemaDir: config.schemaDir,
|
|
@@ -1106,6 +1225,16 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1106
1225
|
// and no explanation. The findings name documents and the properties that are
|
|
1107
1226
|
// unfilled: that is the Settings dashboard's material, and the dashboard's own
|
|
1108
1227
|
// manifest is already `Umb.Condition.SectionAlias` on Settings.
|
|
1228
|
+
// Non-contract, Settings: the audit log dashboard's reads. Settings, as the
|
|
1229
|
+
// dashboard is: it names who signed in from where, and every change made.
|
|
1230
|
+
if (pathname === auditPath || pathname.startsWith(`${auditPath}/`)) {
|
|
1231
|
+
const principal = await authenticate(request)
|
|
1232
|
+
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
1233
|
+
if (!hasSection(principal, 'settings')) return new Response('Forbidden', { status: 403 })
|
|
1234
|
+
const response = await auditHost.handle(request, pathname.slice(auditPath.length))
|
|
1235
|
+
if (response) return response
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1109
1238
|
if (pathname === `${paths.pluginPath}/api/change-report`) {
|
|
1110
1239
|
const principal = await authenticate(request)
|
|
1111
1240
|
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
@@ -1122,7 +1251,10 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1122
1251
|
source[f.kind] = (source[f.kind] ?? 0) + 1
|
|
1123
1252
|
bySource[f.source] = source
|
|
1124
1253
|
}
|
|
1125
|
-
|
|
1254
|
+
// A bundle's tables are part of what an upgrade changes, so the dashboard
|
|
1255
|
+
// that shows an upgrade's findings shows their steps too.
|
|
1256
|
+
const bundles = await bundleTablesReport(db, chains)
|
|
1257
|
+
return Response.json({ health: health(), counts, bySource, findings, bundles })
|
|
1126
1258
|
}
|
|
1127
1259
|
|
|
1128
1260
|
const staticMatch = resolveStaticFile(paths, pathname, {
|
|
@@ -1542,6 +1674,8 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1542
1674
|
port: config.port,
|
|
1543
1675
|
maxRequestBodySize: config.maxRequestBodyBytes,
|
|
1544
1676
|
fetch: diagnostics.wrap(async (request, bunServer: Server<HubSocketData>) => {
|
|
1677
|
+
const peer = bunServer.requestIP(request)?.address
|
|
1678
|
+
if (peer) peers.set(request, peer)
|
|
1545
1679
|
const pathname = new URL(request.url).pathname
|
|
1546
1680
|
if (pathname === hubPath || pathname === previewHubPath) {
|
|
1547
1681
|
const principal = await authenticate(request)
|
package/src/status.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
TransferRunRepository,
|
|
28
28
|
} from '@crombolo/data'
|
|
29
29
|
import { loadSchemaDirectory } from '@crombolo/schema'
|
|
30
|
+
import { BundleConfigurationError, bundleChainsFor, pendingBundleSteps } from './bundle-tables.ts'
|
|
30
31
|
import { type CromboloConfig, describeDatabase, VERSION } from './config.ts'
|
|
31
32
|
import { type EmailAvailability, emailAvailability, resolveEmailPort } from './email.ts'
|
|
32
33
|
import { assertViewRuntime } from './view-runtime.ts'
|
|
@@ -60,6 +61,12 @@ export interface SiteStatus {
|
|
|
60
61
|
pending: boolean
|
|
61
62
|
}
|
|
62
63
|
framework: { pending: string[] }
|
|
64
|
+
/**
|
|
65
|
+
* Each wired bundle that owns tables, by id: its chain's state and the steps
|
|
66
|
+
* still to run. Only a node that mounts bundles applies them, so this is what
|
|
67
|
+
* such a node would refuse to boot over in production.
|
|
68
|
+
*/
|
|
69
|
+
bundles: Record<string, { name: string; state: string | null; pending: string[] }>
|
|
63
70
|
content: { documents: number; media: number; elements: number; lastImport?: string }
|
|
64
71
|
/**
|
|
65
72
|
* Open findings by kind. `auto` ones are reported, not counted against the
|
|
@@ -140,6 +147,7 @@ export async function siteStatus(config: CromboloConfig): Promise<SiteStatus> {
|
|
|
140
147
|
pending: false,
|
|
141
148
|
},
|
|
142
149
|
framework: { pending: [] },
|
|
150
|
+
bundles: {},
|
|
143
151
|
content: { documents: 0, media: 0, elements: 0 },
|
|
144
152
|
findings: { blocking: 0, person: 0, auto: 0, resolved: 0 },
|
|
145
153
|
versions: { crombolo: VERSION, backoffice: backofficeVersion(), bun: Bun.version },
|
|
@@ -189,6 +197,27 @@ export async function siteStatus(config: CromboloConfig): Promise<SiteStatus> {
|
|
|
189
197
|
`${status.framework.pending.length} framework migration(s) pending: run \`crombolo upgrade\``,
|
|
190
198
|
)
|
|
191
199
|
|
|
200
|
+
try {
|
|
201
|
+
const chains = bundleChainsFor(config)
|
|
202
|
+
for (const chain of chains)
|
|
203
|
+
status.bundles[chain.id] = { name: chain.name, state: null, pending: [] }
|
|
204
|
+
for (const bundle of await pendingBundleSteps(db, chains)) {
|
|
205
|
+
status.bundles[bundle.id] = {
|
|
206
|
+
name: bundle.name,
|
|
207
|
+
state: bundle.state,
|
|
208
|
+
pending: bundle.pending.map((step) => step.name),
|
|
209
|
+
}
|
|
210
|
+
if (bundle.pending.length > 0 && !config.development)
|
|
211
|
+
status.problems.push(
|
|
212
|
+
`the bundle "${bundle.id}" has ${bundle.pending.length} step(s) pending: run \`crombolo upgrade\``,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (error instanceof BundleConfigurationError)
|
|
217
|
+
for (const problem of error.problems) status.problems.push(problem)
|
|
218
|
+
else status.problems.push((error as Error).message)
|
|
219
|
+
}
|
|
220
|
+
|
|
192
221
|
const state = await currentSchemaState(db)
|
|
193
222
|
status.schema.database = { version: state.version, revision: state.revision }
|
|
194
223
|
if (loaded) {
|
package/src/tar.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal npm package tarball writer: a gzipped ustar archive with every entry
|
|
3
|
+
* under `package/`, which is the layout `npm pack` produces and `bun add
|
|
4
|
+
* ./file.tgz` and `bun publish` read (`docs/bundles.md`).
|
|
5
|
+
*
|
|
6
|
+
* No dependency: the format is 512-byte headers and padded file bodies, and
|
|
7
|
+
* `Bun.gzipSync` does the compression. Timestamps are fixed, as npm fixes them,
|
|
8
|
+
* so the same selection packs to the same bytes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface TarEntry {
|
|
12
|
+
/** The path inside the package, `/`-separated and never absolute. */
|
|
13
|
+
path: string
|
|
14
|
+
bytes: Uint8Array
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const BLOCK = 512
|
|
18
|
+
|
|
19
|
+
/** 1985-10-26T08:15:00Z, the mtime npm writes into every packed file. */
|
|
20
|
+
const NPM_MTIME = 499162500
|
|
21
|
+
|
|
22
|
+
const encoder = new TextEncoder()
|
|
23
|
+
|
|
24
|
+
/** An octal field, NUL-terminated and zero-padded to its width. */
|
|
25
|
+
function octal(value: number, width: number): Uint8Array {
|
|
26
|
+
return encoder.encode(`${value.toString(8).padStart(width - 1, '0')}\0`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Splits a path across ustar's `prefix` (155 bytes) and `name` (100 bytes) at a
|
|
31
|
+
* slash, which is how a path longer than 100 bytes is written without an
|
|
32
|
+
* extension header.
|
|
33
|
+
*/
|
|
34
|
+
function splitPath(path: string): { name: string; prefix: string } {
|
|
35
|
+
if (encoder.encode(path).length <= 100) return { name: path, prefix: '' }
|
|
36
|
+
for (let i = path.lastIndexOf('/'); i > 0; i = path.lastIndexOf('/', i - 1)) {
|
|
37
|
+
const prefix = path.slice(0, i)
|
|
38
|
+
const name = path.slice(i + 1)
|
|
39
|
+
if (encoder.encode(prefix).length <= 155 && encoder.encode(name).length <= 100)
|
|
40
|
+
return { name, prefix }
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`${path} is too long to pack`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function header(path: string, size: number): Uint8Array {
|
|
46
|
+
const block = new Uint8Array(BLOCK)
|
|
47
|
+
const put = (bytes: Uint8Array, offset: number) => block.set(bytes, offset)
|
|
48
|
+
const { name, prefix } = splitPath(path)
|
|
49
|
+
put(encoder.encode(name), 0)
|
|
50
|
+
put(octal(0o644, 8), 100)
|
|
51
|
+
put(octal(0, 8), 108)
|
|
52
|
+
put(octal(0, 8), 116)
|
|
53
|
+
put(octal(size, 12), 124)
|
|
54
|
+
put(octal(NPM_MTIME, 12), 136)
|
|
55
|
+
// The checksum is computed with its own field read as spaces.
|
|
56
|
+
put(encoder.encode(' '), 148)
|
|
57
|
+
put(encoder.encode('0'), 156)
|
|
58
|
+
put(encoder.encode('ustar\0'), 257)
|
|
59
|
+
put(encoder.encode('00'), 263)
|
|
60
|
+
put(encoder.encode(prefix), 345)
|
|
61
|
+
const sum = block.reduce((total, byte) => total + byte, 0)
|
|
62
|
+
put(encoder.encode(`${sum.toString(8).padStart(6, '0')}\0 `), 148)
|
|
63
|
+
return block
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The entries as an npm package tarball, each under `package/`. */
|
|
67
|
+
export function writeNpmTarball(entries: readonly TarEntry[]): Uint8Array<ArrayBuffer> {
|
|
68
|
+
const parts: Uint8Array[] = []
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
if (entry.path.startsWith('/') || entry.path.split('/').includes('..'))
|
|
71
|
+
throw new Error(`${entry.path} is not a path inside the package`)
|
|
72
|
+
parts.push(header(`package/${entry.path}`, entry.bytes.length), entry.bytes)
|
|
73
|
+
const pad = (BLOCK - (entry.bytes.length % BLOCK)) % BLOCK
|
|
74
|
+
if (pad > 0) parts.push(new Uint8Array(pad))
|
|
75
|
+
}
|
|
76
|
+
// Two empty blocks end the archive.
|
|
77
|
+
parts.push(new Uint8Array(BLOCK * 2))
|
|
78
|
+
const tar = new Uint8Array(parts.reduce((total, part) => total + part.length, 0))
|
|
79
|
+
let offset = 0
|
|
80
|
+
for (const part of parts) {
|
|
81
|
+
tar.set(part, offset)
|
|
82
|
+
offset += part.length
|
|
83
|
+
}
|
|
84
|
+
return Bun.gzipSync(tar) as Uint8Array<ArrayBuffer>
|
|
85
|
+
}
|
package/src/zip.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A minimal zip writer, because a created package is a zip and nothing else
|
|
3
|
-
* here needs one (`docs/
|
|
3
|
+
* here needs one (`docs/bundles.md`).
|
|
4
4
|
*
|
|
5
5
|
* No dependency: `node:zlib` provides `deflateRawSync` and `crc32`, which is
|
|
6
6
|
* everything the format needs. Entries are written in the order given, each
|