@crombolo/server 0.16.0 → 0.18.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.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "The crombolo HTTP server: routing, configuration, media stores and background jobs.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -30,17 +30,17 @@
30
30
  "access": "public"
31
31
  },
32
32
  "dependencies": {
33
- "@crombolo/api-delivery": "0.16.0",
34
- "@crombolo/api-management": "0.16.0",
35
- "@crombolo/assistant": "0.16.0",
36
- "@crombolo/auth": "0.16.0",
37
- "@crombolo/backoffice-host": "0.16.0",
38
- "@crombolo/contracts": "0.16.0",
39
- "@crombolo/core": "0.16.0",
40
- "@crombolo/data": "0.16.0",
41
- "@crombolo/render": "0.16.0",
42
- "@crombolo/schema": "0.16.0",
43
- "@crombolo/transfer": "0.16.0",
33
+ "@crombolo/api-delivery": "0.18.0",
34
+ "@crombolo/api-management": "0.18.0",
35
+ "@crombolo/assistant": "0.18.0",
36
+ "@crombolo/auth": "0.18.0",
37
+ "@crombolo/backoffice-host": "0.18.0",
38
+ "@crombolo/contracts": "0.18.0",
39
+ "@crombolo/core": "0.18.0",
40
+ "@crombolo/data": "0.18.0",
41
+ "@crombolo/render": "0.18.0",
42
+ "@crombolo/schema": "0.18.0",
43
+ "@crombolo/transfer": "0.18.0",
44
44
  "@logtape/logtape": "^2.3.8"
45
45
  }
46
46
  }
