@crombolo/server 0.14.1 → 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.
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,
@@ -95,7 +97,13 @@ import { CommandError, installBundleContent } from './bundle-install.ts'
95
97
  import { bundleChains, bundleTablesReport } from './bundle-tables.ts'
96
98
  import { createBundleRedirects, createServerBundles, validateServerBundles } from './bundles.ts'
97
99
  import { busyResponse } from './busy.ts'
98
- 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'
99
107
  import { attachDatabase, bootstrapDatabase } from './database.ts'
100
108
  import { createDeliveryMemberSecurity } from './delivery-members.ts'
101
109
  import { createDiagnostics } from './diagnostics.ts'
@@ -136,6 +144,8 @@ import { noNodesPage } from './no-nodes.ts'
136
144
  import { NotImplementedLog } from './not-implemented.ts'
137
145
  import { createDeps, PREVIEW_COOKIE, resolveUserLinkSender } from './ports.ts'
138
146
  import { createRedirectTracker } from './redirects.ts'
147
+ import { createRemote } from './remote.ts'
148
+ import { withRemoteMedia } from './remote-media.ts'
139
149
  import { handleTransferAction, pendingTransfers } from './transfer-dashboard.ts'
140
150
  import {
141
151
  changeUrlOptions,
@@ -656,6 +666,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
656
666
  },
657
667
  )
658
668
  : undefined
669
+ const remote = createRemote({ db, config, authenticate })
659
670
  const members = createMemberAuth(db, config, {
660
671
  nodeState: schema.nodeState,
661
672
  nodeId: schema.nodeId,
@@ -728,7 +739,11 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
728
739
  })
729
740
  // The site's config may name a store; without one the environment decides,
730
741
  // and that falls back to the media directory on this disk.
731
- 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
+ )
732
747
  // Resolved once: constructing it twice would warn twice about a provider that
733
748
  // is half-configured, and the adapters are cheap but not free of side effects.
734
749
  const email = resolveEmailPort(config)
@@ -968,6 +983,12 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
968
983
  if (response) return response
969
984
  }
