@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/server",
3
- "version": "0.14.1",
3
+ "version": "0.14.4",
4
4
  "description": "The crombolo HTTP server: routing, configuration, media stores and background jobs.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -28,17 +28,17 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@crombolo/api-delivery": "0.14.1",
32
- "@crombolo/api-management": "0.14.1",
33
- "@crombolo/assistant": "0.14.1",
34
- "@crombolo/auth": "0.14.1",
35
- "@crombolo/backoffice-host": "0.14.1",
36
- "@crombolo/contracts": "0.14.1",
37
- "@crombolo/core": "0.14.1",
38
- "@crombolo/data": "0.14.1",
39
- "@crombolo/render": "0.14.1",
40
- "@crombolo/schema": "0.14.1",
41
- "@crombolo/transfer": "0.14.1",
31
+ "@crombolo/api-delivery": "0.14.4",
32
+ "@crombolo/api-management": "0.14.4",
33
+ "@crombolo/assistant": "0.14.4",
34
+ "@crombolo/auth": "0.14.4",
35
+ "@crombolo/backoffice-host": "0.14.4",
36
+ "@crombolo/contracts": "0.14.4",
37
+ "@crombolo/core": "0.14.4",
38
+ "@crombolo/data": "0.14.4",
39
+ "@crombolo/render": "0.14.4",
40
+ "@crombolo/schema": "0.14.4",
41
+ "@crombolo/transfer": "0.14.4",
42
42
  "@logtape/logtape": "^2.3.8"
43
43
  }
