@crombolo/server 0.17.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.17.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.17.0",
34
- "@crombolo/api-management": "0.17.0",
35
- "@crombolo/assistant": "0.17.0",
36
- "@crombolo/auth": "0.17.0",
37
- "@crombolo/backoffice-host": "0.17.0",
38
- "@crombolo/contracts": "0.17.0",
39
- "@crombolo/core": "0.17.0",
40
- "@crombolo/data": "0.17.0",
41
- "@crombolo/render": "0.17.0",
42
- "@crombolo/schema": "0.17.0",
43
- "@crombolo/transfer": "0.17.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
  }
package/src/components.ts CHANGED
@@ -230,10 +230,13 @@ export default function ${name}({ form, fields, site }: EmailProps) {
230
230
  `
231
231
  }
232
232
 
233
+ /** The folder e-mail templates live in, as an alias prefix. */
234
+ export const EMAIL_FOLDER = 'emails/'
235
+
233
236
  /**
234
- * The components the forms designer offers as e-mail designs: those written
235
- * over `EmailProps`, and any a form already names, so a picker never loses the
236
- * one that is set. A layout takes `EmailLayoutProps`, so it is not offered.
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.
237
240
  */
238
241
  export function listEmailTemplates(
239
242
  root: string,
@@ -242,7 +245,11 @@ export function listEmailTemplates(
242
245
  const components = listComponents(root)
243
246
  const found = new Set<string>()
244
247
  for (const component of components)
245
- if (/\bEmailProps\b/.test(readFileSync(component.path, 'utf8'))) found.add(component.alias)
248
+ if (
249
+ component.alias.startsWith(EMAIL_FOLDER) &&
250
+ !/\bEmailLayoutProps\b/.test(readFileSync(component.path, 'utf8'))
251
+ )
252
+ found.add(component.alias)
246
253
  const present = new Set(components.map((component) => component.alias))
247
254
  for (const alias of named) if (present.has(alias)) found.add(alias)
248
255
  const items = [...found].sort().map((alias) => ({ alias, name: readableName(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.17.0'
743
+ export const VERSION = '0.18.0'
package/src/email-ses.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  postToProvider,
31
31
  validateMessage,
32
32
  } from './email.ts'
33
+ import { setting } from './settings.ts'
33
34
 
34
35
  export interface SesOptions extends EmailAdapterOptions {
35
36
  region: string
@@ -228,13 +229,13 @@ export function rawMime(
228
229
 
229
230
  export function sesEmail(options: SesOptions): EmailPort {
230
231
  if (!options.region) throw new Error('An SES e-mail adapter needs a region.')
231
- const accessKeyId = options.accessKeyId ?? Bun.env.AWS_ACCESS_KEY_ID ?? ''
232
- const secretAccessKey = options.secretAccessKey ?? Bun.env.AWS_SECRET_ACCESS_KEY ?? ''
232
+ const accessKeyId = options.accessKeyId ?? setting('AWS_ACCESS_KEY_ID') ?? ''
233
+ const secretAccessKey = options.secretAccessKey ?? setting('AWS_SECRET_ACCESS_KEY') ?? ''
233
234
  if (!accessKeyId || !secretAccessKey)
234
235
  throw new Error(
235
236
  'An SES e-mail adapter needs credentials: pass accessKeyId and secretAccessKey, or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. Instance roles are not resolved.',
236
237
  )
237
- const sessionToken = options.sessionToken ?? Bun.env.AWS_SESSION_TOKEN ?? undefined
238
+ const sessionToken = options.sessionToken ?? setting('AWS_SESSION_TOKEN') ?? undefined
238
239
  const call = options.fetch ?? fetch
239
240
  const base = options.endpoint ?? `https://email.${options.region}.amazonaws.com`
240
241
  assertSecureEndpoint(base, 'SES')
package/src/index.ts CHANGED
@@ -78,6 +78,7 @@ export * from './secrets.ts'
78
78
  export * from './seo.ts'
79
79
  export * from './server.ts'
80
80
  export * from './server-events.ts'
81
+ export * from './settings.ts'
81
82
  export * from './setup.ts'
82
83
  export * from './site-file.ts'
83
84
  export * from './site-replace.ts'
@@ -0,0 +1,921 @@
1
+ /**
2
+ * Every environment variable crombolo reads, declared once. `loadConfig` reads
3
+ * through `setting`, and the configuration reference is generated from the list.
4
+ */
5
+
6
+ export type SettingKind = 'string' | 'number' | 'boolean' | 'list' | 'enum'
7
+
8
+ export interface Setting {
9
+ env: string
10
+ kind: SettingKind
11
+ /** As a reader would see it; omitted when there is none. */
12
+ default?: string
13
+ /** The accepted values, for an enum. */
14
+ values?: readonly string[]
15
+ section: string
16
+ /** One or two plain sentences for a site owner. */
17
+ description: string
18
+ /** Never echoed. */
19
+ secret?: boolean
20
+ /** Who reads it; the server when unset. */
21
+ appliesTo?: 'server' | 'cli' | 'both'
22
+ }
23
+
24
+ const SECTIONS = [
25
+ 'Server',
26
+ 'Database',
27
+ 'Sign-in and secrets',
28
+ 'Back office and bundles',
29
+ 'Schema',
30
+ 'Components',
31
+ 'Media',
32
+ 'E-mail',
33
+ 'Members',
34
+ 'Public site',
35
+ 'Remote',
36
+ 'Logging',
37
+ 'Command line',
38
+ 'AWS credentials',
39
+ ] as const
40
+
41
+ /** Section headings, in display order. */
42
+ export const SETTING_SECTIONS: readonly string[] = SECTIONS
43
+
44
+ type Section = (typeof SECTIONS)[number]
45
+
46
+ const DECLARED = [
47
+ // Server
48
+ {
49
+ env: 'NODE_ENV',
50
+ kind: 'string',
51
+ section: 'Server',
52
+ description:
53
+ '`production` turns off the development conveniences: generated admin passwords, writable schema files, e-mail to the console and relaxed upgrade checks.',
54
+ },
55
+ {
56
+ env: 'PORT',
57
+ kind: 'number',
58
+ default: '8080',
59
+ section: 'Server',
60
+ description: 'The HTTP port. The application URL defaults to localhost on this port.',
61
+ },
62
+ {
63
+ env: 'CROMBOLO_SITE_NAME',
64
+ kind: 'string',
65
+ default: 'Crombolo',
66
+ section: 'Server',
67
+ description: "The site's name, shown in the back office and used when a page is shared.",
68
+ },
69
+ {
70
+ env: 'CROMBOLO_APPLICATION_URL',
71
+ kind: 'string',
72
+ default: 'http://localhost:<port>',
73
+ section: 'Server',
74
+ description: "The site's public address, which invitation and password-reset links point at.",
75
+ },
76
+ {
77
+ env: 'CROMBOLO_ROLE',
78
+ kind: 'enum',
79
+ values: ['all', 'api', 'web'],
80
+ default: 'all',
81
+ section: 'Server',
82
+ description:
83
+ 'What this process serves: everything, the back office and its API, or the public site alone. Splitting needs Postgres; a misspelt role stops the server.',
84
+ },
85
+ {
86
+ env: 'CROMBOLO_NODE_ID',
87
+ kind: 'string',
88
+ default: 'host:pid',
89
+ section: 'Server',
90
+ description:
91
+ "This node's name, stamped on schema syncs, content changes and the upgrade history.",
92
+ },
93
+ {
94
+ env: 'CROMBOLO_MAX_REQUEST_BODY_MB',
95
+ kind: 'number',
96
+ default: '32',
97
+ section: 'Server',
98
+ description: 'The largest request body the server reads, media uploads included.',
99
+ },
100
+ {
101
+ env: 'CROMBOLO_INSECURE_COOKIES',
102
+ kind: 'boolean',
103
+ default: 'false',
104
+ section: 'Server',
105
+ description:
106
+ 'Drops the secure cookie requirements, for a plain-http host that is not localhost.',
107
+ },
108
+
109
+ // Database
110
+ {
111
+ env: 'CROMBOLO_DB',
112
+ kind: 'enum',
113
+ values: ['sqlite', 'postgres'],
114
+ default: 'sqlite',
115
+ section: 'Database',
116
+ description: 'Which database engine the site runs on.',
117
+ appliesTo: 'both',
118
+ },
119
+ {
120
+ env: 'CROMBOLO_SQLITE_FILE',
121
+ kind: 'string',
122
+ default: 'crombolo.sqlite',
123
+ section: 'Database',
124
+ description: 'The SQLite file, relative to the site, or `:memory:`.',
125
+ appliesTo: 'both',
126
+ },
127
+ {
128
+ env: 'CROMBOLO_POSTGRES_URL',
129
+ kind: 'string',
130
+ section: 'Database',
131
+ description: 'The Postgres connection string. Required when the database is Postgres.',
132
+ secret: true,
133
+ appliesTo: 'both',
134
+ },
135
+ {
136
+ env: 'DATABASE_URL',
137
+ kind: 'string',
138
+ section: 'Database',
139
+ description: 'Used as the Postgres connection string when `CROMBOLO_POSTGRES_URL` is unset.',
140
+ secret: true,
141
+ appliesTo: 'both',
142
+ },
143
+ {
144
+ env: 'CROMBOLO_LOCK_TIMEOUT_SECONDS',
145
+ kind: 'number',
146
+ default: '15',
147
+ section: 'Database',
148
+ description: 'How long a content write waits for another before it gives up and answers 503.',
149
+ appliesTo: 'both',
150
+ },
151
+ {
152
+ env: 'CROMBOLO_PG_DUMP',
153
+ kind: 'string',
154
+ section: 'Database',
155
+ description:
156
+ 'A shell command that backs up Postgres before a repair, an upgrade or a purge. It is given `CROMBOLO_BACKUP_STAMP` and `CROMBOLO_BACKUP_REASON`; without it those commands need `--backup-taken`.',
157
+ appliesTo: 'both',
158
+ },
159
+ {
160
+ env: 'CROMBOLO_UPGRADE_POLICY',
161
+ kind: 'enum',
162
+ values: ['strict', 'pending-allowed'],
163
+ default: 'strict in production, else pending-allowed',
164
+ section: 'Database',
165
+ description: 'Whether findings that need a person stop an upgrade.',
166
+ appliesTo: 'both',
167
+ },
168
+ {
169
+ env: 'CROMBOLO_VERSION_CLEANUP',
170
+ kind: 'boolean',
171
+ default: 'false',
172
+ section: 'Database',
173
+ description: 'Runs the hourly clean-up of old content versions.',
174
+ },
175
+ {
176
+ env: 'CROMBOLO_VERSION_CLEANUP_KEEP_ALL_DAYS',
177
+ kind: 'number',
178
+ default: '7',
179
+ section: 'Database',
180
+ description:
181
+ "The clean-up keeps every version newer than this many days. A content type's own policy wins.",
182
+ },
183
+ {
184
+ env: 'CROMBOLO_VERSION_CLEANUP_KEEP_LATEST_DAYS',
185
+ kind: 'number',
186
+ default: '90',
187
+ section: 'Database',
188
+ description: "After that, it keeps each day's latest version for this many days.",
189
+ },
190
+ {
191
+ env: 'CROMBOLO_AUDIT_RETENTION_DAYS',
192
+ kind: 'number',
193
+ default: '365',
194
+ section: 'Database',
195
+ description: 'How many days the audit log keeps an entry. `0` keeps them all.',
196
+ },
197
+
198
+ // Sign-in and secrets
199
+ {
200
+ env: 'CROMBOLO_ADMIN_LOGIN',
201
+ kind: 'string',
202
+ default: 'admin@crombolo.local',
203
+ section: 'Sign-in and secrets',
204
+ description: 'The login of the first administrator.',
205
+ },
206
+ {
207
+ env: 'CROMBOLO_ADMIN_PASSWORD',
208
+ kind: 'string',
209
+ default: 'generated in development',
210
+ section: 'Sign-in and secrets',
211
+ description:
212
+ "The first administrator's password, used only when the database is created. Unset in production, the site is set up in the browser.",
213
+ secret: true,
214
+ },
215
+ {
216
+ env: 'CROMBOLO_SETUP_CODE',
217
+ kind: 'string',
218
+ section: 'Sign-in and secrets',
219
+ description:
220
+ 'Guards "Set up your new site" in production: 16 or more characters, chosen by you. Remove it once the site is set up.',
221
+ secret: true,
222
+ },
223
+ {
224
+ env: 'CROMBOLO_API_CLIENT_ID',
225
+ kind: 'string',
226
+ section: 'Sign-in and secrets',
227
+ description:
228
+ 'An API client an unattended install creates in the administrators group; it starts `crombolo-back-office-`. With it, no password administrator is created unless `CROMBOLO_ADMIN_PASSWORD` is set.',
229
+ appliesTo: 'both',
230
+ },
231
+ {
232
+ env: 'CROMBOLO_API_CLIENT_SECRET',
233
+ kind: 'string',
234
+ section: 'Sign-in and secrets',
235
+ description:
236
+ "That client's secret, set together with the id. It is used once, when the client is created.",
237
+ secret: true,
238
+ },
239
+ {
240
+ env: 'CROMBOLO_LOCAL_ADMIN',
241
+ kind: 'boolean',
242
+ default: 'false',
243
+ section: 'Sign-in and secrets',
244
+ description:
245
+ 'Creates the password administrator even when an API client is set. Honoured only on localhost.',
246
+ },
247
+ {
248
+ env: 'CROMBOLO_SECRET_KEY',
249
+ kind: 'string',
250
+ section: 'Sign-in and secrets',
251
+ description:
252
+ "Seals secrets kept in the database, such as a webhook's headers and two-factor keys; 32 or more characters. Features that need it are not offered without it.",
253
+ secret: true,
254
+ },
255
+ {
256
+ env: 'CROMBOLO_USERNAME_IS_EMAIL',
257
+ kind: 'boolean',
258
+ default: 'true',
259
+ section: 'Sign-in and secrets',
260
+ description: "A back office user's login is their e-mail address.",
261
+ },
262
+
263
+ // Back office and bundles
264
+ {
265
+ env: 'CROMBOLO_BACKOFFICE_PATH',
266
+ kind: 'string',
267
+ default: '/crombolo',
268
+ section: 'Back office and bundles',
269
+ description: 'Where the back office is served.',
270
+ },
271
+ {
272
+ env: 'CROMBOLO_CSS_DIR',
273
+ kind: 'string',
274
+ default: 'css',
275
+ section: 'Back office and bundles',
276
+ description: 'The stylesheets the Settings section edits, served at `/css/`.',
277
+ },
278
+ {
279
+ env: 'CROMBOLO_SCRIPTS_DIR',
280
+ kind: 'string',
281
+ default: 'scripts',
282
+ section: 'Back office and bundles',
283
+ description: 'The scripts the Settings section edits, served at `/scripts/`.',
284
+ },
285
+ {
286
+ env: 'CROMBOLO_APPLY_BUNDLE_CONTENT',
287
+ kind: 'boolean',
288
+ default: 'true',
289
+ section: 'Back office and bundles',
290
+ description:
291
+ 'Installing a bundle from the Marketplace also applies the schema, components and content it carries.',
292
+ },
293
+ {
294
+ env: 'CROMBOLO_RUNTIME_INSTALLS',
295
+ kind: 'boolean',
296
+ default: 'true, false with a schema store',
297
+ section: 'Back office and bundles',
298
+ description:
299
+ 'The Packages section installs and removes packages on the running site. Off, they are added to `package.json` and deployed.',
300
+ },
301
+ {
302
+ env: 'CROMBOLO_MARKETPLACE_URL',
303
+ kind: 'string',
304
+ default: 'https://www.npmjs.com/search?q=keywords%3Acrombolo-bundle',
305
+ section: 'Back office and bundles',
306
+ description: 'Where the Marketplace sends someone browsing for bundles.',
307
+ },
308
+ {
309
+ env: 'CROMBOLO_PACKAGE_KEYWORD',
310
+ kind: 'string',
311
+ default: 'crombolo-bundle',
312
+ section: 'Back office and bundles',
313
+ description: 'The npm keyword a bundle publishes under to be found by the Marketplace.',
314
+ },
315
+ {
316
+ env: 'CROMBOLO_NPM_REGISTRY',
317
+ kind: 'string',
318
+ default: 'https://registry.npmjs.org',
319
+ section: 'Back office and bundles',
320
+ description: 'The registry the Marketplace searches, such as a mirror.',
321
+ },
322
+
323
+ // Schema
324
+ {
325
+ env: 'CROMBOLO_SCHEMA_DIR',
326
+ kind: 'string',
327
+ default: 'schema',
328
+ section: 'Schema',
329
+ description: 'Where the schema files live. Nothing is synced when the folder is absent.',
330
+ },
331
+ {
332
+ env: 'CROMBOLO_SCHEMA_REVISION',
333
+ kind: 'string',
334
+ default: '0',
335
+ section: 'Schema',
336
+ description: 'Set by a deploy, to order deploys of the same schema version.',
337
+ },
338
+ {
339
+ env: 'CROMBOLO_SCHEMA_CACHE_DIR',
340
+ kind: 'string',
341
+ default: '<temp>/crombolo-schema',
342
+ section: 'Schema',
343
+ description: 'Where a schema kept in cloud storage is copied to when the server starts.',
344
+ },
345
+ {
346
+ env: 'CROMBOLO_SCHEMA_WRITABLE',
347
+ kind: 'boolean',
348
+ default: 'true unless production or with a schema store',
349
+ section: 'Schema',
350
+ description: 'Whether the back office may change the schema files.',
351
+ },
352
+ {
353
+ env: 'CROMBOLO_SYNC_SCHEMA_AT_BOOT',
354
+ kind: 'boolean',
355
+ default: 'true',
356
+ section: 'Schema',
357
+ description:
358
+ 'Syncs the schema files when the server starts. Turn it off when a deploy runs `crombolo types sync` itself.',
359
+ },
360
+
361
+ // Components
362
+ {
363
+ env: 'CROMBOLO_COMPONENTS_DIR',
364
+ kind: 'string',
365
+ default: 'components',
366
+ section: 'Components',
367
+ description: "Where the site's page views and components live.",
368
+ },
369
+ {
370
+ env: 'CROMBOLO_COMPONENTS_CACHE_DIR',
371
+ kind: 'string',
372
+ default: '.crombolo/components',
373
+ section: 'Components',
374
+ description:
375
+ 'Where components are copied so that an edit is picked up without a restart. Inside the site, and cleared when the server starts.',
376
+ },
377
+ {
378
+ env: 'CROMBOLO_COMPONENTS_GATE_MS',
379
+ kind: 'number',
380
+ default: '5000, 500 in development',
381
+ section: 'Components',
382
+ description: 'How often a node looks for changed components, in milliseconds.',
383
+ },
384
+ {
385
+ env: 'CROMBOLO_COMPONENTS_SWAP_MS',
386
+ kind: 'number',
387
+ default: '10000, 0 in development',
388
+ section: 'Components',
389
+ description:
390
+ 'The least time between two changes being picked up, in milliseconds. A save made in the back office is never held back by it.',
391
+ },
392
+ {
393
+ env: 'CROMBOLO_COMPONENTS_GENERATION_LIMIT',
394
+ kind: 'number',
395
+ default: '250',
396
+ section: 'Components',
397
+ description:
398
+ 'How many changes a node picks up before it stops taking more and keeps serving the last.',
399
+ },
400
+ {
401
+ env: 'CROMBOLO_COMPONENTS_KEEP',
402
+ kind: 'number',
403
+ default: '3, all in development',
404
+ section: 'Components',
405
+ description: 'How many earlier copies are kept on disk, for an instant return to one.',
406
+ },
407
+ {
408
+ env: 'CROMBOLO_RENDER_TIMEOUT_MS',
409
+ kind: 'number',
410
+ default: '10000',
411
+ section: 'Components',
412
+ description:
413
+ 'How long a page may take to render, layouts included, before it fails, in milliseconds. `0` for no limit.',
414
+ },
415
+
416
+ // Media
417
+ {
418
+ env: 'CROMBOLO_MEDIA_DIR',
419
+ kind: 'string',
420
+ default: 'media',
421
+ section: 'Media',
422
+ description: 'Where uploaded files live on this disk, served at `/media/`.',
423
+ },
424
+ {
425
+ env: 'CROMBOLO_MEDIA_STORE',
426
+ kind: 'enum',
427
+ values: ['filesystem', 's3', 'azure'],
428
+ default: 'filesystem',
429
+ section: 'Media',
430
+ description: 'Where media files are kept. An unrecognised value falls back to the file system.',
431
+ },
432
+ {
433
+ env: 'CROMBOLO_MEDIA_PUBLIC_URL',
434
+ kind: 'string',
435
+ section: 'Media',
436
+ description:
437
+ 'A CDN in front of the media folder: browsers are sent there instead of being served by this server.',
438
+ },
439
+ {
440
+ env: 'CROMBOLO_MEDIA_S3_BUCKET',
441
+ kind: 'string',
442
+ section: 'Media',
443
+ description: 'S3: the bucket.',
444
+ },
445
+ {
446
+ env: 'CROMBOLO_MEDIA_S3_REGION',
447
+ kind: 'string',
448
+ section: 'Media',
449
+ description: "S3: the bucket's region.",
450
+ },
451
+ {
452
+ env: 'CROMBOLO_MEDIA_S3_ENDPOINT',
453
+ kind: 'string',
454
+ section: 'Media',
455
+ description: 'S3: the endpoint, for an S3-compatible store such as R2, Spaces or MinIO.',
456
+ },
457
+ {
458
+ env: 'CROMBOLO_MEDIA_S3_ACCESS_KEY_ID',
459
+ kind: 'string',
460
+ section: 'Media',
461
+ description: "S3: the access key. Unset, the client's usual credentials are used.",
462
+ },
463
+ {
464
+ env: 'CROMBOLO_MEDIA_S3_SECRET_ACCESS_KEY',
465
+ kind: 'string',
466
+ section: 'Media',
467
+ description: "S3: the access key's secret.",
468
+ secret: true,
469
+ },
470
+ {
471
+ env: 'CROMBOLO_MEDIA_S3_SESSION_TOKEN',
472
+ kind: 'string',
473
+ section: 'Media',
474
+ description: 'S3: a session token, for temporary credentials.',
475
+ secret: true,
476
+ },
477
+ {
478
+ env: 'CROMBOLO_MEDIA_S3_PREFIX',
479
+ kind: 'string',
480
+ section: 'Media',
481
+ description: 'S3: a folder inside the bucket to keep media under.',
482
+ },
483
+ {
484
+ env: 'CROMBOLO_MEDIA_S3_PUBLIC_URL',
485
+ kind: 'string',
486
+ section: 'Media',
487
+ description: 'S3: a public address for the bucket, such as a CDN, that browsers are sent to.',
488
+ },
489
+ {
490
+ env: 'CROMBOLO_MEDIA_S3_PRESIGN_SECONDS',
491
+ kind: 'number',
492
+ section: 'Media',
493
+ description:
494
+ 'S3: sends browsers to a signed bucket address that lasts this many seconds. Use this or a public address, not both.',
495
+ },
496
+ {
497
+ env: 'CROMBOLO_MEDIA_AZURE_ACCOUNT',
498
+ kind: 'string',
499
+ section: 'Media',
500
+ description: 'Azure: the storage account.',
501
+ },
502
+ {
503
+ env: 'CROMBOLO_MEDIA_AZURE_CONTAINER',
504
+ kind: 'string',
505
+ default: 'media',
506
+ section: 'Media',
507
+ description: 'Azure: the blob container.',
508
+ },
509
+ {
510
+ env: 'CROMBOLO_MEDIA_AZURE_KEY',
511
+ kind: 'string',
512
+ section: 'Media',
513
+ description: "Azure: the account's key.",
514
+ secret: true,
515
+ },
516
+ {
517
+ env: 'CROMBOLO_MEDIA_AZURE_SAS',
518
+ kind: 'string',
519
+ section: 'Media',
520
+ description: 'Azure: a shared access signature, instead of the key.',
521
+ secret: true,
522
+ },
523
+ {
524
+ env: 'CROMBOLO_MEDIA_AZURE_ENDPOINT',
525
+ kind: 'string',
526
+ section: 'Media',
527
+ description: 'Azure: the blob endpoint, for a sovereign cloud or a local emulator.',
528
+ },
529
+ {
530
+ env: 'CROMBOLO_MEDIA_AZURE_PREFIX',
531
+ kind: 'string',
532
+ section: 'Media',
533
+ description: 'Azure: a folder inside the container to keep media under.',
534
+ },
535
+ {
536
+ env: 'CROMBOLO_MEDIA_AZURE_PUBLIC_URL',
537
+ kind: 'string',
538
+ section: 'Media',
539
+ description:
540
+ 'Azure: a public address for the container, such as a CDN, that browsers are sent to.',
541
+ },
542
+
543
+ // E-mail
544
+ {
545
+ env: 'CROMBOLO_EMAIL_PROVIDER',
546
+ kind: 'enum',
547
+ values: ['none', 'resend', 'postmark', 'ses', 'custom'],
548
+ default: 'none',
549
+ section: 'E-mail',
550
+ description:
551
+ 'How e-mail is sent. Without one, invitations, password resets and form e-mails are unavailable; development prints messages to the console.',
552
+ },
553
+ {
554
+ env: 'CROMBOLO_EMAIL_FROM',
555
+ kind: 'string',
556
+ section: 'E-mail',
557
+ description: 'The sender address. Every provider needs it, and verifies its domain.',
558
+ },
559
+ {
560
+ env: 'CROMBOLO_EMAIL_FROM_NAME',
561
+ kind: 'string',
562
+ section: 'E-mail',
563
+ description: 'The name shown beside the sender address.',
564
+ },
565
+ {
566
+ env: 'CROMBOLO_EMAIL_API_KEY',
567
+ kind: 'string',
568
+ section: 'E-mail',
569
+ description:
570
+ "The provider's key: Resend's API key, Postmark's server token, or a bearer token for a custom endpoint.",
571
+ secret: true,
572
+ },
573
+ {
574
+ env: 'CROMBOLO_EMAIL_URL',
575
+ kind: 'string',
576
+ default: "the provider's own API",
577
+ section: 'E-mail',
578
+ description:
579
+ 'The address messages are sent to. Required for a custom endpoint, an override for the others. Must be HTTPS unless it is on this machine.',
580
+ },
581
+ {
582
+ env: 'CROMBOLO_EMAIL_POSTMARK_STREAM',
583
+ kind: 'string',
584
+ default: 'outbound',
585
+ section: 'E-mail',
586
+ description: 'Postmark: the message stream to send on.',
587
+ },
588
+ {
589
+ env: 'CROMBOLO_EMAIL_SES_REGION',
590
+ kind: 'string',
591
+ default: 'AWS_REGION',
592
+ section: 'E-mail',
593
+ description: 'SES: the region to send from.',
594
+ },
595
+ {
596
+ env: 'CROMBOLO_EMAIL_SES_ACCESS_KEY_ID',
597
+ kind: 'string',
598
+ default: 'AWS_ACCESS_KEY_ID',
599
+ section: 'E-mail',
600
+ description: 'SES: the access key, when it differs from the standard AWS one.',
601
+ },
602
+ {
603
+ env: 'CROMBOLO_EMAIL_SES_SECRET_ACCESS_KEY',
604
+ kind: 'string',
605
+ default: 'AWS_SECRET_ACCESS_KEY',
606
+ section: 'E-mail',
607
+ description: "SES: that access key's secret.",
608
+ secret: true,
609
+ },
610
+ {
611
+ env: 'CROMBOLO_EMAIL_SES_CONFIGURATION_SET',
612
+ kind: 'string',
613
+ section: 'E-mail',
614
+ description: 'SES: the configuration set that governs sending.',
615
+ },
616
+ {
617
+ env: 'CROMBOLO_EMAIL_SES_ENDPOINT',
618
+ kind: 'string',
619
+ default: 'the regional SES address',
620
+ section: 'E-mail',
621
+ description: 'SES: a private endpoint to send through instead.',
622
+ },
623
+
624
+ // Members
625
+ {
626
+ env: 'CROMBOLO_ALLOW_MEMBER_REGISTRATION',
627
+ kind: 'boolean',
628
+ default: 'false',
629
+ section: 'Members',
630
+ description: 'Lets visitors register as members of the site.',
631
+ },
632
+ {
633
+ env: 'CROMBOLO_MEMBER_REGISTRATION_TYPE',
634
+ kind: 'string',
635
+ default: 'Member',
636
+ section: 'Members',
637
+ description: 'The member type a registration creates, by alias.',
638
+ },
639
+ {
640
+ env: 'CROMBOLO_MEMBER_REGISTRATION_GROUPS',
641
+ kind: 'list',
642
+ section: 'Members',
643
+ description: 'The member groups a registration joins, separated by commas.',
644
+ },
645
+ {
646
+ env: 'CROMBOLO_MEMBER_SESSION_MINUTES',
647
+ kind: 'number',
648
+ default: '20160',
649
+ section: 'Members',
650
+ description: "How long a member's sign-in lasts, in minutes. The default is 14 days.",
651
+ },
652
+ {
653
+ env: 'CROMBOLO_MAX_FAILED_PASSWORD_ATTEMPTS',
654
+ kind: 'number',
655
+ default: '5',
656
+ section: 'Members',
657
+ description: 'Failed member sign-ins before the member is locked out. `0` never locks out.',
658
+ },
659
+
660
+ // Public site
661
+ {
662
+ env: 'CROMBOLO_INDEXABLE',
663
+ kind: 'boolean',
664
+ default: 'true',
665
+ section: 'Public site',
666
+ description:
667
+ 'Off keeps search engines out entirely, for a staging site: robots are refused, there is no sitemap and every page asks not to be indexed.',
668
+ },
669
+ {
670
+ env: 'CROMBOLO_SITEMAP',
671
+ kind: 'boolean',
672
+ default: 'true',
673
+ section: 'Public site',
674
+ description: 'Serves `/sitemap.xml`.',
675
+ },
676
+ {
677
+ env: 'CROMBOLO_TRACK_REDIRECTS',
678
+ kind: 'boolean',
679
+ default: 'true',
680
+ section: 'Public site',
681
+ description: 'Renaming or moving a page records a redirect from the address it had.',
682
+ },
683
+ {
684
+ env: 'CROMBOLO_DELIVERY_API',
685
+ kind: 'boolean',
686
+ default: 'false',
687
+ section: 'Public site',
688
+ description: 'Turns on the Delivery API, which serves content as JSON.',
689
+ },
690
+ {
691
+ env: 'CROMBOLO_DELIVERY_API_KEY',
692
+ kind: 'string',
693
+ section: 'Public site',
694
+ description:
695
+ 'The key that unlocks a private Delivery API, and previews of unpublished content.',
696
+ secret: true,
697
+ },
698
+ {
699
+ env: 'CROMBOLO_DELIVERY_API_PUBLIC',
700
+ kind: 'boolean',
701
+ default: 'true',
702
+ section: 'Public site',
703
+ description: 'Off, every Delivery API request needs the key.',
704
+ },
705
+ {
706
+ env: 'CROMBOLO_DELIVERY_API_MEDIA',
707
+ kind: 'boolean',
708
+ default: 'false',
709
+ section: 'Public site',
710
+ description: 'The Delivery API serves media as well as content.',
711
+ },
712
+
713
+ // Remote
714
+ {
715
+ env: 'CROMBOLO_REMOTE_ENABLED',
716
+ kind: 'boolean',
717
+ default: 'false',
718
+ section: 'Remote',
719
+ description: 'Lets `crombolo pull` and `crombolo push` reach this site.',
720
+ },
721
+ {
722
+ env: 'CROMBOLO_REMOTE_ENVIRONMENT',
723
+ kind: 'string',
724
+ default: 'production',
725
+ section: 'Remote',
726
+ description:
727
+ 'The name this site goes by for pull and push: lower case letters, digits and dashes.',
728
+ },
729
+ {
730
+ env: 'CROMBOLO_REMOTE_PROTECTED',
731
+ kind: 'boolean',
732
+ default: 'true',
733
+ section: 'Remote',
734
+ description: 'A push here needs the protected-push permission.',
735
+ },
736
+ {
737
+ env: 'CROMBOLO_REMOTE_APPROVALS',
738
+ kind: 'boolean',
739
+ default: 'false',
740
+ section: 'Remote',
741
+ description:
742
+ 'A push to a protected site waits for someone else to approve it. Needs `CROMBOLO_SECRET_KEY`.',
743
+ },
744
+ {
745
+ env: 'CROMBOLO_REMOTE_MAX_UPLOAD_GB',
746
+ kind: 'number',
747
+ default: '50',
748
+ section: 'Remote',
749
+ description:
750
+ 'The largest push or media file a client may upload, in gigabytes. Every other request keeps the usual body limit.',
751
+ },
752
+
753
+ // Logging
754
+ {
755
+ env: 'CROMBOLO_LOGS_DIR',
756
+ kind: 'string',
757
+ default: 'logs',
758
+ section: 'Logging',
759
+ description: 'Where log files are written, a file a day, read by the back office log viewer.',
760
+ },
761
+ {
762
+ env: 'CROMBOLO_LOG_LEVEL',
763
+ kind: 'enum',
764
+ values: ['trace', 'debug', 'info', 'warning', 'error', 'fatal'],
765
+ default: 'info',
766
+ section: 'Logging',
767
+ description: 'The least severe level logged.',
768
+ },
769
+ {
770
+ env: 'CROMBOLO_LOG_TO_CONSOLE',
771
+ kind: 'boolean',
772
+ default: 'true',
773
+ section: 'Logging',
774
+ description: 'Log events are also printed to the console.',
775
+ },
776
+ {
777
+ env: 'CROMBOLO_TRACE_REQUESTS',
778
+ kind: 'boolean',
779
+ default: 'false',
780
+ section: 'Logging',
781
+ description: 'Prints a line on the console as each request arrives and as it is answered.',
782
+ },
783
+ {
784
+ env: 'CROMBOLO_HEAP_INTERVAL_SECONDS',
785
+ kind: 'number',
786
+ default: '0',
787
+ section: 'Logging',
788
+ description:
789
+ 'Every this many seconds, prints the memory in use and the requests in flight. `0` is off.',
790
+ },
791
+
792
+ // Command line
793
+ {
794
+ env: 'CROMBOLO_BOOT_ATTEMPTS',
795
+ kind: 'number',
796
+ default: '10',
797
+ section: 'Command line',
798
+ description:
799
+ 'How many times `crombolo start` tries to start the site, waiting longer each time, before it exits.',
800
+ appliesTo: 'cli',
801
+ },
802
+ {
803
+ env: 'CROMBOLO_ACTOR',
804
+ kind: 'string',
805
+ default: 'the account running the command',
806
+ section: 'Command line',
807
+ description: 'Who the audit log records for a command, for a pipeline acting for someone.',
808
+ appliesTo: 'cli',
809
+ },
810
+ {
811
+ env: 'CROMBOLO_BUNDLE_PASSPHRASE',
812
+ kind: 'string',
813
+ section: 'Command line',
814
+ description: 'The passphrase for an encrypted content file, instead of being asked for it.',
815
+ secret: true,
816
+ appliesTo: 'cli',
817
+ },
818
+ {
819
+ env: 'CROMBOLO_DEBUG',
820
+ kind: 'boolean',
821
+ default: 'false',
822
+ section: 'Command line',
823
+ description: 'Adds the stack trace to an unexpected error.',
824
+ appliesTo: 'cli',
825
+ },
826
+ {
827
+ env: 'CROMBOLO_TEMPLATES_DIR',
828
+ kind: 'string',
829
+ default: 'the templates the command line ships',
830
+ section: 'Command line',
831
+ description: 'Where `crombolo init` finds its site templates, for trying a changed one.',
832
+ appliesTo: 'cli',
833
+ },
834
+
835
+ // AWS credentials
836
+ {
837
+ env: 'AWS_REGION',
838
+ kind: 'string',
839
+ section: 'AWS credentials',
840
+ description: 'The AWS region, for SES e-mail and the Bedrock assistant when they set none.',
841
+ },
842
+ {
843
+ env: 'AWS_PROFILE',
844
+ kind: 'string',
845
+ section: 'AWS credentials',
846
+ description: 'The AWS profile the Bedrock assistant signs in with.',
847
+ },
848
+ {
849
+ env: 'AWS_ACCESS_KEY_ID',
850
+ kind: 'string',
851
+ section: 'AWS credentials',
852
+ description: 'A static AWS access key, for SES e-mail and the Bedrock assistant.',
853
+ },
854
+ {
855
+ env: 'AWS_SECRET_ACCESS_KEY',
856
+ kind: 'string',
857
+ section: 'AWS credentials',
858
+ description: "That access key's secret.",
859
+ secret: true,
860
+ },
861
+ {
862
+ env: 'AWS_SESSION_TOKEN',
863
+ kind: 'string',
864
+ section: 'AWS credentials',
865
+ description: 'A session token, for temporary AWS credentials.',
866
+ secret: true,
867
+ },
868
+ ] as const satisfies readonly (Setting & { section: Section })[]
869
+
870
+ export const SETTINGS: readonly Setting[] = DECLARED
871
+
872
+ /** The name of a declared setting. */
873
+ export type SettingName = (typeof DECLARED)[number]['env']
874
+
875
+ const NAMES: ReadonlySet<string> = new Set(SETTINGS.map((entry) => entry.env))
876
+
877
+ /** The raw environment value of a declared setting; unset is undefined. */
878
+ export function setting(name: SettingName): string | undefined {
879
+ if (!NAMES.has(name)) throw new Error(`${name} is not a declared setting.`)
880
+ return Bun.env[name]
881
+ }
882
+
883
+ function cell(text: string): string {
884
+ return text.replaceAll('|', '\\|').replaceAll('\n', ' ')
885
+ }
886
+
887
+ function shownDefault(entry: Setting): string {
888
+ if (entry.default === undefined) return '—'
889
+ return /\s/.test(entry.default) ? cell(entry.default) : `\`${cell(entry.default)}\``
890
+ }
891
+
892
+ function shownKind(entry: Setting): string {
893
+ if (entry.kind === 'enum') return (entry.values ?? []).map((value) => `\`${value}\``).join(', ')
894
+ if (entry.kind === 'boolean') return '`true` / `false`'
895
+ return entry.kind
896
+ }
897
+
898
+ function shownDescription(entry: Setting): string {
899
+ const notes: string[] = []
900
+ if (entry.appliesTo === 'cli') notes.push('Read by the command line.')
901
+ if (entry.appliesTo === 'both') notes.push('Read by the server and the command line.')
902
+ if (entry.secret) notes.push('Kept secret: never printed.')
903
+ return cell([entry.description, ...notes].join(' '))
904
+ }
905
+
906
+ /** The settings reference: a table per section, in display order. */
907
+ export function settingsMarkdown(): string {
908
+ const parts: string[] = []
909
+ for (const section of SETTING_SECTIONS) {
910
+ const entries = SETTINGS.filter((entry) => entry.section === section)
911
+ if (entries.length === 0) continue
912
+ parts.push(`### ${section}`, '')
913
+ parts.push('| Setting | Takes | Default | Description |', '| --- | --- | --- | --- |')
914
+ for (const entry of entries)
915
+ parts.push(
916
+ `| \`${entry.env}\` | ${shownKind(entry)} | ${shownDefault(entry)} | ${shownDescription(entry)} |`,
917
+ )
918
+ parts.push('')
919
+ }
920
+ return parts.join('\n').trimEnd()
921
+ }