@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/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.12.1'
621
+ export const VERSION = '0.13.2'
@@ -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 api = createManagementApiRouter({
661
- deps: {
662
- ...createDeps({
663
- config,
664
- paths,
665
- extensions,
666
- db,
667
- webhooks,
668
- cache,
669
- schema,
670
- mediaFiles,
671
- email,
672
- onSearchQueued: () => jobs.kickSearchIndexing(),
673
- // This node looks again at once; the others are told to. The write has
674
- // already landed wherever `componentsDir` points, so there is nothing to
675
- // distribute — only the fact that it happened.
676
- onViewsChange: async (alias) => {
677
- // The snapshot first, and awaited: the hash announced is then the one
678
- // this write produced rather than the generation it replaced, and the
679
- // editor's next request renders what they just saved.
680
- const hash = await snapshots.refresh()
681
- void appendCacheInstruction(db, {
682
- kind: 'components',
683
- payload: hash ? { alias, hash } : { alias },
684
- by: schema.nodeId,
685
- }).catch((error: unknown) => {
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({ items: await marketplace.installed() })
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/`)) {