@@ -75,6 +75,8 @@ export function createDocumentPort(
75
75
  tracker: RedirectTracker = NO_REDIRECT_TRACKING,
76
76
  /** The site's own directory: where `domains.toml` is, when the site keeps one. */
77
77
  siteDir?: string,
78
+ /** Whether the site can send the e-mails a subscription asks for (story 045). */
79
+ notificationsAvailable = true,
78
80
  ): DocumentPort {
79
81
  const repo = new DocumentRepository(db, options)
80
82
  const domains = new DomainRepository(db)
@@ -415,9 +417,14 @@ export function createDocumentPort(
415
417
  async setNotifications(key, principal, actionIds) {
416
418
  const node = await repo.nodes.byKey(key)
417
419
  const userId = await userIdOf(principal)
418
- if (!node || node.objectType !== repo.objectType || userId === undefined) return false
420
+ if (!node || node.objectType !== repo.objectType || userId === undefined) return 'notFound'
421
+ // Clearing is always allowed; asking for something the site cannot send is not.
422
+ if (!notificationsAvailable) {
423
+ const had = new Set(await notifications.subscribed(userId, node.id))
424
+ if (actionIds.some((id) => !had.has(id))) return 'unavailable'
425
+ }
419
426
  await notifications.set(userId, node.id, actionIds)
420
- return true
427
+ return 'saved'
421
428
  },
422
429
 
423
430
  versions: (key) => repo.versions(key),
@@ -119,6 +119,8 @@ export interface UserPortOptions {
119
119
  nodes: NodeLookup
120
120
  /** Signs a user out everywhere, as a password change set by someone else does. */
121
121
  endSessions: (userId: number) => Promise<void>
122
+ /** A group save changed what it may do on these pages (story 045: the "rights" notification). */
123
+ onPagePermissionsChanged?: (principal: Principal, nodeKeys: string[]) => void | Promise<void>
122
124
  }
123
125
 
124
126
  export function createUserPorts(
@@ -661,7 +663,11 @@ export function createUserPorts(
661
663
 
662
664
  return {
663
665
  users: userPort,
664
- groups: createUserGroupPort(db, { adminKey, nodes }),
666
+ groups: createUserGroupPort(db, {
667
+ adminKey,
668
+ nodes,
669
+ onPagePermissionsChanged: options.onPagePermissionsChanged,
670
+ }),
665
671
  userData: createUserDataPort(db),
666
672
  }
667
673
  }
@@ -763,7 +769,11 @@ function fromPermissions(permissions: UserGroupInput['permissions']): GranularPe
763
769
 
764
770
  function createUserGroupPort(
765
771
  db: Db,
766
- deps: { adminKey: () => Promise<string | undefined>; nodes: NodeLookup },
772
+ deps: {
773
+ adminKey: () => Promise<string | undefined>
774
+ nodes: NodeLookup
775
+ onPagePermissionsChanged?: UserPortOptions['onPagePermissionsChanged']
776
+ },
767
777
  ): UserGroupPort {
768
778
  const groups = new UserGroupRepository(db)
769
779
  const users = new UserRepository(db)
@@ -827,6 +837,34 @@ function createUserGroupPort(
827
837
  return 'Success'
828
838
  }
829
839
 
840
+ /** Each page's verbs for the group, by page key. */
841
+ const pageVerbs = (group: { granular: GranularPermission[] } | undefined) => {
842
+ const out = new Map<string, Set<string>>()
843
+ for (const row of group?.granular ?? [])
844
+ if (row.context === 'Document' && row.key) {
845
+ const verbs = out.get(row.key) ?? new Set<string>()
846
+ verbs.add(row.permission)
847
+ out.set(row.key, verbs)
848
+ }
849
+ return out
850
+ }
851
+ /** The pages whose verbs for the group a save changed. */
852
+ const changedPages = (before: Map<string, Set<string>>, after: Map<string, Set<string>>) =>
853
+ [...new Set([...before.keys(), ...after.keys()])].filter((key) => {
854
+ const a = before.get(key) ?? new Set<string>()
855
+ const b = after.get(key) ?? new Set<string>()
856
+ return a.size !== b.size || [...a].some((verb) => !b.has(verb))
857
+ })
858
+ const announcePermissions = async (
859
+ principal: Principal,
860
+ before: Map<string, Set<string>>,
861
+ key: string,
862
+ ) => {
863
+ if (!deps.onPagePermissionsChanged) return
864
+ const changed = changedPages(before, pageVerbs(await groups.byKey(key)))
865
+ if (changed.length > 0) await deps.onPagePermissionsChanged(principal, changed)
866
+ }
867
+
830
868
  const save = async (input: UserGroupInput, key: string): Promise<UserGroupStatus> => {
831
869
  const result = await groups.save({
832
870
  key,
@@ -906,6 +944,7 @@ function createUserGroupPort(
906
944
  if (await groups.byKey(key)) return { status: 'DuplicateAlias' }
907
945
  const saved = await save(input, key)
908
946
  if (saved !== 'Success') return { status: saved }
947
+ await announcePermissions(principal, new Map(), key)
909
948
  // A non-admin who creates a group joins it, as in Umbraco
910
949
  if (!principal.isAdmin) await users.addToGroup(key, [principal.id])
911
950
  return { status: 'Success', key }
@@ -917,7 +956,10 @@ function createUserGroupPort(
917
956
  if (!isMember(principal, group.key)) return 'Forbidden'
918
957
  const valid = await validate(principal, input, group)
919
958
  if (valid !== 'Success') return valid
920
- return save(input, group.key)
959
+ const before = pageVerbs(group)
960
+ const saved = await save(input, group.key)
961
+ if (saved === 'Success') await announcePermissions(principal, before, group.key)
962
+ return saved
921
963
  },
922
964
 
923
965
  async delete(principal, keys) {
package/src/approvals.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * This decides: who may act, what an edit does to a waiting request, when a
8
8
  * stage is done, and when the page is published.
9
9
  */
10
+
10
11
  import type { Principal } from '@crombolo/api-management'
11
12
  import {
12
13
  approvalsNeeded,
@@ -36,6 +37,7 @@ import {
36
37
  type StoredWorkflow,
37
38
  UserGroupRepository,
38
39
  } from '@crombolo/data'
40
+ import type { ContentNotifier } from './content-notifications.ts'
39
41
  import type { EmailPort } from './email.ts'
40
42
 
41
43
  /** What publishing and unpublishing go through when approval says yes. */
@@ -57,6 +59,8 @@ export interface ApprovalsOptions {
57
59
  email?: EmailPort
58
60
  /** Where a link in an e-mail points, e.g. `https://site.example/crombolo`. */
59
61
  backOfficeUrl?: string
62
+ /** Queues the content notifications a publish on approval makes (story 045). */
63
+ notifier?: ContentNotifier
60
64
  }
61
65
 
62
66
  /** A refusal a person reads: why, and what to do instead. */
@@ -426,6 +430,12 @@ export function createApprovals(options: ApprovalsOptions): Approvals {
426
430
  request.action === 'publish' ? 'Published on approval' : 'Unpublished on approval',
427
431
  `through "${request.workflow.name}"`,
428
432
  )
433
+ if (request.action === 'publish')
434
+ await options.notifier?.raise(
435
+ { actions: ['Umb.Document.Publish'], key: request.nodeKey, keyKind: 'document' },
436
+ principal ? { key: principal.id, name: principal.name } : null,
437
+ `approved through "${request.workflow.name}"`,
438
+ )
429
439
  await notify(
430
440
  'editor',
431
441
  request,
package/src/components.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, statSync } from 'node:fs'
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
2
2
  import { join, relative, resolve, sep } from 'node:path'
3
3
 
4
4
  /**
@@ -196,6 +196,82 @@ export function componentFileFor(root: string, alias: string): string {
196
196
  return join(root, `${alias}.tsx`)
197
197
  }
198
198
 
199
+ /** What an e-mail template looks like: a default export over `EmailProps`. */
200
+ export function emailScaffold(alias: string): string {
201
+ const name = componentName(alias)
202
+ return `import type { EmailProps } from '@crombolo/crombolo'
203
+
204
+ export default function ${name}({ form, fields, site }: EmailProps) {
205
+ return (
206
+ <html lang="en">
207
+ <body>
208
+ <h1>{form.name}</h1>
209
+ <table>
210
+ {fields.map((field) => (
211
+ <tr>
212
+ <th>{field.caption}</th>
213
+ <td>
214
+ {field.leftOut
215
+ ? 'Not included'
216
+ : field.answered
217
+ ? field.values.join(', ')
218
+ : 'Not answered'}
219
+ </td>
220
+ </tr>
221
+ ))}
222
+ </table>
223
+ <p>
224
+ Sent from <a href={site.url}>{site.name}</a>
225
+ </p>
226
+ </body>
227
+ </html>
228
+ )
229
+ }
230
+ `
231
+ }
232
+
233
+ /** The folder e-mail templates live in, as an alias prefix. */
234
+ export const EMAIL_FOLDER = 'emails/'
235
+
236
+ /**
237
+ * The components the forms designer offers as e-mail designs: those in
238
+ * `components/emails/`, and any a form already names, so a picker never loses
239
+ * the one that is set. A layout there takes `EmailLayoutProps`, so it is not offered.
240
+ */
241
+ export function listEmailTemplates(
242
+ root: string,
243
+ named: Iterable<string> = [],
244
+ ): { alias: string; name: string }[] {
245
+ const components = listComponents(root)
246
+ const found = new Set<string>()
247
+ for (const component of components)
248
+ if (
249
+ component.alias.startsWith(EMAIL_FOLDER) &&
250
+ !/\bEmailLayoutProps\b/.test(readFileSync(component.path, 'utf8'))
251
+ )
252
+ found.add(component.alias)
253
+ const present = new Set(components.map((component) => component.alias))
254
+ for (const alias of named) if (present.has(alias)) found.add(alias)
255
+ const items = [...found].sort().map((alias) => ({ alias, name: readableName(alias) }))
256
+ // Two folders with the same file name would read alike, so those keep their path.
257
+ const counts = new Map<string, number>()
258
+ for (const item of items) counts.set(item.name, (counts.get(item.name) ?? 0) + 1)
259
+ return items.map((item) =>
260
+ (counts.get(item.name) ?? 0) > 1 ? { ...item, name: `${item.name} (${item.alias})` } : item,
261
+ )
262
+ }
263
+
264
+ /** `emails/contactReceipt` → "Contact receipt". */
265
+ function readableName(alias: string): string {
266
+ const base = alias.split('/').pop() ?? alias
267
+ const words = base
268
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
269
+ .replace(/[-_]+/g, ' ')
270
+ .trim()
271
+ .toLowerCase()
272
+ return words.charAt(0).toUpperCase() + words.slice(1)
273
+ }
274
+
199
275
  /** What a component looks like: a function, taking whatever imports it hands it. */
200
276
  export function componentScaffold(alias: string): string {
201
277
  const name = componentName(alias)
package/src/config.ts CHANGED
@@ -14,6 +14,7 @@ import type { GitConfig } from './git-routes.ts'
14
14
  import { checkIdentityProviders, type IdentityProvider } from './identity-providers.ts'
15
15
  import type { MediaStore } from './media-store.ts'
16
16
  import type { SchemaStore } from './schema-store.ts'
17
+ import { setting } from './settings.ts'
17
18
 
18
19
  /**
19
20
  * Runtime settings for a site. Umbraco's equivalents live in appsettings.json;
@@ -497,11 +498,11 @@ export function loadConfig(
497
498
  cwd = process.cwd(),
498
499
  ): CromboloConfig {
499
500
  const fromEnv: CromboloConfig = {
500
- port: Number(Bun.env.PORT ?? DEFAULTS.port),
501
- maxRequestBodyBytes: Bun.env.CROMBOLO_MAX_REQUEST_BODY_MB
502
- ? Number(Bun.env.CROMBOLO_MAX_REQUEST_BODY_MB) * 1024 * 1024
501
+ port: Number(setting('PORT') ?? DEFAULTS.port),
502
+ maxRequestBodyBytes: setting('CROMBOLO_MAX_REQUEST_BODY_MB')
503
+ ? Number(setting('CROMBOLO_MAX_REQUEST_BODY_MB')) * 1024 * 1024
503
504
  : DEFAULTS.maxRequestBodyBytes,
504
- backOfficePath: Bun.env.CROMBOLO_BACKOFFICE_PATH ?? DEFAULTS.backOfficePath,
505
+ backOfficePath: setting('CROMBOLO_BACKOFFICE_PATH') ?? DEFAULTS.backOfficePath,
505
506
  defaultUiLanguage: DEFAULTS.defaultUiLanguage,
506
507
  keepUserLoggedIn: DEFAULTS.keepUserLoggedIn,
507
508
  allowLocalLogin: DEFAULTS.allowLocalLogin,
@@ -509,135 +510,135 @@ export function loadConfig(
509
510
  versionCheckPeriod: DEFAULTS.versionCheckPeriod,
510
511
  umbracoCssPath: DEFAULTS.umbracoCssPath,
511
512
  signalRSkipNegotiation: DEFAULTS.signalRSkipNegotiation,
512
- development: Bun.env.NODE_ENV !== 'production',
513
- sqliteFile: Bun.env.CROMBOLO_SQLITE_FILE ?? DEFAULTS.sqliteFile,
514
- adminLogin: Bun.env.CROMBOLO_ADMIN_LOGIN ?? DEFAULTS.adminLogin,
515
- adminPassword: Bun.env.CROMBOLO_ADMIN_PASSWORD,
513
+ development: setting('NODE_ENV') !== 'production',
514
+ sqliteFile: setting('CROMBOLO_SQLITE_FILE') ?? DEFAULTS.sqliteFile,
515
+ adminLogin: setting('CROMBOLO_ADMIN_LOGIN') ?? DEFAULTS.adminLogin,
516
+ adminPassword: setting('CROMBOLO_ADMIN_PASSWORD'),
516
517
  apiClient:
517
- Bun.env.CROMBOLO_API_CLIENT_ID || Bun.env.CROMBOLO_API_CLIENT_SECRET
518
+ setting('CROMBOLO_API_CLIENT_ID') || setting('CROMBOLO_API_CLIENT_SECRET')
518
519
  ? {
519
- id: Bun.env.CROMBOLO_API_CLIENT_ID ?? '',
520
- secret: Bun.env.CROMBOLO_API_CLIENT_SECRET ?? '',
520
+ id: setting('CROMBOLO_API_CLIENT_ID') ?? '',
521
+ secret: setting('CROMBOLO_API_CLIENT_SECRET') ?? '',
521
522
  }
522
523
  : undefined,
523
- localAdmin: Bun.env.CROMBOLO_LOCAL_ADMIN === 'true',
524
- setupCode: Bun.env.CROMBOLO_SETUP_CODE,
525
- secretKey: Bun.env.CROMBOLO_SECRET_KEY,
526
- secureCookies: Bun.env.CROMBOLO_INSECURE_COOKIES !== 'true',
527
- siteName: Bun.env.CROMBOLO_SITE_NAME ?? DEFAULTS.siteName,
524
+ localAdmin: setting('CROMBOLO_LOCAL_ADMIN') === 'true',
525
+ setupCode: setting('CROMBOLO_SETUP_CODE'),
526
+ secretKey: setting('CROMBOLO_SECRET_KEY'),
527
+ secureCookies: setting('CROMBOLO_INSECURE_COOKIES') !== 'true',
528
+ siteName: setting('CROMBOLO_SITE_NAME') ?? DEFAULTS.siteName,
528
529
  siteDir: cwd,
529
- componentsDir: Bun.env.CROMBOLO_COMPONENTS_DIR ?? DEFAULTS.componentsDir,
530
- componentsCacheDir: Bun.env.CROMBOLO_COMPONENTS_CACHE_DIR ?? DEFAULTS.componentsCacheDir,
530
+ componentsDir: setting('CROMBOLO_COMPONENTS_DIR') ?? DEFAULTS.componentsDir,
531
+ componentsCacheDir: setting('CROMBOLO_COMPONENTS_CACHE_DIR') ?? DEFAULTS.componentsCacheDir,
531
532
  componentsSnapshot: {
532
- ...(Bun.env.CROMBOLO_COMPONENTS_GATE_MS
533
- ? { gateTtlMs: Number(Bun.env.CROMBOLO_COMPONENTS_GATE_MS) }
533
+ ...(setting('CROMBOLO_COMPONENTS_GATE_MS')
534
+ ? { gateTtlMs: Number(setting('CROMBOLO_COMPONENTS_GATE_MS')) }
534
535
  : {}),
535
- ...(Bun.env.CROMBOLO_COMPONENTS_GENERATION_LIMIT
536
- ? { maxGenerations: Number(Bun.env.CROMBOLO_COMPONENTS_GENERATION_LIMIT) }
536
+ ...(setting('CROMBOLO_COMPONENTS_GENERATION_LIMIT')
537
+ ? { maxGenerations: Number(setting('CROMBOLO_COMPONENTS_GENERATION_LIMIT')) }
537
538
  : {}),
538
- ...(Bun.env.CROMBOLO_COMPONENTS_KEEP
539
- ? { keep: Number(Bun.env.CROMBOLO_COMPONENTS_KEEP) }
539
+ ...(setting('CROMBOLO_COMPONENTS_KEEP')
540
+ ? { keep: Number(setting('CROMBOLO_COMPONENTS_KEEP')) }
540
541
  : {}),
541
- ...(Bun.env.CROMBOLO_COMPONENTS_SWAP_MS
542
- ? { minSwapIntervalMs: Number(Bun.env.CROMBOLO_COMPONENTS_SWAP_MS) }
542
+ ...(setting('CROMBOLO_COMPONENTS_SWAP_MS')
543
+ ? { minSwapIntervalMs: Number(setting('CROMBOLO_COMPONENTS_SWAP_MS')) }
543
544
  : {}),
544
545
  },
545
- renderTimeoutMs: Number(Bun.env.CROMBOLO_RENDER_TIMEOUT_MS ?? DEFAULTS.renderTimeoutMs),
546
- schemaDir: Bun.env.CROMBOLO_SCHEMA_DIR ?? DEFAULTS.schemaDir,
547
- schemaRevision: Bun.env.CROMBOLO_SCHEMA_REVISION ?? DEFAULTS.schemaRevision,
548
- nodeId: Bun.env.CROMBOLO_NODE_ID ?? `${hostname()}:${process.pid}`,
549
- role: roleFromEnvironment(Bun.env.CROMBOLO_ROLE),
546
+ renderTimeoutMs: Number(setting('CROMBOLO_RENDER_TIMEOUT_MS') ?? DEFAULTS.renderTimeoutMs),
547
+ schemaDir: setting('CROMBOLO_SCHEMA_DIR') ?? DEFAULTS.schemaDir,
548
+ schemaRevision: setting('CROMBOLO_SCHEMA_REVISION') ?? DEFAULTS.schemaRevision,
549
+ nodeId: setting('CROMBOLO_NODE_ID') ?? `${hostname()}:${process.pid}`,
550
+ role: roleFromEnvironment(setting('CROMBOLO_ROLE')),
550
551
  git: undefined,
551
552
  schemaStore: undefined,
552
- schemaCacheDir: Bun.env.CROMBOLO_SCHEMA_CACHE_DIR ?? join(tmpdir(), 'crombolo-schema'),
553
+ schemaCacheDir: setting('CROMBOLO_SCHEMA_CACHE_DIR') ?? join(tmpdir(), 'crombolo-schema'),
553
554
  // Resolved again below, once it is known whether a store was configured.
554
555
  schemaWritable:
555
- Bun.env.CROMBOLO_SCHEMA_WRITABLE !== undefined
556
- ? Bun.env.CROMBOLO_SCHEMA_WRITABLE === 'true'
557
- : Bun.env.NODE_ENV !== 'production',
558
- syncSchemaAtBoot: Bun.env.CROMBOLO_SYNC_SCHEMA_AT_BOOT !== 'false',
559
- dialect: Bun.env.CROMBOLO_DB === 'postgres' ? 'postgres' : 'sqlite',
560
- pgDump: Bun.env.CROMBOLO_PG_DUMP,
556
+ setting('CROMBOLO_SCHEMA_WRITABLE') !== undefined
557
+ ? setting('CROMBOLO_SCHEMA_WRITABLE') === 'true'
558
+ : setting('NODE_ENV') !== 'production',
559
+ syncSchemaAtBoot: setting('CROMBOLO_SYNC_SCHEMA_AT_BOOT') !== 'false',
560
+ dialect: setting('CROMBOLO_DB') === 'postgres' ? 'postgres' : 'sqlite',
561
+ pgDump: setting('CROMBOLO_PG_DUMP'),
561
562
  upgradePolicy:
562
- Bun.env.CROMBOLO_UPGRADE_POLICY === 'pending-allowed' ||
563
- (Bun.env.CROMBOLO_UPGRADE_POLICY === undefined && Bun.env.NODE_ENV !== 'production')
563
+ setting('CROMBOLO_UPGRADE_POLICY') === 'pending-allowed' ||
564
+ (setting('CROMBOLO_UPGRADE_POLICY') === undefined && setting('NODE_ENV') !== 'production')
564
565
  ? 'pending-allowed'
565
566
  : 'strict',
566
567
  versionCleanup: {
567
- enabled: Bun.env.CROMBOLO_VERSION_CLEANUP === 'true',
568
+ enabled: setting('CROMBOLO_VERSION_CLEANUP') === 'true',
568
569
  keepAllVersionsNewerThanDays: Number(
569
- Bun.env.CROMBOLO_VERSION_CLEANUP_KEEP_ALL_DAYS ?? DEFAULTS.keepAllVersionsNewerThanDays,
570
+ setting('CROMBOLO_VERSION_CLEANUP_KEEP_ALL_DAYS') ?? DEFAULTS.keepAllVersionsNewerThanDays,
570
571
  ),
571
572
  keepLatestVersionPerDayForDays: Number(
572
- Bun.env.CROMBOLO_VERSION_CLEANUP_KEEP_LATEST_DAYS ??
573
+ setting('CROMBOLO_VERSION_CLEANUP_KEEP_LATEST_DAYS') ??
573
574
  DEFAULTS.keepLatestVersionPerDayForDays,
574
575
  ),
575
576
  },
576
577
  auditRetentionDays: nonNegative(
577
- Bun.env.CROMBOLO_AUDIT_RETENTION_DAYS,
578
+ setting('CROMBOLO_AUDIT_RETENTION_DAYS'),
578
579
  DEFAULTS.auditRetentionDays,
579
580
  ),
580
- mediaDir: Bun.env.CROMBOLO_MEDIA_DIR ?? DEFAULTS.mediaDir,
581
+ mediaDir: setting('CROMBOLO_MEDIA_DIR') ?? DEFAULTS.mediaDir,
581
582
  mediaStore: undefined,
582
- logsDir: Bun.env.CROMBOLO_LOGS_DIR ?? DEFAULTS.logsDir,
583
- logLevel: parseLogLevel(Bun.env.CROMBOLO_LOG_LEVEL),
584
- logToConsole: Bun.env.CROMBOLO_LOG_TO_CONSOLE !== 'false',
583
+ logsDir: setting('CROMBOLO_LOGS_DIR') ?? DEFAULTS.logsDir,
584
+ logLevel: parseLogLevel(setting('CROMBOLO_LOG_LEVEL')),
585
+ logToConsole: setting('CROMBOLO_LOG_TO_CONSOLE') !== 'false',
585
586
  diagnostics: {
586
- traceRequests: Bun.env.CROMBOLO_TRACE_REQUESTS === 'true',
587
- heapIntervalSeconds: Math.max(0, Number(Bun.env.CROMBOLO_HEAP_INTERVAL_SECONDS ?? 0) || 0),
587
+ traceRequests: setting('CROMBOLO_TRACE_REQUESTS') === 'true',
588
+ heapIntervalSeconds: Math.max(0, Number(setting('CROMBOLO_HEAP_INTERVAL_SECONDS') ?? 0) || 0),
588
589
  },
589
- stylesheetsDir: Bun.env.CROMBOLO_CSS_DIR ?? DEFAULTS.stylesheetsDir,
590
- scriptsDir: Bun.env.CROMBOLO_SCRIPTS_DIR ?? DEFAULTS.scriptsDir,
591
- usernameIsEmail: Bun.env.CROMBOLO_USERNAME_IS_EMAIL !== 'false',
592
- applicationUrl: Bun.env.CROMBOLO_APPLICATION_URL ?? '',
590
+ stylesheetsDir: setting('CROMBOLO_CSS_DIR') ?? DEFAULTS.stylesheetsDir,
591
+ scriptsDir: setting('CROMBOLO_SCRIPTS_DIR') ?? DEFAULTS.scriptsDir,
592
+ usernameIsEmail: setting('CROMBOLO_USERNAME_IS_EMAIL') !== 'false',
593
+ applicationUrl: setting('CROMBOLO_APPLICATION_URL') ?? '',
593
594
  sendUserLink: undefined,
594
595
  email: undefined,
595
- allowMemberRegistration: Bun.env.CROMBOLO_ALLOW_MEMBER_REGISTRATION === 'true',
596
+ allowMemberRegistration: setting('CROMBOLO_ALLOW_MEMBER_REGISTRATION') === 'true',
596
597
  memberRegistrationType:
597
- Bun.env.CROMBOLO_MEMBER_REGISTRATION_TYPE ?? DEFAULTS.memberRegistrationType,
598
- memberRegistrationGroups: (Bun.env.CROMBOLO_MEMBER_REGISTRATION_GROUPS ?? '')
598
+ setting('CROMBOLO_MEMBER_REGISTRATION_TYPE') ?? DEFAULTS.memberRegistrationType,
599
+ memberRegistrationGroups: (setting('CROMBOLO_MEMBER_REGISTRATION_GROUPS') ?? '')
599
600
  .split(',')
600
601
  .map((name) => name.trim())
601
602
  .filter(Boolean),
602
603
  memberSessionMinutes: Number(
603
- Bun.env.CROMBOLO_MEMBER_SESSION_MINUTES ?? DEFAULTS.memberSessionMinutes,
604
+ setting('CROMBOLO_MEMBER_SESSION_MINUTES') ?? DEFAULTS.memberSessionMinutes,
604
605
  ),
605
606
  maxFailedPasswordAttempts: Number(
606
- Bun.env.CROMBOLO_MAX_FAILED_PASSWORD_ATTEMPTS ?? DEFAULTS.maxFailedPasswordAttempts,
607
+ setting('CROMBOLO_MAX_FAILED_PASSWORD_ATTEMPTS') ?? DEFAULTS.maxFailedPasswordAttempts,
607
608
  ),
608
609
  // No environment variable: a provider is a preset, which is code.
609
610
  memberIdentityProviders: [],
610
611
  backofficeIdentityProviders: [],
611
612
  remote: {
612
- enabled: Bun.env.CROMBOLO_REMOTE_ENABLED === 'true',
613
- environment: Bun.env.CROMBOLO_REMOTE_ENVIRONMENT || 'production',
614
- protected: Bun.env.CROMBOLO_REMOTE_PROTECTED !== 'false',
615
- approvals: Bun.env.CROMBOLO_REMOTE_APPROVALS === 'true',
616
- maxUploadBytes: Number(Bun.env.CROMBOLO_REMOTE_MAX_UPLOAD_GB ?? 50) * 1024 ** 3,
613
+ enabled: setting('CROMBOLO_REMOTE_ENABLED') === 'true',
614
+ environment: setting('CROMBOLO_REMOTE_ENVIRONMENT') || 'production',
615
+ protected: setting('CROMBOLO_REMOTE_PROTECTED') !== 'false',
616
+ approvals: setting('CROMBOLO_REMOTE_APPROVALS') === 'true',
617
+ maxUploadBytes: Number(setting('CROMBOLO_REMOTE_MAX_UPLOAD_GB') ?? 50) * 1024 ** 3,
617
618
  },
618
619
  redirects: [],
619
- trackRedirects: Bun.env.CROMBOLO_TRACK_REDIRECTS !== 'false',
620
- applyBundleContentOnInstall: Bun.env.CROMBOLO_APPLY_BUNDLE_CONTENT !== 'false',
620
+ trackRedirects: setting('CROMBOLO_TRACK_REDIRECTS') !== 'false',
621
+ applyBundleContentOnInstall: setting('CROMBOLO_APPLY_BUNDLE_CONTENT') !== 'false',
621
622
  // Resolved again below, once it is known whether a store was configured.
622
- runtimeInstalls: Bun.env.CROMBOLO_RUNTIME_INSTALLS !== 'false',
623
+ runtimeInstalls: setting('CROMBOLO_RUNTIME_INSTALLS') !== 'false',
623
624
  seo: stripUndefined({
624
- sitemap: envFlag(Bun.env.CROMBOLO_SITEMAP),
625
- indexable: envFlag(Bun.env.CROMBOLO_INDEXABLE),
625
+ sitemap: envFlag(setting('CROMBOLO_SITEMAP')),
626
+ indexable: envFlag(setting('CROMBOLO_INDEXABLE')),
626
627
  }),
627
628
  deliveryApi: stripUndefined({
628
- enabled: envFlag(Bun.env.CROMBOLO_DELIVERY_API),
629
- apiKey: Bun.env.CROMBOLO_DELIVERY_API_KEY || undefined,
630
- publicAccess: envFlag(Bun.env.CROMBOLO_DELIVERY_API_PUBLIC),
629
+ enabled: envFlag(setting('CROMBOLO_DELIVERY_API')),
630
+ apiKey: setting('CROMBOLO_DELIVERY_API_KEY') || undefined,
631
+ publicAccess: envFlag(setting('CROMBOLO_DELIVERY_API_PUBLIC')),
631
632
  media:
632
- envFlag(Bun.env.CROMBOLO_DELIVERY_API_MEDIA) === undefined
633
+ envFlag(setting('CROMBOLO_DELIVERY_API_MEDIA')) === undefined
633
634
  ? undefined
634
- : { enabled: envFlag(Bun.env.CROMBOLO_DELIVERY_API_MEDIA) },
635
+ : { enabled: envFlag(setting('CROMBOLO_DELIVERY_API_MEDIA')) },
635
636
  }),
636
637
  // No environment variable: a bundle is an import, which is code.
637
638
  bundles: [],
638
- marketplaceUrl: Bun.env.CROMBOLO_MARKETPLACE_URL ?? DEFAULTS.marketplaceUrl,
639
- packageKeyword: Bun.env.CROMBOLO_PACKAGE_KEYWORD ?? DEFAULTS.packageKeyword,
640
- npmRegistry: Bun.env.CROMBOLO_NPM_REGISTRY ?? DEFAULTS.npmRegistry,
639
+ marketplaceUrl: setting('CROMBOLO_MARKETPLACE_URL') ?? DEFAULTS.marketplaceUrl,
640
+ packageKeyword: setting('CROMBOLO_PACKAGE_KEYWORD') ?? DEFAULTS.packageKeyword,
641
+ npmRegistry: setting('CROMBOLO_NPM_REGISTRY') ?? DEFAULTS.npmRegistry,
641
642
  // No environment variable: enabling it takes a provider, which is code.
642
643
  assistant: undefined,
643
644
  }
@@ -661,7 +662,7 @@ export function loadConfig(
661
662
  // it — that is the point of configuring one. An explicit setting still wins.
662
663
  if (
663
664
  merged.schemaStore &&
664
- Bun.env.CROMBOLO_SCHEMA_WRITABLE === undefined &&
665
+ setting('CROMBOLO_SCHEMA_WRITABLE') === undefined &&
665
666
  overrides.schemaWritable === undefined
666
667
  ) {
667
668
  merged.schemaWritable = true
@@ -670,7 +671,7 @@ export function loadConfig(
670
671
  // them would not either. An explicit setting still wins.
671
672
  if (
672
673
  merged.schemaStore &&
673
- Bun.env.CROMBOLO_RUNTIME_INSTALLS === undefined &&
674
+ setting('CROMBOLO_RUNTIME_INSTALLS') === undefined &&
674
675
  overrides.runtimeInstalls === undefined
675
676
  ) {
676
677
  merged.runtimeInstalls = false
@@ -727,7 +728,7 @@ function stripUndefined<T extends object>(value: T): Partial<T> {
727
728
  */
728
729
  export function describeDatabase(
729
730
  config: Pick<CromboloConfig, 'dialect' | 'sqliteFile'>,
730
- url: string | undefined = Bun.env.CROMBOLO_POSTGRES_URL ?? Bun.env.DATABASE_URL,
731
+ url: string | undefined = setting('CROMBOLO_POSTGRES_URL') ?? setting('DATABASE_URL'),
731
732
  ): string {
732
733
  if (config.dialect !== 'postgres') return config.sqliteFile
733
734
  if (!url) return 'postgres'
@@ -739,4 +740,4 @@ export function describeDatabase(
739
740
  }
740
741
  }
741
742
 
742
- export const VERSION = '0.16.0'
743
+ export const VERSION = '0.18.0'