@crombolo/server 0.13.2 → 0.14.4

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.
@@ -111,6 +111,22 @@ export async function materialiseSchema(store: SchemaStore, dir: string): Promis
111
111
  return keys
112
112
  }
113
113
 
114
+ /**
115
+ * Points a config at its store's schema: materialises the store into
116
+ * `schemaCacheDir` and makes that the schema directory. A no-op without a store;
117
+ * answers how many files were copied.
118
+ */
119
+ export async function useSchemaStore(config: {
120
+ schemaStore: SchemaStore | undefined
121
+ schemaCacheDir: string
122
+ schemaDir: string
123
+ }): Promise<number | undefined> {
124
+ if (!config.schemaStore) return undefined
125
+ const copied = await materialiseSchema(config.schemaStore, config.schemaCacheDir)
126
+ config.schemaDir = config.schemaCacheDir
127
+ return copied.length
128
+ }
129
+
114
130
  export interface PublishReport {
115
131
  written: string[]
116
132
  removed: string[]
package/src/schema.ts CHANGED
@@ -43,7 +43,7 @@ import { listComponents } from './components.ts'
43
43
  import { type CromboloConfig, VERSION } from './config.ts'
44
44
  import { formTypesFor } from './form-types.ts'
45
45
  import { logger } from './logging.ts'
46
- import { materialiseSchema } from './schema-store.ts'
46
+ import { useSchemaStore } from './schema-store.ts'
47
47
 
48
48
  export interface SchemaBoot {
49
49
  /** The state this node runs at: the files' version, the deploy's revision. */
@@ -397,13 +397,11 @@ export async function importSchema(
397
397
  db: Db,
398
398
  config: CromboloConfig,
399
399
  boot: { nodeId: string; nodeState: NodeSchemaState },
400
- options: { dryRun?: boolean } = {},
400
+ options: { dryRun?: boolean; materialise?: boolean } = {},
401
401
  ): Promise<SchemaImportResult> {
402
- let materialised: number | undefined
403
- if (config.schemaStore) {
404
- materialised = (await materialiseSchema(config.schemaStore, config.schemaCacheDir)).length
405
- config.schemaDir = config.schemaCacheDir
406
- }
402
+ // A caller that has just written into the directory says not to, or the store's
403
+ // older copy would be laid over its write.
404
+ const materialised = options.materialise === false ? undefined : await useSchemaStore(config)
407
405
  if (!existsSync(config.schemaDir)) {
408
406
  return { materialised, classification: 'none', findings: [], report: undefined }
409
407
  }
package/src/server.ts CHANGED
@@ -31,7 +31,9 @@ import {
31
31
  createExtensionRegistry,
32
32
  isSpaRoute,
33
33
  renderBackOfficeShell,
34
+ renderDeviceShell,
34
35
  renderLoginShell,
36
+ renderRemoteApprovalShell,
35
37
  resolveGraphic,
36
38
  resolveLoginAsset,
37
39
  resolveStaticFile,
@@ -46,6 +48,7 @@ import {
46
48
  hasAccessToSensitiveData,
47
49
  localReturnUrl,
48
50
  MANAGEMENT_API_PREFIX,
51
+ ObjectTypes,
49
52
  PREVIEW_HUB_PATH,
50
53
  SERVER_EVENT_HUB_PATH,
51
54
  } from '@crombolo/core'
@@ -53,6 +56,7 @@ import {
53
56
  appendCacheInstruction,
54
57
  createTablesCapability,
55
58
  type Db,
59
+ ensureKeyValue,
56
60
  FormEntryRepository,
57
61
  FormWorkflowRepository,
58
62
  PublicAccessRepository,
@@ -60,7 +64,9 @@ import {
60
64
  readLedger,
61
65
  readReport,
62
66
  readUrlOptions,
67
+ SiteSearch,
63
68
  TransferRunRepository,
69
+ UserGroupRepository,
64
70
  type WritesPaused,
65
71
  } from '@crombolo/data'
66
72
  import {
@@ -69,6 +75,7 @@ import {
69
75
  FORM_SCRIPT,
70
76
  FORM_SCRIPT_PATH,
71
77
  FORM_SUBMIT_PATH,
78
+ MEMBER_EXTERNAL_SIGN_IN_PATH,
72
79
  PublishedCache,
73
80
  Renderer,
74
81
  type SnapshotStatus,
@@ -90,7 +97,13 @@ import { CommandError, installBundleContent } from './bundle-install.ts'
90
97
  import { bundleChains, bundleTablesReport } from './bundle-tables.ts'
91
98
  import { createBundleRedirects, createServerBundles, validateServerBundles } from './bundles.ts'
92
99
  import { busyResponse } from './busy.ts'
93
- import { type CromboloConfig, loadConfig, type ServerRole, VERSION } from './config.ts'
100
+ import {
101
+ type CromboloConfig,
102
+ listeningBodyLimit,
103
+ loadConfig,
104
+ type ServerRole,
105
+ VERSION,
106
+ } from './config.ts'
94
107
  import { attachDatabase, bootstrapDatabase } from './database.ts'
95
108
  import { createDeliveryMemberSecurity } from './delivery-members.ts'
96
109
  import { createDiagnostics } from './diagnostics.ts'
@@ -131,6 +144,8 @@ import { noNodesPage } from './no-nodes.ts'
131
144
  import { NotImplementedLog } from './not-implemented.ts'
132
145
  import { createDeps, PREVIEW_COOKIE, resolveUserLinkSender } from './ports.ts'
133
146
  import { createRedirectTracker } from './redirects.ts'
147
+ import { createRemote } from './remote.ts'
148
+ import { withRemoteMedia } from './remote-media.ts'
134
149
  import { handleTransferAction, pendingTransfers } from './transfer-dashboard.ts'
135
150
  import {
136
151
  changeUrlOptions,
@@ -141,6 +156,12 @@ import {
141
156
 
142
157
  export { PREVIEW_COOKIE }
143
158
 
159
+ import { BACKOFFICE_EXTERNAL_PATH, backofficeAudience } from './backoffice-external.ts'
160
+ import { createExternalSignIn } from './external-sign-in.ts'
161
+ import {
162
+ createBundleIdentityProviders,
163
+ IdentityProviderOverrides,
164
+ } from './identity-provider-overrides.ts'
144
165
  import { syncConfiguredRedirects } from './redirects.ts'
145
166
  import {
146
167
  bootSchema,
@@ -149,7 +170,7 @@ import {
149
170
  type SchemaBoot,
150
171
  watchSchema,
151
172
  } from './schema.ts'
152
- import { materialiseSchema, publishSchema, type SchemaStore } from './schema-store.ts'
173
+ import { publishSchema, type SchemaStore, useSchemaStore } from './schema-store.ts'
153
174
  import { createSeoHost, originOf } from './seo.ts'
154
175
  import {
155
176
  combineHubs,
@@ -158,6 +179,8 @@ import {
158
179
  type ServerEventHub,
159
180
  } from './server-events.ts'
160
181
  import { createSetupRoutes } from './setup.ts'
182
+ import { siteSearchFor } from './site-search.ts'
183
+ import { TwoFactorService, USER_TWO_FACTOR_PROVIDER } from './two-factor.ts'
161
184
  import { summariseUpgrades } from './upgrade-summary.ts'
162
185
  import { assertViewRuntime } from './view-runtime.ts'
163
186
  import { bundleEventProblems, createWebhookService, webhookStoreView } from './webhooks.ts'
@@ -305,15 +328,13 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
305
328
  // A configured schema store is copied down before anything reads `schema/`, and
306
329
  // becomes the schema directory for this boot. Everything after here — loading,
307
330
  // validation, hashing, sync, the writer — works on files exactly as it always has.
308
- if (config.schemaStore) {
309
- const copied = await materialiseSchema(config.schemaStore, config.schemaCacheDir)
310
- config.schemaDir = config.schemaCacheDir
331
+ const copied = await useSchemaStore(config)
332
+ if (config.schemaStore)
311
333
  schemaLog.info('Schema read from {store}: {count} files in {dir}', {
312
334
  store: config.schemaStore.description,
313
- count: copied.length,
335
+ count: copied,
314
336
  dir: config.schemaCacheDir,
315
337
  })
316
- }
317
338
  // A node that does not own the schema still loads it — the renderer needs the
318
339
  // property definitions — but writes none of it.
319
340
  const schema = await bootSchema(db, owns ? config : { ...config, syncSchemaAtBoot: false })
@@ -338,10 +359,29 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
338
359
  clientKey: (request) =>
339
360
  peers.get(request) ?? request.headers.get('x-forwarded-for') ?? 'unknown',
340
361
  })
362
+ // Story 032: a user with the authenticator on is asked for a code after the password.
363
+ const twoFactor = new TwoFactorService(db, {
364
+ secretKey: config.secretKey,
365
+ issuer: config.siteName,
366
+ })
367
+ let twoFactorKey: Promise<string> | undefined
341
368
  const authRoutes = createAuthRoutes({
342
369
  backOfficePath: paths.backOfficePath,
343
370
  service: auth,
344
371
  cookies,
372
+ twoFactor: {
373
+ providersFor: (userKey) => twoFactor.providers(userKey),
374
+ verify: (userKey, provider, code) =>
375
+ provider === USER_TWO_FACTOR_PROVIDER
376
+ ? twoFactor.verify(userKey, provider, code)
377
+ : Promise.resolve(false),
378
+ secret: () => {
379
+ twoFactorKey ??= ensureKeyValue(db, 'crombolo/twoFactorPendingKey', () =>
380
+ crypto.randomUUID(),
381
+ )
382
+ return twoFactorKey
383
+ },
384
+ },
345
385
  onAudit: (event, request) =>
346
386
  recordAudit(db, {
347
387
  category: 'sign-in',
@@ -356,6 +396,47 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
356
396
  ...clientAddress(request),
357
397
  }),
358
398
  })
399
+ // Story 031: backoffice users signing in through an identity provider, ending
400
+ // in the session the login endpoint would have set.
401
+ const backofficeExternal =
402
+ config.backofficeIdentityProviders.length === 0
403
+ ? undefined
404
+ : createExternalSignIn(
405
+ db,
406
+ backofficeAudience(db, {
407
+ auth,
408
+ providers: config.backofficeIdentityProviders,
409
+ backOfficePath: paths.backOfficePath,
410
+ sessionCookie: (sessionId) =>
411
+ serializeCookie({
412
+ name: names.session,
413
+ value: sessionId,
414
+ secure: config.secureCookies,
415
+ maxAgeSeconds: auth.settings.refreshTokenSeconds,
416
+ }),
417
+ clientAddress: (request) => clientAddress(request).ipAddress ?? undefined,
418
+ onSignIn: (user, request) =>
419
+ recordAudit(db, {
420
+ category: 'sign-in',
421
+ action: 'SignIn',
422
+ source: 'request',
423
+ userKey: user.key,
424
+ userName: user.userName,
425
+ entityType: 'User',
426
+ entityKey: user.key,
427
+ entityName: user.userName,
428
+ detail: 'Through an identity provider',
429
+ ...clientAddress(request),
430
+ }),
431
+ }),
432
+ {
433
+ applicationUrl: config.applicationUrl,
434
+ secureCookies: config.secureCookies,
435
+ log: (message) => log.warning('Backoffice sign-in: {message}', { message }),
436
+ resolve: (provider) =>
437
+ new IdentityProviderOverrides(db, config.secretKey).apply('user', provider),
438
+ },
439
+ )
359
440
 
360
441
  /**
361
442
  * Resolves the caller from the reference access token. The client sends
@@ -506,6 +587,25 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
506
587
  },
507
588
  },
508
589
  log: bundleLog('bundle'),
590
+ identityProviders: createBundleIdentityProviders({
591
+ overrides: new IdentityProviderOverrides(db, config.secretKey),
592
+ members: config.memberIdentityProviders,
593
+ backoffice: config.backofficeIdentityProviders,
594
+ memberCallbackPath: MEMBER_EXTERNAL_SIGN_IN_PATH,
595
+ backofficeCallbackPath: BACKOFFICE_EXTERNAL_PATH,
596
+ groups: async (audience) =>
597
+ audience === 'user'
598
+ ? (await new UserGroupRepository(db).all()).map((g) => ({
599
+ value: g.alias,
600
+ name: g.name,
601
+ }))
602
+ : (
603
+ await db.query<{ text: string }>(
604
+ 'SELECT text FROM node WHERE node_object_type = ? ORDER BY text',
605
+ [ObjectTypes.MemberGroup],
606
+ )
607
+ ).map((g) => ({ value: String(g.text), name: String(g.text) })),
608
+ }),
509
609
  },
510
610
  log: (id) => bundleLog(id),
511
611
  })
@@ -566,6 +666,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
566
666
  },
567
667
  )
568
668
  : undefined
669
+ const remote = createRemote({ db, config, authenticate })
569
670
  const members = createMemberAuth(db, config, {
570
671
  nodeState: schema.nodeState,
571
672
  nodeId: schema.nodeId,
@@ -589,6 +690,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
589
690
  protection: () => new PublicAccessRepository(db).allRules(),
590
691
  member: (token) => deliveryMembers.member(token),
591
692
  security: (request, action) => deliveryMembers.handle(request, action),
693
+ search: (term, options) => new SiteSearch(db).query(term, options),
592
694
  })
593
695
 
594
696
  /** A document's latest saved version, as preview shows it, in the published tree's context. */
@@ -637,7 +739,11 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
637
739
  })
638
740
  // The site's config may name a store; without one the environment decides,
639
741
  // and that falls back to the media directory on this disk.
640
- const mediaStore = config.mediaStore ?? (await mediaStoreFromEnvironment(config.mediaDir))
742
+ // A site pulled with `--lazy-media` reads files through from its remote (story 108).
743
+ const mediaStore = withRemoteMedia(
744
+ config.mediaStore ?? (await mediaStoreFromEnvironment(config.mediaDir)),
745
+ config.siteDir,
746
+ )
641
747
  // Resolved once: constructing it twice would warn twice about a provider that
642
748
  // is half-configured, and the adapters are cheap but not free of side effects.
643
749
  const email = resolveEmailPort(config)
@@ -877,6 +983,12 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
877
983
  if (response) return response
878
984
  }
879
985
 
986
+ // The remote protocol (story 107), where the backoffice is: it acts as a person.
987
+ if (servesBackOffice) {
988
+ const response = await remote.handle(request, url)
989
+ if (response) return response
990
+ }
991
+
880
992
  if (servesBackOffice) {
881
993
  const response = await serveBackOffice(request, url)
882
994
  if (response) {
@@ -1593,6 +1705,8 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1593
1705
  if (response) return response
1594
1706
  }
1595
1707
 
1708
+ const externalResponse = await backofficeExternal?.handle(request, pathname)
1709
+ if (externalResponse) return externalResponse
1596
1710
  const authResponse = await authRoutes.handle(request, pathname)
1597
1711
  if (authResponse) return authResponse
1598
1712
 
@@ -1605,6 +1719,51 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1605
1719
  return api.dispatch(request)
1606
1720
  }
1607
1721
 
1722
+ // Approving a command-line sign-in (story 107): signed in first, then back here.
1723
+ if (pathname === `${paths.backOfficePath}/device` && request.method === 'GET') {
1724
+ const sessionId = readCookie(request, names.session)
1725
+ if (!sessionId || !(await auth.resolveSession(sessionId))) {
1726
+ const back = `${pathname}${url.search}`
1727
+ return new Response(null, {
1728
+ status: 303,
1729
+ headers: {
1730
+ location: `${paths.backOfficePath}/login?returnUrl=${encodeURIComponent(back)}`,
1731
+ },
1732
+ })
1733
+ }
1734
+ const { importmap } = collectManifests(paths, extensions.list())
1735
+ return html(
1736
+ renderDeviceShell(paths, importmap, {
1737
+ title: config.siteName,
1738
+ userCode: url.searchParams.get('user_code') ?? undefined,
1739
+ }),
1740
+ )
1741
+ }
1742
+
1743
+ // Approving a push to a protected environment (story 107): the page reads
1744
+ // and decides the job through the remote API, as the signed-in person.
1745
+ if (
1746
+ pathname === `${paths.backOfficePath}/remote-approval` &&
1747
+ request.method === 'GET' &&
1748
+ config.remote.enabled
1749
+ ) {
1750
+ const sessionId = readCookie(request, names.session)
1751
+ if (!sessionId || !(await auth.resolveSession(sessionId)))
1752
+ return new Response(null, {
1753
+ status: 303,
1754
+ headers: {
1755
+ location: `${paths.backOfficePath}/login?returnUrl=${encodeURIComponent(`${pathname}${url.search}`)}`,
1756
+ },
1757
+ })
1758
+ const { importmap } = collectManifests(paths, extensions.list())
1759
+ return html(
1760
+ renderRemoteApprovalShell(paths, importmap, {
1761
+ title: config.siteName,
1762
+ job: url.searchParams.get('job') ?? '',
1763
+ }),
1764
+ )
1765
+ }
1766
+
1608
1767
  if (pathname === `${paths.backOfficePath}/login`) {
1609
1768
  const { importmap } = collectManifests(paths, extensions.list())
1610
1769
  return html(
@@ -1623,6 +1782,14 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1623
1782
  // the session exists, so anything else is an open redirect at the worst
1624
1783
  // possible moment, and `javascript:` there would run signed in.
1625
1784
  returnUrl: localReturnUrl(url.searchParams.get('returnUrl')) ?? paths.backOfficePath,
1785
+ externalProviders: config.backofficeIdentityProviders.map((provider) => ({
1786
+ name: provider.oidc.name,
1787
+ url:
1788
+ backofficeExternal?.startUrl(
1789
+ provider.oidc.id,
1790
+ localReturnUrl(url.searchParams.get('returnUrl')) ?? paths.backOfficePath,
1791
+ ) ?? '',
1792
+ })),
1626
1793
  }),
1627
1794
  )
1628
1795
  }
@@ -1731,7 +1898,10 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1731
1898
  if (!key) return undefined
1732
1899
  const draft = await draftModel(key, new URL(request.url).searchParams.get('culture'))
1733
1900
  if (!draft) return undefined
1734
- return renderer.renderModel(draft.content, draft.culture, await pageRequest(request))
1901
+ return renderer.renderModel(draft.content, draft.culture, {
1902
+ ...(await pageRequest(request)),
1903
+ search: siteSearchFor(db, cache, undefined),
1904
+ })
1735
1905
  }
1736
1906
 
1737
1907
  /** What every rendered page is told about the request and the site's search defaults. */
@@ -1880,6 +2050,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1880
2050
  member,
1881
2051
  submission,
1882
2052
  access: (content) => members.decide(content, member),
2053
+ search: siteSearchFor(db, cache, member),
1883
2054
  ...(await pageRequest(request)),
1884
2055
  }))