970
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
+
971
992
  if (servesBackOffice) {
972
993
  const response = await serveBackOffice(request, url)
973
994
  if (response) {
@@ -1698,6 +1719,51 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1698
1719
  return api.dispatch(request)
1699
1720
  }
1700
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
+
1701
1767
  if (pathname === `${paths.backOfficePath}/login`) {
1702
1768
  const { importmap } = collectManifests(paths, extensions.list())
1703
1769
  return html(
@@ -2108,11 +2174,24 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
2108
2174
  jobs,
2109
2175
  serveOptions: {
2110
2176
  port: config.port,
2111
- maxRequestBodySize: config.maxRequestBodyBytes,
2177
+ maxRequestBodySize: listeningBodyLimit(config),
2112
2178
  fetch: diagnostics.wrap(async (request, bunServer: Server<HubSocketData>) => {
2113
2179
  const peer = bunServer.requestIP(request)?.address
2114
2180
  if (peer) peers.set(request, peer)
2115
- 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
+ }
2116
2195
  if (pathname === hubPath || pathname === previewHubPath) {
2117
2196
  const principal = await authenticate(request)
2118
2197
  const hub = pathname === hubPath ? events : previewHub
package/src/site-file.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  packageNameFor,
28
28
  planStreamedBundle,
29
29
  sanitise,
30
+ sealBytes,
30
31
  writeManifest,
31
32
  } from '@crombolo/transfer'
32
33
  import { createFileSystemPort } from './adapters/file-system.ts'
@@ -40,15 +41,25 @@ import { TarballWriter } from './tar.ts'
40
41
  export interface SiteExportOptions {
41
42
  /** The `.tgz` to write. */
42
43
  out: string
43
- /** False for `--no-media`: the media nodes travel, their files do not. */
44
- media?: boolean
44
+ /**
45
+ * False for `--no-media`: the media nodes travel, their files do not.
46
+ * `referenced`: likewise, for a remote and its CLI to move the files one at a
47
+ * time (story 107).
48
+ */
49
+ media?: boolean | 'referenced'
45
50
  /** Carry the latest drafts rather than what is published. */
46
51
  drafts?: boolean
47
52
  withMembers?: boolean
48
53
  withUsers?: boolean
49
54
  /** Replace every personal value before anything is written (`sanitise`). */
50
55
  anonymise?: boolean
51
- /** Seals the people; needed when any travel. */
56
+ /**
57
+ * Seal the content data — property values and media files — as well as any
58
+ * people, under `passphrase`: the bundle rule, metadata readable and content
59
+ * not (story 107). Each media file is sealed whole, one at a time.
60
+ */
61
+ sealContent?: boolean
62
+ /** Seals the people, and the content with `sealContent`; needed when either travels sealed. */
52
63
  passphrase?: string
53
64
  package?: { name?: string; version?: string }
54
65
  }
@@ -101,12 +112,14 @@ export async function exportSite(
101
112
  config: CromboloConfig,
102
113
  options: SiteExportOptions,
103
114
  ): Promise<SiteExportResult> {
104
- const withMedia = options.media !== false
115
+ const withMedia = options.media !== false && options.media !== 'referenced'
105
116
  const carryMembers = Boolean(options.withMembers)
106
117
  // Anonymised users are removed, so there are none to carry.
107
118
  const carryUsers = Boolean(options.withUsers) && !options.anonymise
108
119
  if ((carryMembers || carryUsers) && !options.passphrase)
109
120
  throw new Error('Members and users travel sealed: give a passphrase.')
121
+ if (options.sealContent && !options.passphrase)
122
+ throw new Error('Sealed content needs a passphrase.')
110
123
 
111
124
  const exported = await exportBundle(db, {
112
125
  roots: await topLevelNodes(db),
@@ -158,30 +171,43 @@ export async function exportSite(
158
171
  const store = await mediaStoreFor(config)
159
172
  const stored = new Map<string, StoredFile>()
160
173
  const missing: string[] = []
161
- if (withMedia)
174
+ // Referenced, each file is named with its size and etag, so the far side can
175
+ // fetch it — or skip it, holding one with the same etag already.
176
+ const described = new Map<string, { size: number; etag: string }>()
177
+ if (withMedia || options.media === 'referenced')
162
178
  for (const key of exported.blobKeys) {
163
179
  const file = await store.get(key)
164
- if (file) stored.set(key, file)
165
- else missing.push(key)
180
+ if (!file) missing.push(key)
181
+ else if (withMedia) stored.set(key, file)
182
+ else described.set(key, { size: file.size, etag: file.etag })
166
183
  }
167
184
 
168
185
  const site = {
169
186
  ...set,
170
187
  manifest: {
171
188
  ...set.manifest,
189
+ blobs: set.manifest.blobs.map((blob) => {
190
+ const found = described.get(blob.key)
191
+ return found ? { ...blob, size: found.size, etag: found.etag } : blob
192
+ }),
172
193
  label: `${config.siteName} (whole site)`,
173
194
  provenance: { ...set.manifest.provenance, cmsVersion: VERSION },
174
195
  scope: 'site' as const,
175
- ...(withMedia ? {} : { media: 'omitted' as const }),
196
+ ...(withMedia
197
+ ? {}
198
+ : {
199
+ media: options.media === 'referenced' ? ('referenced' as const) : ('omitted' as const),
200
+ }),
176
201
  requires: requiredPackages(config),
177
202
  },
178
203
  }
179
- const seal = people && options.passphrase ? newSeal(options.passphrase) : undefined
204
+ const seal =
205
+ (people || options.sealContent) && options.passphrase ? newSeal(options.passphrase) : undefined
180
206
  const planned = planStreamedBundle(
181
207
  site,
182
208
  new Map([...stored].map(([key, file]) => [key, file.size])),
183
209
  sections,
184
- { people, seal },
210
+ { people, seal, content: options.sealContent ? 'sealed' : 'open' },
185
211
  )
186
212
 
187
213
  const name = options.package?.name ?? packageNameFor(`${config.siteName}-site`)
@@ -213,6 +239,14 @@ export async function exportSite(
213
239
  await writer.add(`${PAYLOAD_DIR}/${path}`, body)
214
240
  } else {
215
241
  const media = stored.get(blobs.get(path) as string) as StoredFile
242
+ if (planned.sealing && seal) {
243
+ const sealed = sealBytes(seal.key, path, await media.bytes())
244
+ hasher.update(sealed)
245
+ hasher.update('\n')
246
+ await writer.add(`${PAYLOAD_DIR}/${path}`, sealed)
247
+ bytes += media.size
248
+ continue
249
+ }
216
250
  async function* hashed() {
217
251
  for await (const chunk of streamOf(media)) {
218
252
  hasher.update(chunk)
@@ -233,7 +267,7 @@ export async function exportSite(
233
267
  await writer.close()
234
268
  await rename(partial, options.out)
235
269
  } catch (error) {
236
- await writer.close().catch(() => {})
270
+ await writer.abort()
237
271
  await rm(partial, { force: true })
238
272
  throw error
239
273
  }
package/src/tar.ts CHANGED
@@ -148,6 +148,15 @@ export class TarballWriter {
148
148
  this.#gzip.end()
149
149
  await this.#done
150
150
  }
151
+
152
+ /**
153
+ * Gives up on a half-written file: the stream ends now, and its error is
154
+ * swallowed, so nothing surfaces later after the caller has cleaned up.
155
+ */
156
+ async abort(): Promise<void> {
157
+ this.#gzip.destroy()
158
+ await this.#done.catch(() => {})
159
+ }
151
160
  }
152
161
 
153
162
  const decoder = new TextDecoder()