@crombolo/server 0.12.1 → 0.13.2
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 -12
- package/src/adapters/bundles.ts +2 -1
- package/src/approvals-api.ts +189 -0
- package/src/approvals.ts +838 -0
- package/src/config.ts +22 -1
- package/src/marketplace.ts +37 -0
- package/src/ports.ts +15 -0
- package/src/server.ts +74 -31
package/src/config.ts
CHANGED
|
@@ -272,6 +272,16 @@ export interface CromboloConfig {
|
|
|
272
272
|
* deploy turns it off, and an install can decline it for itself.
|
|
273
273
|
*/
|
|
274
274
|
applyBundleContentOnInstall: boolean
|
|
275
|
+
/**
|
|
276
|
+
* Whether the Packages section may install and remove packages on the running
|
|
277
|
+
* site (`bun add` on the node that served the request). Off, the Marketplace
|
|
278
|
+
* still lists packages and says to add them to `package.json` and deploy:
|
|
279
|
+
* what a site of several nodes, or of containers that start afresh, needs —
|
|
280
|
+
* an install there reaches one node and is lost at its next restart. On unless
|
|
281
|
+
* set, or unless a schema store is configured, which says the same thing
|
|
282
|
+
* about the nodes' disks. `CROMBOLO_RUNTIME_INSTALLS`.
|
|
283
|
+
*/
|
|
284
|
+
runtimeInstalls: boolean
|
|
275
285
|
/**
|
|
276
286
|
* Where the Packages section's Marketplace sends someone browsing for
|
|
277
287
|
* extensions. Never framed — npm refuses to be framed — so the native view
|
|
@@ -495,6 +505,8 @@ export function loadConfig(
|
|
|
495
505
|
redirects: [],
|
|
496
506
|
trackRedirects: Bun.env.CROMBOLO_TRACK_REDIRECTS !== 'false',
|
|
497
507
|
applyBundleContentOnInstall: Bun.env.CROMBOLO_APPLY_BUNDLE_CONTENT !== 'false',
|
|
508
|
+
// Resolved again below, once it is known whether a store was configured.
|
|
509
|
+
runtimeInstalls: Bun.env.CROMBOLO_RUNTIME_INSTALLS !== 'false',
|
|
498
510
|
seo: stripUndefined({
|
|
499
511
|
sitemap: envFlag(Bun.env.CROMBOLO_SITEMAP),
|
|
500
512
|
indexable: envFlag(Bun.env.CROMBOLO_INDEXABLE),
|
|
@@ -533,6 +545,15 @@ export function loadConfig(
|
|
|
533
545
|
) {
|
|
534
546
|
merged.schemaWritable = true
|
|
535
547
|
}
|
|
548
|
+
// Likewise a store says the nodes' disks do not last, so an install on one of
|
|
549
|
+
// them would not either. An explicit setting still wins.
|
|
550
|
+
if (
|
|
551
|
+
merged.schemaStore &&
|
|
552
|
+
Bun.env.CROMBOLO_RUNTIME_INSTALLS === undefined &&
|
|
553
|
+
overrides.runtimeInstalls === undefined
|
|
554
|
+
) {
|
|
555
|
+
merged.runtimeInstalls = false
|
|
556
|
+
}
|
|
536
557
|
// Relative paths are relative to the site, not to wherever the process started.
|
|
537
558
|
merged.siteDir = resolve(cwd, merged.siteDir)
|
|
538
559
|
// Against the site rather than the working directory: it has to sit where the
|
|
@@ -597,4 +618,4 @@ export function describeDatabase(
|
|
|
597
618
|
}
|
|
598
619
|
}
|
|
599
620
|
|
|
600
|
-
export const VERSION = '0.
|
|
621
|
+
export const VERSION = '0.13.2'
|
package/src/marketplace.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* browser cannot reach npm, and the backoffice keeps making same-origin
|
|
18
18
|
* requests only.
|
|
19
19
|
*/
|
|
20
|
+
import { readFileSync } from 'node:fs'
|
|
20
21
|
import { join } from 'node:path'
|
|
21
22
|
import {
|
|
22
23
|
type BundlePackage,
|
|
@@ -57,6 +58,10 @@ export interface InstalledListing {
|
|
|
57
58
|
wired: boolean
|
|
58
59
|
/** The version whose content was applied here, or null when none has been. */
|
|
59
60
|
contentApplied: string | null
|
|
61
|
+
/** What the package says it is, from its `package.json`; empty when it says nothing. */
|
|
62
|
+
description: string
|
|
63
|
+
/** The crombolo releases it is written for. */
|
|
64
|
+
peerRange: string
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
/** What became of the content half of an install. */
|
|
@@ -88,6 +93,11 @@ export interface MarketplaceOptions {
|
|
|
88
93
|
applyContent?: (packageName: string) => Promise<ContentOutcome>
|
|
89
94
|
/** The site's `applyBundleContentOnInstall`. */
|
|
90
95
|
applyContentOnInstall?: boolean
|
|
96
|
+
/**
|
|
97
|
+
* False refuses installing and removing here: the dependency belongs in
|
|
98
|
+
* `package.json` and a deploy, so every node has it and it outlives a restart.
|
|
99
|
+
*/
|
|
100
|
+
installs?: boolean
|
|
91
101
|
}
|
|
92
102
|
|
|
93
103
|
export interface InstallOutcome {
|
|
@@ -303,11 +313,14 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
|
|
|
303
313
|
carries: bundle.carries,
|
|
304
314
|
wired: wired.has(bundle.id),
|
|
305
315
|
contentApplied: (await options.contentApplied?.(bundle.packageName)) ?? null,
|
|
316
|
+
description: descriptionOf(bundle.directory),
|
|
317
|
+
peerRange: bundle.peerRange,
|
|
306
318
|
})),
|
|
307
319
|
)
|
|
308
320
|
},
|
|
309
321
|
|
|
310
322
|
async install(name, version, request = {}) {
|
|
323
|
+
if (options.installs === false) return refusedHere(name, 'add')
|
|
311
324
|
if (!isValidPackageName(name))
|
|
312
325
|
return { ok: false, message: `'${name}' is not a package name npm would accept.` }
|
|
313
326
|
if (version !== undefined && !isValidVersion(version))
|
|
@@ -365,6 +378,7 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
|
|
|
365
378
|
},
|
|
366
379
|
|
|
367
380
|
async uninstall(name) {
|
|
381
|
+
if (options.installs === false) return refusedHere(name, 'remove')
|
|
368
382
|
if (!isValidPackageName(name))
|
|
369
383
|
return { ok: false, message: `'${name}' is not a package name npm would accept.` }
|
|
370
384
|
const result = await install('remove', name, options.siteDir)
|
|
@@ -379,4 +393,27 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
|
|
|
379
393
|
}
|
|
380
394
|
}
|
|
381
395
|
|
|
396
|
+
/** A package's own description, read leniently: anything unreadable is none. */
|
|
397
|
+
function descriptionOf(directory: string): string {
|
|
398
|
+
try {
|
|
399
|
+
const json = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) as {
|
|
400
|
+
description?: unknown
|
|
401
|
+
}
|
|
402
|
+
return typeof json.description === 'string' ? json.description : ''
|
|
403
|
+
} catch {
|
|
404
|
+
return ''
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** What a site that does not install at runtime says instead, with what to do. */
|
|
409
|
+
function refusedHere(name: string, verb: 'add' | 'remove'): InstallOutcome {
|
|
410
|
+
return {
|
|
411
|
+
ok: false,
|
|
412
|
+
message:
|
|
413
|
+
verb === 'add'
|
|
414
|
+
? `Packages are not installed from the backoffice on this site. Add ${name} to the site's package.json (bun add ${name}) and deploy, so every server has it.`
|
|
415
|
+
: `Packages are not removed from the backoffice on this site. Remove ${name} from the site's package.json (bun remove ${name}) and deploy.`,
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
382
419
|
export { EXTENSION_KEYWORD }
|
package/src/ports.ts
CHANGED
|
@@ -70,6 +70,7 @@ import { createTemplateFileStore } from './adapters/template-files.ts'
|
|
|
70
70
|
import { createTemplateQueryPort } from './adapters/template-query.ts'
|
|
71
71
|
import { avatarUrls, createUserPorts } from './adapters/users.ts'
|
|
72
72
|
import { createWebhookPort } from './adapters/webhooks.ts'
|
|
73
|
+
import { createApprovals } from './approvals.ts'
|
|
73
74
|
import { COMPONENT_SNIPPETS } from './component-snippets.ts'
|
|
74
75
|
import { type CromboloConfig, type UserLinkSender, VERSION } from './config.ts'
|
|
75
76
|
import { type EmailPort, resolveEmailPort } from './email.ts'
|
|
@@ -603,6 +604,20 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
|
|
|
603
604
|
}),
|
|
604
605
|
options.config.siteDir,
|
|
605
606
|
)
|
|
607
|
+
if (deps.documents) {
|
|
608
|
+
const documents = deps.documents
|
|
609
|
+
deps.approvals = createApprovals({
|
|
610
|
+
db: options.db,
|
|
611
|
+
nodeState: options.schema?.nodeState,
|
|
612
|
+
email,
|
|
613
|
+
// Through the same port an editor's publish takes, so what approval
|
|
614
|
+
// publishes is cached, tracked and announced as any publish is.
|
|
615
|
+
publisher: {
|
|
616
|
+
publish: (key, cultures) => documents.publish(key, cultures),
|
|
617
|
+
unpublish: (key, cultures) => documents.unpublish(key, cultures),
|
|
618
|
+
},
|
|
619
|
+
})
|
|
620
|
+
}
|
|
606
621
|
}
|
|
607
622
|
return deps
|
|
608
623
|
}
|
package/src/server.ts
CHANGED
|
@@ -84,6 +84,8 @@ import { createAuditHost, createAuditSink, pluginAuditPlan, recordAudit } from '
|
|
|
84
84
|
import { readCreatedBundlePackage, writeCreatedBundlePackage } from './adapters/bundles.ts'
|
|
85
85
|
import { createPublishedContentSource, loadDraftNode } from './adapters/documents.ts'
|
|
86
86
|
import { createSchemaProposals } from './adapters/schema-proposals.ts'
|
|
87
|
+
import type { Approvals } from './approvals.ts'
|
|
88
|
+
import { handleApprovalsApi } from './approvals-api.ts'
|
|
87
89
|
import { CommandError, installBundleContent } from './bundle-install.ts'
|
|
88
90
|
import { bundleChains, bundleTablesReport } from './bundle-tables.ts'
|
|
89
91
|
import { createBundleRedirects, createServerBundles, validateServerBundles } from './bundles.ts'
|
|
@@ -247,6 +249,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
247
249
|
contentApplied: async (packageName) =>
|
|
248
250
|
(await new TransferRunRepository(db).appliedForPackage(packageName))?.packageVersion ?? null,
|
|
249
251
|
applyContentOnInstall: config.applyBundleContentOnInstall,
|
|
252
|
+
installs: config.runtimeInstalls,
|
|
250
253
|
applyContent: async (packageName) => {
|
|
251
254
|
// A package from the marketplace is for anyone, so nothing in it can be
|
|
252
255
|
// sealed; one that is is refused rather than half applied.
|
|
@@ -657,37 +660,36 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
657
660
|
}
|
|
658
661
|
: undefined,
|
|
659
662
|
})
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
renderLog.error('Could not announce the template {alias}: {error}', { alias, error })
|
|
687
|
-
})
|
|
688
|
-
},
|
|
689
|
-
}),
|
|
663
|
+
const managementDeps = createDeps({
|
|
664
|
+
config,
|
|
665
|
+
paths,
|
|
666
|
+
extensions,
|
|
667
|
+
db,
|
|
668
|
+
webhooks,
|
|
669
|
+
cache,
|
|
670
|
+
schema,
|
|
671
|
+
mediaFiles,
|
|
672
|
+
email,
|
|
673
|
+
onSearchQueued: () => jobs.kickSearchIndexing(),
|
|
674
|
+
// This node looks again at once; the others are told to. The write has
|
|
675
|
+
// already landed wherever `componentsDir` points, so there is nothing to
|
|
676
|
+
// distribute — only the fact that it happened.
|
|
677
|
+
onViewsChange: async (alias) => {
|
|
678
|
+
// The snapshot first, and awaited: the hash announced is then the one
|
|
679
|
+
// this write produced rather than the generation it replaced, and the
|
|
680
|
+
// editor's next request renders what they just saved.
|
|
681
|
+
const hash = await snapshots.refresh()
|
|
682
|
+
void appendCacheInstruction(db, {
|
|
683
|
+
kind: 'components',
|
|
684
|
+
payload: hash ? { alias, hash } : { alias },
|
|
685
|
+
by: schema.nodeId,
|
|
686
|
+
}).catch((error: unknown) => {
|
|
687
|
+
renderLog.error('Could not announce the template {alias}: {error}', { alias, error })
|
|
688
|
+
})
|
|
690
689
|
},
|
|
690
|
+
})
|
|
691
|
+
const api = createManagementApiRouter({
|
|
692
|
+
deps: managementDeps,
|
|
691
693
|
authenticate,
|
|
692
694
|
nodes: createNodeLookup(db),
|
|
693
695
|
allowLocalLogin: config.allowLocalLogin,
|
|
@@ -1089,6 +1091,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1089
1091
|
url: marketplace.url(),
|
|
1090
1092
|
keyword: config.packageKeyword,
|
|
1091
1093
|
appliesContent: config.applyBundleContentOnInstall,
|
|
1094
|
+
installs: config.runtimeInstalls,
|
|
1092
1095
|
items: await marketplace.search(url.searchParams.get('q') ?? undefined),
|
|
1093
1096
|
})
|
|
1094
1097
|
}
|
|
@@ -1110,7 +1113,10 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1110
1113
|
}
|
|
1111
1114
|
|
|
1112
1115
|
if (action === 'installed' && request.method === 'GET') {
|
|
1113
|
-
return Response.json({
|
|
1116
|
+
return Response.json({
|
|
1117
|
+
installs: config.runtimeInstalls,
|
|
1118
|
+
items: await marketplace.installed(),
|
|
1119
|
+
})
|
|
1114
1120
|
}
|
|
1115
1121
|
|
|
1116
1122
|
if ((action === 'install' || action === 'uninstall') && request.method === 'POST') {
|
|
@@ -1460,6 +1466,43 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1460
1466
|
: Response.json({ ok: false, message: outcome.message }, { status: outcome.status })
|
|
1461
1467
|
}
|
|
1462
1468
|
|
|
1469
|
+
// Non-contract: approval workflows (story 028) — the page's Workflow tab, the
|
|
1470
|
+
// Workflows section and its designer. Content or Workflows, as either screen is.
|
|
1471
|
+
if (pathname.startsWith(`${paths.pluginPath}/api/approvals/`)) {
|
|
1472
|
+
const principal = await authenticate(request)
|
|
1473
|
+
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
1474
|
+
if (!hasSection(principal, 'content') && !hasSection(principal, 'workflow'))
|
|
1475
|
+
return new Response('Forbidden', { status: 403 })
|
|
1476
|
+
const approvals = managementDeps.approvals as Approvals | undefined
|
|
1477
|
+
if (!approvals) return new Response('Not Found', { status: 404 })
|
|
1478
|
+
const response = await handleApprovalsApi(
|
|
1479
|
+
request,
|
|
1480
|
+
pathname.slice(`${paths.pluginPath}/api/approvals/`.length),
|
|
1481
|
+
{
|
|
1482
|
+
db,
|
|
1483
|
+
approvals,
|
|
1484
|
+
principal,
|
|
1485
|
+
schemaDir: config.schemaDir,
|
|
1486
|
+
writable: config.schemaWritable,
|
|
1487
|
+
onChanged: config.schemaStore
|
|
1488
|
+
? async () => {
|
|
1489
|
+
await publishSchema(config.schemaStore as SchemaStore, config.schemaDir)
|
|
1490
|
+
}
|
|
1491
|
+
: undefined,
|
|
1492
|
+
},
|
|
1493
|
+
)
|
|
1494
|
+
if (response) {
|
|
1495
|
+
if (request.method !== 'GET')
|
|
1496
|
+
log.info('{user} {verb} on approvals: {status}', {
|
|
1497
|
+
user: principal.email,
|
|
1498
|
+
verb: pathname.split('/').slice(-1)[0],
|
|
1499
|
+
status: response.status,
|
|
1500
|
+
})
|
|
1501
|
+
return response
|
|
1502
|
+
}
|
|
1503
|
+
return new Response('Not Found', { status: 404 })
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1463
1506
|
// Non-contract, Packages: answering a content import's conflicts and running
|
|
1464
1507
|
// it, from the Changes dashboard. Packages, because installing is.
|
|
1465
1508
|
if (pathname.startsWith(`${paths.pluginPath}/api/transfers/`)) {
|