1885
2056
  const cleared: Record<string, string> = cookie
@@ -2003,11 +2174,24 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
2003
2174
  jobs,
2004
2175
  serveOptions: {
2005
2176
  port: config.port,
2006
- maxRequestBodySize: config.maxRequestBodyBytes,
2177
+ maxRequestBodySize: listeningBodyLimit(config),
2007
2178
  fetch: diagnostics.wrap(async (request, bunServer: Server<HubSocketData>) => {
2008
2179
  const peer = bunServer.requestIP(request)?.address
2009
2180
  if (peer) peers.set(request, peer)
2010
- const pathname = new URL(request.url).pathname
2181
+ const requestUrl = new URL(request.url)
2182
+ const pathname = requestUrl.pathname
2183
+ // Listening with the remote's larger limit (story 107), so every other
2184
+ // request is held to the usual one here.
2185
+ if (
2186
+ listeningBodyLimit(config) > config.maxRequestBodyBytes &&
2187
+ !remote.isUpload(request, requestUrl)
2188
+ ) {
2189
+ const length = Number(request.headers.get('content-length') ?? 0)
2190
+ if (length > config.maxRequestBodyBytes)
2191
+ return new Response('Payload Too Large', { status: 413 })
2192
+ if (!request.headers.has('content-length') && request.headers.has('transfer-encoding'))
2193
+ return new Response('Length Required', { status: 411 })
2194
+ }
2011
2195
  if (pathname === hubPath || pathname === previewHubPath) {
2012
2196
  const principal = await authenticate(request)
2013
2197
  const hub = pathname === hubPath ? events : previewHub