44
44
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The API client an unattended install seeds (story 034): an API user in the
3
+ * administrators group, holding the configured client, so a pipeline can sign
4
+ * in with the client-credentials grant on a site whose backoffice only takes an
5
+ * identity provider. Created once; a client that exists is never rotated from
6
+ * config. `docs/operations.md`.
7
+ */
8
+ import { hashPassword } from '@crombolo/auth'
9
+ import { ADMIN_GROUP_ALIAS } from '@crombolo/core'
10
+ import { BUILT_IN_GROUPS, closeSetup, type Db, UserRepository } from '@crombolo/data'
11
+ import { recordAudit } from './adapters/audit.ts'
12
+
13
+ /** Seeds the client when no user holds its id; `existed` when one already does. */
14
+ export async function ensureApiClient(
15
+ db: Db,
16
+ client: { id: string; secret: string },
17
+ ): Promise<'created' | 'existed'> {
18
+ const users = new UserRepository(db)
19
+ if (await users.clientCredentialOwner(client.id)) {
20
+ // A machine can already administer the site, so the browser setup step goes.
21
+ await closeSetup(db)
22
+ return 'existed'
23
+ }
24
+ const admins = BUILT_IN_GROUPS.find((group) => group.alias === ADMIN_GROUP_ALIAS)
25
+ const created = await users.create({
26
+ key: crypto.randomUUID(),
27
+ name: 'API client',
28
+ userName: client.id,
29
+ email: '',
30
+ kind: 'Api',
31
+ groupKeys: admins ? [admins.key] : [],
32
+ languageIsoCode: null,
33
+ })
34
+ if (!created) throw new Error(`The API client ${client.id} could not be created.`)
35
+ await users.addClientCredential(created.id, client.id, await hashPassword(client.secret))
36
+ await closeSetup(db)
37
+ await recordAudit(db, {
38
+ category: 'user',
39
+ action: 'Create',
40
+ source: 'cli',
41
+ entityType: 'User',
42
+ entityKey: created.key,
43
+ entityName: client.id,
44
+ detail: 'API client seeded from CROMBOLO_API_CLIENT_ID',
45
+ })
46
+ return 'created'
47
+ }
@@ -524,15 +524,32 @@ export async function installBundleContent(
524
524
  return refuse([
525
525
  'this file was written without its media files (--no-media); pass --allow-missing-media to import the media items without them',
526
526
  ])
527
+ // Referenced media arrives in the store one file at a time, before the file
528
+ // that names it (story 107): every one has to be there already.
529
+ let referencedPresent = false
530
+ if (manifest.media === 'referenced') {
531
+ const store = await mediaStoreFor(config)
532
+ const absent: string[] = []
533
+ for (const blob of manifest.blobs) if (!(await store.get(blob.key))) absent.push(blob.key)
534
+ if (absent.length > 0 && !input.allowMissingBlobs)
535
+ return refuse([
536
+ `${absent.length} media file(s) this file refers to are not in the media store yet`,
537
+ ...absent,
538
+ ])
539
+ referencedPresent = true
540
+ }
527
541
  if (replace === 'replace-all' && !carriesAdministrator(set.people))
528
542
  return refuse([
529
543
  'this file carries no back-office administrator, so replacing everything would leave nobody able to sign in; export it with --with-users, or use --replace',
530
544
  ])
531
545
 
532
546
  // A replace takes the file's side of every difference, and replaces files.
533
- const effective: ContentImportInput = replace
547
+ const replaced: ContentImportInput = replace
534
548
  ? { ...input, resolveAll: 'take-bundle', replaceFiles: true, publish: input.publish ?? true }
535
549
  : input
550
+ const effective: ContentImportInput = referencedPresent
551
+ ? { ...replaced, allowMissingBlobs: true }
552
+ : replaced
536
553
 
537
554
  // The coarse layer, under the fine-grained revert: in-place conversion is not
538
555
  // reversible by itself, so the same rule as `upgrade` applies here.
package/src/config.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { hostname, tmpdir } from 'node:os'
2
2
  import { join, resolve } from 'node:path'
3
3
  import type { DeliveryApiOptions } from '@crombolo/api-delivery'
4
- import { DEFAULT_BACKOFFICE_PATH } from '@crombolo/core'
4
+ import {
5
+ DEFAULT_BACKOFFICE_PATH,
6
+ DEFAULT_PASSWORD_CONFIGURATION,
7
+ isValidPassword,
8
+ } from '@crombolo/core'
5
9
  import { DEFAULT_RENDER_TIMEOUT_MS, type RedirectRule, type SnapshotLimits } from '@crombolo/render'
6
10
  import type { AssistantConfig } from './assistant.ts'
7
11
  import type { ServerBundle } from './bundles.ts'
@@ -30,6 +34,65 @@ export type UserLinkSender = (message: {
30
34
  message: string | null
31
35
  }) => Promise<void>
32
36
 
37
+ export interface RemoteSettings {
38
+ enabled: boolean
39
+ /** The environment this server is, as the remote protocol names it. */
40
+ environment: string
41
+ /** A push here replaces something people rely on: it needs its own permission. */
42
+ protected: boolean
43
+ /** A push to a protected environment waits for another person's approval. */
44
+ approvals: boolean
45
+ /**
46
+ * The largest push file or media file a client may upload. Every other route
47
+ * keeps `maxRequestBodyBytes`, checked per request, since Bun's own limit is
48
+ * the server's.
49
+ */
50
+ maxUploadBytes: number
51
+ }
52
+
53
+ /** The prefix every API client id carries, as the Management API requires. */
54
+ export const API_CLIENT_ID_PREFIX = 'crombolo-back-office-'
55
+
56
+ /** Refuses a seeded API client that the Management API would refuse, naming the variable. */
57
+ function checkApiClient(client: CromboloConfig['apiClient']): void {
58
+ if (!client) return
59
+ if (!client.id || !client.secret)
60
+ throw new Error(
61
+ 'CROMBOLO_API_CLIENT_ID and CROMBOLO_API_CLIENT_SECRET go together: set both, or neither.',
62
+ )
63
+ if (
64
+ !client.id.startsWith(API_CLIENT_ID_PREFIX) ||
65
+ client.id.length <= API_CLIENT_ID_PREFIX.length
66
+ )
67
+ throw new Error(`CROMBOLO_API_CLIENT_ID must start with "${API_CLIENT_ID_PREFIX}".`)
68
+ if (!isValidPassword(client.secret))
69
+ throw new Error(
70
+ `CROMBOLO_API_CLIENT_SECRET must be at least ${DEFAULT_PASSWORD_CONFIGURATION.minimumPasswordLength} characters.`,
71
+ )
72
+ }
73
+
74
+ /**
75
+ * Whether this site runs on the developer's own machine: development, and an
76
+ * application URL that is unset or names localhost. What `localAdmin` needs.
77
+ */
78
+ export function runsOnLocalhost(config: CromboloConfig): boolean {
79
+ if (!config.development) return false
80
+ if (!config.applicationUrl) return true
81
+ try {
82
+ const host = new URL(config.applicationUrl).hostname
83
+ return host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '::1'
84
+ } catch {
85
+ return false
86
+ }
87
+ }
88
+
89
+ /** The body limit to listen with: raised for remote uploads when the remote is on. */
90
+ export function listeningBodyLimit(config: CromboloConfig): number {
91
+ return config.remote.enabled
92
+ ? Math.max(config.maxRequestBodyBytes, config.remote.maxUploadBytes)
93
+ : config.maxRequestBodyBytes
94
+ }
95
+
33
96
  export interface CromboloConfig {
34
97
  port: number
35
98
  /** The largest request body the server will read, media uploads included. */
@@ -74,6 +137,20 @@ export interface CromboloConfig {
74
137
  adminPassword: string | undefined
75
138
  /** Guards "Set up your new site". Chosen by the owner; never printed, logged or shown. */
76
139
  setupCode: string | undefined
140
+ /**
141
+ * The API client an unattended install seeds (story 034), so a pipeline can
142
+ * administer a site whose backoffice only takes an identity provider. Seeded
143
+ * once and never rotated from here; the secret is never printed or logged.
144
+ * With it set, no password administrator is seeded unless `adminPassword` is
145
+ * — or `localAdmin` is, on a site running on localhost.
146
+ */
147
+ apiClient: { id: string; secret: string } | undefined
148
+ /**
149
+ * Seed the password administrator even with `apiClient` set, so local
150
+ * development keeps printing a sign-in. Honoured only on localhost
151
+ * (`runsOnLocalhost`); ignored, with a warning, anywhere else.
152
+ */
153
+ localAdmin: boolean
77
154
  /** Seals secrets kept in the database, such as a webhook's headers; never stored. */
78
155
  secretKey: string | undefined
79
156
  /**
@@ -241,6 +318,15 @@ export interface CromboloConfig {
241
318
  memberIdentityProviders: readonly IdentityProvider[]
242
319
  /** Identity providers backoffice users may sign in with. `allowLocalLogin: false` makes them the only way in. */
243
320
  backofficeIdentityProviders: readonly IdentityProvider[]
321
+ /**
322
+ * Answering the remote protocol (story 107): a developer's `crombolo` pulls a
323
+ * copy of this site and pushes work back. Off unless `enabled`. This server
324
+ * offers one environment, itself, named `environment`; `protected` makes a
325
+ * push need the protected-push permission, and `approvals` makes it wait for
326
+ * someone else with that permission to approve it, which needs
327
+ * `CROMBOLO_SECRET_KEY`. docs/remote-protocol.md.
328
+ */
329
+ remote: RemoteSettings
244
330
  /**
245
331
  * Redirects the site declares itself, built with `redirect()`. They are synced
246
332
  * into the database at boot, so the backoffice lists them beside the tracked
@@ -427,6 +513,14 @@ export function loadConfig(
427
513
  sqliteFile: Bun.env.CROMBOLO_SQLITE_FILE ?? DEFAULTS.sqliteFile,
428
514
  adminLogin: Bun.env.CROMBOLO_ADMIN_LOGIN ?? DEFAULTS.adminLogin,
429
515
  adminPassword: Bun.env.CROMBOLO_ADMIN_PASSWORD,
516
+ apiClient:
517
+ Bun.env.CROMBOLO_API_CLIENT_ID || Bun.env.CROMBOLO_API_CLIENT_SECRET
518
+ ? {
519
+ id: Bun.env.CROMBOLO_API_CLIENT_ID ?? '',
520
+ secret: Bun.env.CROMBOLO_API_CLIENT_SECRET ?? '',
521
+ }
522
+ : undefined,
523
+ localAdmin: Bun.env.CROMBOLO_LOCAL_ADMIN === 'true',
430
524
  setupCode: Bun.env.CROMBOLO_SETUP_CODE,
431
525
  secretKey: Bun.env.CROMBOLO_SECRET_KEY,
432
526
  secureCookies: Bun.env.CROMBOLO_INSECURE_COOKIES !== 'true',
@@ -514,6 +608,13 @@ export function loadConfig(
514
608
  // No environment variable: a provider is a preset, which is code.
515
609
  memberIdentityProviders: [],
516
610
  backofficeIdentityProviders: [],
611
+ 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,
617
+ },
517
618
  redirects: [],
518
619
  trackRedirects: Bun.env.CROMBOLO_TRACK_REDIRECTS !== 'false',
519
620
  applyBundleContentOnInstall: Bun.env.CROMBOLO_APPLY_BUNDLE_CONTENT !== 'false',
@@ -545,6 +646,12 @@ export function loadConfig(
545
646
  checkIdentityProviders(merged.backofficeIdentityProviders, 'backoffice')
546
647
  // Field by field, so a site setting its title suffix keeps the environment's switches.
547
648
  merged.seo = { ...fromEnv.seo, ...stripUndefined(overrides.seo ?? {}) }
649
+ merged.remote = { ...fromEnv.remote, ...stripUndefined(overrides.remote ?? {}) }
650
+ checkApiClient(merged.apiClient)
651
+ if (!/^[a-z][a-z0-9-]{0,62}$/.test(merged.remote.environment))
652
+ throw new Error(
653
+ `remote.environment "${merged.remote.environment}" must be lower case letters, digits and dashes.`,
654
+ )
548
655
  merged.deliveryApi = {
549
656
  ...fromEnv.deliveryApi,
550
657
  ...stripUndefined(overrides.deliveryApi ?? {}),
@@ -632,4 +739,4 @@ export function describeDatabase(
632
739
  }
633
740
  }
634
741
 
635
- export const VERSION = '0.14.1'
742
+ export const VERSION = '0.14.4'
package/src/database.ts CHANGED
@@ -21,8 +21,10 @@ import {
21
21
  seedContent,
22
22
  seedIdentity,
23
23
  } from '@crombolo/data'
24
+ import { ensureApiClient } from './api-client.ts'
24
25
  import { type BundleChain, migrateBundles, pendingBundleSteps } from './bundle-tables.ts'
25
- import type { CromboloConfig } from './config.ts'
26
+ import { type CromboloConfig, runsOnLocalhost } from './config.ts'
27
+ import { logger } from './logging.ts'
26
28
 
27
29
  /** Production never auto-upgrades: an existing database with steps pending refuses to boot. */
28
30
  export class UpgradePendingError extends Error {
@@ -127,17 +129,28 @@ export async function installDatabase(
127
129
  await migrateBundles(db, chains, { by: config.nodeId })
128
130
 
129
131
  // In production a password is never made up: the pipeline supplies one, or
130
- // the owner sets the site up in the browser (docs/operations.md).
131
- const settingUp = !config.development && !config.adminPassword
132
- const password = config.adminPassword ?? crypto.randomUUID().replaceAll('-', '').slice(0, 20)
132
+ // the owner sets the site up in the browser (docs/operations.md). With an API
133
+ // client to seed (story 034) a machine administers the site instead, and no
134
+ // password administrator exists unless asked for — `adminPassword`, or
135
+ // `localAdmin` on the developer's own machine.
136
+ const localAdmin = config.localAdmin && runsOnLocalhost(config)
137
+ if (config.localAdmin && !localAdmin)
138
+ logger('install').warning(
139
+ 'CROMBOLO_LOCAL_ADMIN is ignored: this site does not run on localhost',
140
+ )
141
+ const machineOnly = config.apiClient !== undefined && !config.adminPassword && !localAdmin
142
+ const settingUp = !machineOnly && !config.development && !config.adminPassword
143
+ const password = config.adminPassword || crypto.randomUUID().replaceAll('-', '').slice(0, 20)
133
144
  const seeded = await seedIdentity(db, {
134
- admin: settingUp
135
- ? undefined
136
- : { name: 'Administrator', login: config.adminLogin, email: config.adminLogin, password },
145
+ admin:
146
+ settingUp || machineOnly
147
+ ? undefined
148
+ : { name: 'Administrator', login: config.adminLogin, email: config.adminLogin, password },
137
149
  hashPassword,
138
150
  passwordConfig: passwordConfigJson(),
139
151
  })
140
152
  if (seeded && settingUp) await markSetupPending(db)
153
+ if (config.apiClient) await ensureApiClient(db, config.apiClient)
141
154
 
142
155
  await seedContent(db)
143
156
  // A database from an older version gains the built-ins it lacks.
@@ -146,5 +159,5 @@ export async function installDatabase(
146
159
  await ensureSystemMemberTypes(db)
147
160
  await ensureSystemContentTypes(db)
148
161
 
149
- return seeded && !settingUp && !config.adminPassword ? password : undefined
162
+ return seeded && !settingUp && !machineOnly && !config.adminPassword ? password : undefined
150
163
  }
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export * from './adapters/public-access.ts'
12
12
  export * from './adapters/redirects.ts'
13
13
  export * from './adapters/references.ts'
14
14
  export * from './adapters/template-files.ts'
15
+ export * from './api-client.ts'
15
16
  export * from './backup.ts'
16
17
  export * from './boot-failure.ts'
17
18
  export * from './bundle-install.ts'
@@ -63,6 +64,9 @@ export * from './no-nodes.ts'
63
64
  export * from './oembed.ts'
64
65
  export * from './ports.ts'
65
66
  export * from './redirects.ts'
67
+ export * from './remote.ts'
68
+ export * from './remote-credentials.ts'
69
+ export * from './remote-media.ts'
66
70
  export * from './schema.ts'
67
71
  export * from './schema-store.ts'
68
72
  export * from './secrets.ts'
@@ -168,7 +168,7 @@ export function azureMediaStore(options: AzureMediaStoreOptions): MediaStore {
168
168
  async put(key, body, putOptions) {
169
169
  if (!isSafeMediaKey(key)) throw new Error(`"${key}" is not a usable media key.`)
170
170
  // Copied into a plain ArrayBuffer so `fetch` and the signature agree on its length.
171
- const bytes = new Uint8Array(body instanceof File ? await body.arrayBuffer() : body)
171
+ const bytes = new Uint8Array(body instanceof Blob ? await body.arrayBuffer() : body)
172
172
  const response = await send('PUT', blobUrl(key), {
173
173
  body: bytes,
174
174
  headers: {
@@ -13,6 +13,7 @@
13
13
  * `ab12cd34/photo.jpg` a placed file, served at `/media/ab12cd34/photo.jpg`
14
14
  * `.temp/<uuid>/photo.jpg` an upload not yet placed, with `meta.json` beside it
15
15
  * `.cache/ab/<hash>.webp` a processed image variant
16
+ * `.remote/<job>/site.tgz` a remote pull's or push's file (story 107), never public
16
17
  *
17
18
  * A site picks a store in `crombolo.config.ts` — `fileSystemMediaStore`,
18
19
  * `s3MediaStore` or `azureMediaStore` — or leaves it to the environment, which
@@ -41,7 +42,7 @@ export interface MediaStore {
41
42
  /** Named in the boot banner and in logs: `media/`, `s3://bucket`, … */
42
43
  readonly description: string
43
44
  get(key: string): Promise<StoredFile | undefined>
44
- put(key: string, body: Uint8Array | File, options?: { contentType?: string }): Promise<void>
45
+ put(key: string, body: Uint8Array | Blob, options?: { contentType?: string }): Promise<void>
45
46
  delete(key: string): Promise<void>
46
47
  /** Every key under a prefix, which is how temporary uploads are swept. */
47
48
  list(prefix: string): Promise<string[]>
@@ -111,7 +112,8 @@ export function isSafeMediaKey(key: string): boolean {
111
112
  part !== '' &&
112
113
  part !== '.' &&
113
114
  part !== '..' &&
114
- (!part.startsWith('.') || (index === 0 && (part === '.temp' || part === '.cache'))),
115
+ (!part.startsWith('.') ||
116
+ (index === 0 && (part === '.temp' || part === '.cache' || part === '.remote'))),
115
117
  )
116
118
  }
117
119
 
@@ -0,0 +1,108 @@
1
+ /**
2
+ * A developer's sign-ins to remotes (story 108): kept in the operating system's
3
+ * keychain (macOS Keychain, Windows Credential Manager, libsecret) through
4
+ * `Bun.secrets`, keyed by the remote's origin, never in the site's folder.
5
+ * Shared by the CLI's commands and a lazily-fetching local media store.
6
+ */
7
+
8
+ export const KEYCHAIN_SERVICE = 'crombolo-remote'
9
+
10
+ export interface Keychain {
11
+ get(name: string): Promise<string | null>
12
+ set(name: string, value: string): Promise<void>
13
+ delete(name: string): Promise<void>
14
+ }
15
+
16
+ /** The operating system's keychain. */
17
+ export function osKeychain(): Keychain {
18
+ return {
19
+ get: (name) => Bun.secrets.get({ service: KEYCHAIN_SERVICE, name }),
20
+ set: (name, value) => Bun.secrets.set({ service: KEYCHAIN_SERVICE, name, value }),
21
+ delete: async (name) => {
22
+ await Bun.secrets.delete({ service: KEYCHAIN_SERVICE, name })
23
+ },
24
+ }
25
+ }
26
+
27
+ /** A keychain in memory, for tests and machines without one. */
28
+ export function memoryKeychain(): Keychain {
29
+ const values = new Map<string, string>()
30
+ return {
31
+ get: async (name) => values.get(name) ?? null,
32
+ set: async (name, value) => {
33
+ values.set(name, value)
34
+ },
35
+ delete: async (name) => {
36
+ values.delete(name)
37
+ },
38
+ }
39
+ }
40
+
41
+ export interface RemoteSignIn {
42
+ accessToken: string
43
+ refreshToken: string
44
+ /** Milliseconds since the epoch. */
45
+ expiresAt: number
46
+ tokenUrl: string
47
+ clientId: string
48
+ /** Who approved it, for `whoami`. */
49
+ user?: string
50
+ }
51
+
52
+ const keyOf = (origin: string) => new URL(origin).origin
53
+
54
+ export async function readSignIn(
55
+ keychain: Keychain,
56
+ origin: string,
57
+ ): Promise<RemoteSignIn | undefined> {
58
+ const stored = await keychain.get(keyOf(origin))
59
+ if (!stored) return undefined
60
+ try {
61
+ return JSON.parse(stored) as RemoteSignIn
62
+ } catch {
63
+ return undefined
64
+ }
65
+ }
66
+
67
+ export const saveSignIn = (keychain: Keychain, origin: string, signIn: RemoteSignIn) =>
68
+ keychain.set(keyOf(origin), JSON.stringify(signIn))
69
+
70
+ export const forgetSignIn = (keychain: Keychain, origin: string) => keychain.delete(keyOf(origin))
71
+
72
+ /**
73
+ * A usable access token: the stored one, or a refreshed one when it is within a
74
+ * minute of expiring. Undefined when there is no sign-in, or the remote refused
75
+ * the refresh — the person signs in again.
76
+ */
77
+ export async function freshAccessToken(
78
+ keychain: Keychain,
79
+ origin: string,
80
+ http: typeof fetch = fetch,
81
+ force = false,
82
+ ): Promise<string | undefined> {
83
+ const signIn = await readSignIn(keychain, origin)
84
+ if (!signIn) return undefined
85
+ if (!force && signIn.expiresAt - Date.now() > 60_000) return signIn.accessToken
86
+ const response = await http(signIn.tokenUrl, {
87
+ method: 'POST',
88
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
89
+ body: new URLSearchParams({
90
+ grant_type: 'refresh_token',
91
+ refresh_token: signIn.refreshToken,
92
+ client_id: signIn.clientId,
93
+ }).toString(),
94
+ })
95
+ if (!response.ok) return undefined
96
+ const tokens = (await response.json()) as {
97
+ access_token: string
98
+ refresh_token: string
99
+ expires_in: number
100
+ }
101
+ await saveSignIn(keychain, origin, {
102
+ ...signIn,
103
+ accessToken: tokens.access_token,
104
+ refreshToken: tokens.refresh_token,
105
+ expiresAt: Date.now() + tokens.expires_in * 1000,
106
+ })
107
+ return tokens.access_token
108
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Media fetched from a remote on first request (story 108, `pull
3
+ * --media-info-only --lazy-media`): a local site pulled without its media
4
+ * files reads each one through from the remote it came from the first time it
5
+ * is asked for, under the developer's own sign-in, and keeps it.
6
+ *
7
+ * Only the store the site serves from is wrapped. An export reads the plain
8
+ * store, so pushing a lazily-pulled site never downloads what it never used.
9
+ */
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
11
+ import { dirname, join } from 'node:path'
12
+ import { logger } from './logging.ts'
13
+ import type { MediaStore, StoredFile } from './media-store.ts'
14
+ import { freshAccessToken, type Keychain, osKeychain } from './remote-credentials.ts'
15
+
16
+ export const REMOTE_MEDIA_FILE = join('.crombolo', 'remote-media.json')
17
+
18
+ export interface RemoteMediaSource {
19
+ /** The remote's API base, which is also what its sign-in is kept under. */
20
+ api: string
21
+ environment: string
22
+ }
23
+
24
+ export function readRemoteMedia(siteDir: string): RemoteMediaSource | undefined {
25
+ const file = join(siteDir, REMOTE_MEDIA_FILE)
26
+ if (!existsSync(file)) return undefined
27
+ try {
28
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<RemoteMediaSource>
29
+ return typeof parsed.api === 'string' && typeof parsed.environment === 'string'
30
+ ? { api: parsed.api, environment: parsed.environment }
31
+ : undefined
32
+ } catch {
33
+ return undefined
34
+ }
35
+ }
36
+
37
+ export function writeRemoteMedia(siteDir: string, source: RemoteMediaSource): void {
38
+ const file = join(siteDir, REMOTE_MEDIA_FILE)
39
+ mkdirSync(dirname(file), { recursive: true })
40
+ writeFileSync(file, `${JSON.stringify(source, null, 2)}\n`)
41
+ }
42
+
43
+ /** The site's store, reading through to its remote when the site was pulled lazily. */
44
+ export function withRemoteMedia(
45
+ store: MediaStore,
46
+ siteDir: string,
47
+ options: { keychain?: Keychain; http?: typeof fetch } = {},
48
+ ): MediaStore {
49
+ const source = readRemoteMedia(siteDir)
50
+ if (!source) return store
51
+ const keychain = options.keychain ?? osKeychain()
52
+ const http = options.http ?? fetch
53
+ const log = logger('media')
54
+ let warned = false
55
+
56
+ const fetchThrough = async (key: string): Promise<StoredFile | undefined> => {
57
+ const token = await freshAccessToken(keychain, source.api, http)
58
+ if (!token) {
59
+ if (!warned)
60
+ log.warning(
61
+ 'A media file is not here yet, and fetching it needs `crombolo login` to {api}',
62
+ {
63
+ api: source.api,
64
+ },
65
+ )
66
+ warned = true
67
+ return undefined
68
+ }
69
+ const path = key.split('/').map(encodeURIComponent).join('/')
70
+ const response = await http(`${source.api}/environments/${source.environment}/media/${path}`, {
71
+ headers: { authorization: `Bearer ${token}` },
72
+ }).catch(() => undefined)
73
+ if (!response?.ok) return undefined
74
+ await store.put(key, await response.blob())
75
+ return store.get(key)
76
+ }
77
+
78
+ return {
79
+ ...store,
80
+ description: `${store.description} (fetching from ${new URL(source.api).origin})`,
81
+ async get(key) {
82
+ const local = await store.get(key)
83
+ if (local || key.startsWith('.')) return local
84
+ return fetchThrough(key)
85
+ },
86
+ put: (key, body, putOptions) => store.put(key, body, putOptions),
87
+ delete: (key) => store.delete(key),
88
+ list: (prefix) => store.list(prefix),
89
+ ...(store.publicUrl ? { publicUrl: (key: string) => store.publicUrl?.(key) } : {}),
90
+ }
91
+ }