@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.
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Settings an administrator changed for an identity provider the config
3
+ * declares (story 033): kept in `key_value`, one row per provider and audience,
4
+ * with the client secret sealed by `CROMBOLO_SECRET_KEY`. The config's preset is
5
+ * the provider; these are only the fields changed, put on top of it at every
6
+ * sign-in, so a change reaches every node with nothing to invalidate.
7
+ */
8
+ import type { Db, ExternalLoginAudience } from '@crombolo/data'
9
+ import { entraIssuer, type IdentityProvider } from './identity-providers.ts'
10
+ import { openSecret, SECRET_KEY_VARIABLE, sealSecret, usableSecretKey } from './secrets.ts'
11
+
12
+ /** The fields an override may change. */
13
+ export interface ProviderOverrides {
14
+ name?: string
15
+ tenantId?: string
16
+ issuer?: string
17
+ clientId?: string
18
+ autoCreate?: boolean
19
+ linkByEmail?: boolean
20
+ emailsVerified?: boolean
21
+ groupMap?: Record<string, string>
22
+ defaultGroups?: string[]
23
+ }
24
+
25
+ /** What an administrator sends: the fields to change, and a client secret to set (`null` clears it). */
26
+ export interface ProviderOverrideInput extends ProviderOverrides {
27
+ clientSecret?: string | null
28
+ }
29
+
30
+ interface Stored extends ProviderOverrides {
31
+ clientSecretSealed?: string
32
+ }
33
+
34
+ const OVERRIDABLE: readonly (keyof ProviderOverrides)[] = [
35
+ 'name',
36
+ 'tenantId',
37
+ 'issuer',
38
+ 'clientId',
39
+ 'autoCreate',
40
+ 'linkByEmail',
41
+ 'emailsVerified',
42
+ 'groupMap',
43
+ 'defaultGroups',
44
+ ]
45
+
46
+ const keyOf = (audience: ExternalLoginAudience, id: string) =>
47
+ `crombolo:identity-provider:${audience}:${id}`
48
+
49
+ /** The provider the config declares, with an administrator's changes on top. Pure. */
50
+ export function effectiveProvider(
51
+ code: IdentityProvider,
52
+ overrides: ProviderOverrides & { clientSecret?: string },
53
+ ): IdentityProvider {
54
+ const tenantId = overrides.tenantId ?? code.tenantId
55
+ const issuer =
56
+ overrides.issuer ??
57
+ (code.kind === 'entra' && overrides.tenantId
58
+ ? entraIssuer(overrides.tenantId)
59
+ : code.oidc.issuer)
60
+ return {
61
+ ...code,
62
+ ...(tenantId ? { tenantId } : {}),
63
+ oidc: {
64
+ ...code.oidc,
65
+ name: overrides.name ?? code.oidc.name,
66
+ issuer,
67
+ clientId: overrides.clientId ?? code.oidc.clientId,
68
+ clientSecret: overrides.clientSecret ?? code.oidc.clientSecret,
69
+ },
70
+ autoCreate: overrides.autoCreate ?? code.autoCreate,
71
+ linkByEmail: overrides.linkByEmail ?? code.linkByEmail,
72
+ emailsVerified: overrides.emailsVerified ?? code.emailsVerified,
73
+ groupMap: overrides.groupMap ?? code.groupMap,
74
+ defaultGroups: overrides.defaultGroups ?? code.defaultGroups,
75
+ }
76
+ }
77
+
78
+ export class IdentityProviderOverrides {
79
+ #db: Db
80
+ #secretKey: string | undefined
81
+
82
+ constructor(db: Db, secretKey: string | undefined) {
83
+ this.#db = db
84
+ this.#secretKey = usableSecretKey(secretKey)
85
+ }
86
+
87
+ async #read(audience: ExternalLoginAudience, id: string): Promise<Stored> {
88
+ const rows = await this.#db.query<{ value: string | null }>(
89
+ 'SELECT value FROM key_value WHERE key = ?',
90
+ [keyOf(audience, id)],
91
+ )
92
+ try {
93
+ return rows[0]?.value ? (JSON.parse(rows[0].value) as Stored) : {}
94
+ } catch {
95
+ return {}
96
+ }
97
+ }
98
+
99
+ /** The fields changed, with whether a client secret is kept here; never the secret itself. */
100
+ async get(
101
+ audience: ExternalLoginAudience,
102
+ id: string,
103
+ ): Promise<{ overrides: ProviderOverrides; hasClientSecret: boolean }> {
104
+ const { clientSecretSealed, ...overrides } = await this.#read(audience, id)
105
+ return { overrides, hasClientSecret: Boolean(clientSecretSealed) }
106
+ }
107
+
108
+ /** A provider as a sign-in uses it: the config's, with the changes and a kept secret on top. */
109
+ async apply(audience: ExternalLoginAudience, code: IdentityProvider): Promise<IdentityProvider> {
110
+ const { clientSecretSealed, ...overrides } = await this.#read(audience, code.oidc.id)
111
+ if (Object.keys(overrides).length === 0 && !clientSecretSealed) return code
112
+ let clientSecret: string | undefined
113
+ if (clientSecretSealed && this.#secretKey) {
114
+ try {
115
+ clientSecret = openSecret(clientSecretSealed, this.#secretKey)
116
+ } catch {
117
+ clientSecret = undefined
118
+ }
119
+ }
120
+ return effectiveProvider(code, { ...overrides, clientSecret })
121
+ }
122
+
123
+ /** Changes some fields. An empty string or an empty value clears a field back to the config's. */
124
+ async update(
125
+ audience: ExternalLoginAudience,
126
+ id: string,
127
+ input: ProviderOverrideInput,
128
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
129
+ const stored = await this.#read(audience, id)
130
+ for (const field of OVERRIDABLE) {
131
+ if (!(field in input)) continue
132
+ const value = input[field]
133
+ if (value === undefined || value === null || value === '') delete stored[field]
134
+ else (stored as Record<string, unknown>)[field] = value
135
+ }
136
+ if (input.clientSecret === null || input.clientSecret === '') delete stored.clientSecretSealed
137
+ else if (typeof input.clientSecret === 'string') {
138
+ if (!this.#secretKey)
139
+ return {
140
+ ok: false,
141
+ error: `Keeping a client secret here needs ${SECRET_KEY_VARIABLE} set on the server.`,
142
+ }
143
+ stored.clientSecretSealed = sealSecret(input.clientSecret, this.#secretKey)
144
+ }
145
+ await this.#write(audience, id, stored)
146
+ return { ok: true }
147
+ }
148
+
149
+ /** Puts a provider back to what the config says. */
150
+ async reset(audience: ExternalLoginAudience, id: string): Promise<void> {
151
+ await this.#db.exec('DELETE FROM key_value WHERE key = ?', [keyOf(audience, id)])
152
+ }
153
+
154
+ async #write(audience: ExternalLoginAudience, id: string, stored: Stored): Promise<void> {
155
+ const key = keyOf(audience, id)
156
+ const now = this.#db.dialect.now()
157
+ if (Object.keys(stored).length === 0) {
158
+ await this.#db.exec('DELETE FROM key_value WHERE key = ?', [key])
159
+ return
160
+ }
161
+ const value = JSON.stringify(stored)
162
+ const updated = await this.#db.query<{ key: string }>(
163
+ `UPDATE key_value SET value = ?, update_date = ${now} WHERE key = ? RETURNING key`,
164
+ [value, key],
165
+ )
166
+ if (updated.length === 0)
167
+ await this.#db.exec(`INSERT INTO key_value (key, value, update_date) VALUES (?, ?, ${now})`, [
168
+ key,
169
+ value,
170
+ ])
171
+ }
172
+ }
173
+
174
+ /** A provider as a bundle sees it: its effective settings and which of them were changed. */
175
+ export interface BundleIdentityProvider {
176
+ audience: ExternalLoginAudience
177
+ id: string
178
+ kind: IdentityProvider['kind']
179
+ name: string
180
+ tenantId: string | null
181
+ issuer: string
182
+ clientId: string
183
+ /** Whether there is a client secret, from the config or kept here. Never the secret. */
184
+ hasClientSecret: boolean
185
+ autoCreate: boolean
186
+ linkByEmail: boolean
187
+ emailsVerified: boolean
188
+ groupMap: Record<string, string>
189
+ defaultGroups: string[]
190
+ /** The fields an administrator changed, `clientSecret` among them when one is kept here. */
191
+ overridden: string[]
192
+ /** The path to register with the provider as the redirect URL, after the site's address. */
193
+ callbackPath: string
194
+ }
195
+
196
+ /** The `identityProviders` bundle capability (story 033): edit, never declare. */
197
+ export interface BundleIdentityProviders {
198
+ list(): Promise<BundleIdentityProvider[]>
199
+ update(
200
+ audience: ExternalLoginAudience,
201
+ id: string,
202
+ input: ProviderOverrideInput,
203
+ ): Promise<{ ok: true } | { ok: false; error: string }>
204
+ reset(audience: ExternalLoginAudience, id: string): Promise<boolean>
205
+ /** Whether the provider's discovery document answers, with what it says if not. */
206
+ test(audience: ExternalLoginAudience, id: string): Promise<{ ok: boolean; message: string }>
207
+ /** The groups a mapping can name: user groups by alias, member groups by name. */
208
+ groups(audience: ExternalLoginAudience): Promise<Array<{ value: string; name: string }>>
209
+ }
210
+
211
+ export function createBundleIdentityProviders(options: {
212
+ overrides: IdentityProviderOverrides
213
+ members: readonly IdentityProvider[]
214
+ backoffice: readonly IdentityProvider[]
215
+ memberCallbackPath: string
216
+ backofficeCallbackPath: string
217
+ groups(audience: ExternalLoginAudience): Promise<Array<{ value: string; name: string }>>
218
+ }): BundleIdentityProviders {
219
+ const declared = (audience: ExternalLoginAudience, id: string) =>
220
+ (audience === 'member' ? options.members : options.backoffice).find((p) => p.oidc.id === id)
221
+
222
+ const describe = async (
223
+ audience: ExternalLoginAudience,
224
+ code: IdentityProvider,
225
+ ): Promise<BundleIdentityProvider> => {
226
+ const effective = await options.overrides.apply(audience, code)
227
+ const { overrides, hasClientSecret } = await options.overrides.get(audience, code.oidc.id)
228
+ const base = audience === 'member' ? options.memberCallbackPath : options.backofficeCallbackPath
229
+ return {
230
+ audience,
231
+ id: code.oidc.id,
232
+ kind: code.kind,
233
+ name: effective.oidc.name,
234
+ tenantId: effective.tenantId ?? null,
235
+ issuer: effective.oidc.issuer,
236
+ clientId: effective.oidc.clientId,
237
+ hasClientSecret: Boolean(effective.oidc.clientSecret),
238
+ autoCreate: effective.autoCreate,
239
+ linkByEmail: effective.linkByEmail,
240
+ emailsVerified: effective.emailsVerified,
241
+ groupMap: { ...effective.groupMap },
242
+ defaultGroups: [...effective.defaultGroups],
243
+ overridden: [...Object.keys(overrides), ...(hasClientSecret ? ['clientSecret'] : [])],
244
+ callbackPath: `${base}/${code.oidc.id}/callback`,
245
+ }
246
+ }
247
+
248
+ return {
249
+ async list() {
250
+ return Promise.all([
251
+ ...options.members.map((p) => describe('member', p)),
252
+ ...options.backoffice.map((p) => describe('user', p)),
253
+ ])
254
+ },
255
+ async update(audience, id, input) {
256
+ if (!declared(audience, id))
257
+ return { ok: false, error: 'The config declares no such identity provider.' }
258
+ return options.overrides.update(audience, id, input)
259
+ },
260
+ async reset(audience, id) {
261
+ if (!declared(audience, id)) return false
262
+ await options.overrides.reset(audience, id)
263
+ return true
264
+ },
265
+ groups: (audience) => options.groups(audience),
266
+ async test(audience, id) {
267
+ const code = declared(audience, id)
268
+ if (!code) return { ok: false, message: 'The config declares no such identity provider.' }
269
+ const effective = await options.overrides.apply(audience, code)
270
+ const url = `${effective.oidc.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`
271
+ try {
272
+ const response = await fetch(url, { headers: { accept: 'application/json' } })
273
+ if (!response.ok) return { ok: false, message: `${url} answered ${response.status}.` }
274
+ const document = (await response.json()) as { authorization_endpoint?: unknown }
275
+ return typeof document.authorization_endpoint === 'string'
276
+ ? { ok: true, message: `${effective.oidc.name} answered at ${effective.oidc.issuer}.` }
277
+ : { ok: false, message: `${url} is not an OpenID Connect discovery document.` }
278
+ } catch (error) {
279
+ return { ok: false, message: `${url} could not be reached: ${(error as Error).message}` }
280
+ }
281
+ },
282
+ }
283
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * External identity providers (story 031): typed presets a site's config lists,
3
+ * one list for members and one for backoffice users. Each preset knows its
4
+ * provider's issuer, scopes and claims; the rest says what a first sign-in may
5
+ * do and how groups follow the account.
6
+ *
7
+ * ```ts
8
+ * defineConfig({
9
+ * backofficeIdentityProviders: [entra({ tenantId, clientId, clientSecret, groups: { map: { … } } })],
10
+ * memberIdentityProviders: [awsIdentityCenter({ issuer, clientId, clientSecret })],
11
+ * })
12
+ * ```
13
+ */
14
+ import type { OidcClaims, OidcProviderOptions } from '@crombolo/auth'
15
+
16
+ /** What every preset takes besides the provider's own settings. */
17
+ export interface IdentityProviderOptions {
18
+ /** The provider's id, in its URLs and in `external_login`; the preset's name by default. */
19
+ id?: string
20
+ /** What its sign-in button says: "Sign in with {name}". */
21
+ name?: string
22
+ /** Create a member or user for an account nobody has yet. On by default. */
23
+ autoCreate?: boolean
24
+ /**
25
+ * Link a first sign-in to an existing member or user with the same email,
26
+ * when the provider says the email is verified. Off by default: without it, an
27
+ * email already taken is refused rather than merged.
28
+ */
29
+ linkByEmail?: boolean
30
+ /**
31
+ * Treat every email this provider sends as verified. For a provider whose
32
+ * emails are the organisation's own, such as one Entra tenant, which sends no
33
+ * `email_verified` claim.
34
+ */
35
+ emailsVerified?: boolean
36
+ /**
37
+ * Groups that follow the account: claim values mapped to member groups (for
38
+ * members) or user group aliases (for users), made to match the claim on
39
+ * every sign-in. Groups outside the map are left as they are.
40
+ */
41
+ groups?: { claim?: string; map: Record<string, string> }
42
+ /** Groups a new member or user joins, besides the mapped ones. */
43
+ defaultGroups?: readonly string[]
44
+ /** Members only: the member type a new member is created as, by alias; `Member` by default. */
45
+ memberType?: string
46
+ }
47
+
48
+ /** Which preset made a provider: what the Entra bundle looks for. */
49
+ export type IdentityProviderKind = 'entra' | 'aws' | 'oidc'
50
+
51
+ /** A provider, ready for the config. */
52
+ export interface IdentityProvider {
53
+ kind: IdentityProviderKind
54
+ /** Entra only: the tenant its issuer is for, which an override may change. */
55
+ tenantId?: string
56
+ oidc: OidcProviderOptions
57
+ autoCreate: boolean
58
+ linkByEmail: boolean
59
+ emailsVerified: boolean
60
+ groupMap: Readonly<Record<string, string>>
61
+ defaultGroups: readonly string[]
62
+ memberType: string | undefined
63
+ }
64
+
65
+ const ID = /^[a-z0-9][a-z0-9-]*$/
66
+
67
+ /** Entra's issuer for a tenant. */
68
+ export const entraIssuer = (tenantId: string) =>
69
+ `https://login.microsoftonline.com/${tenantId}/v2.0`
70
+
71
+ function preset(
72
+ kind: IdentityProviderKind,
73
+ defaults: { id: string; name: string },
74
+ provider: { issuer: string; clientId: string; clientSecret?: string; scopes?: readonly string[] },
75
+ claims: OidcClaims,
76
+ options: IdentityProviderOptions,
77
+ ): IdentityProvider {
78
+ const id = options.id ?? defaults.id
79
+ if (!ID.test(id))
80
+ throw new Error(`An identity provider's id is letters, digits and dashes: "${id}" is not.`)
81
+ if (!provider.issuer) throw new Error(`The ${defaults.name} identity provider needs an issuer.`)
82
+ if (!provider.clientId)
83
+ throw new Error(`The ${defaults.name} identity provider needs a clientId.`)
84
+ return {
85
+ kind,
86
+ oidc: {
87
+ id,
88
+ name: options.name ?? defaults.name,
89
+ issuer: provider.issuer,
90
+ clientId: provider.clientId,
91
+ clientSecret: provider.clientSecret,
92
+ scopes: provider.scopes ?? ['openid', 'profile', 'email'],
93
+ // A group map with no claim named reads the usual `groups` claim.
94
+ claims: {
95
+ ...claims,
96
+ groups: options.groups?.claim ?? claims.groups ?? (options.groups ? 'groups' : undefined),
97
+ },
98
+ },
99
+ autoCreate: options.autoCreate ?? true,
100
+ linkByEmail: options.linkByEmail ?? false,
101
+ emailsVerified: options.emailsVerified ?? false,
102
+ groupMap: options.groups?.map ?? {},
103
+ defaultGroups: options.defaultGroups ?? [],
104
+ memberType: options.memberType,
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Microsoft Entra ID, for one tenant. Register a web app with a client secret
110
+ * and the redirect URLs in `docs/operations.md`; for groups, add the `groups`
111
+ * claim to its token configuration.
112
+ */
113
+ export function entra(
114
+ options: IdentityProviderOptions & { tenantId: string; clientId: string; clientSecret?: string },
115
+ ): IdentityProvider {
116
+ if (!options.tenantId) throw new Error('The Entra identity provider needs a tenantId.')
117
+ const provider = preset(
118
+ 'entra',
119
+ { id: 'entra', name: 'Microsoft' },
120
+ {
121
+ issuer: entraIssuer(options.tenantId),
122
+ clientId: options.clientId,
123
+ clientSecret: options.clientSecret,
124
+ },
125
+ { email: 'email', name: 'name', groups: 'groups' },
126
+ options,
127
+ )
128
+ return { ...provider, tenantId: options.tenantId }
129
+ }
130
+
131
+ /**
132
+ * AWS IAM Identity Center, through the OpenID Connect application its
133
+ * instance issues; `issuer` is that instance's issuer URL.
134
+ */
135
+ export function awsIdentityCenter(
136
+ options: IdentityProviderOptions & { issuer: string; clientId: string; clientSecret?: string },
137
+ ): IdentityProvider {
138
+ return preset(
139
+ 'aws',
140
+ { id: 'aws', name: 'AWS' },
141
+ { issuer: options.issuer, clientId: options.clientId, clientSecret: options.clientSecret },
142
+ { email: 'email', name: 'name', groups: 'groups' },
143
+ options,
144
+ )
145
+ }
146
+
147
+ /** Any OpenID Connect provider, by its issuer: Google, Okta, Auth0, Keycloak and the like. */
148
+ export function oidc(
149
+ options: IdentityProviderOptions & {
150
+ id: string
151
+ name: string
152
+ issuer: string
153
+ clientId: string
154
+ clientSecret?: string
155
+ scopes?: readonly string[]
156
+ claims?: Partial<OidcClaims>
157
+ },
158
+ ): IdentityProvider {
159
+ return preset(
160
+ 'oidc',
161
+ { id: options.id, name: options.name },
162
+ {
163
+ issuer: options.issuer,
164
+ clientId: options.clientId,
165
+ clientSecret: options.clientSecret,
166
+ scopes: options.scopes,
167
+ },
168
+ { email: 'email', name: 'name', ...options.claims },
169
+ options,
170
+ )
171
+ }
172
+
173
+ /** Refuses two providers with one id in a list, which would share one set of URLs. */
174
+ export function checkIdentityProviders(
175
+ list: readonly IdentityProvider[],
176
+ label: string,
177
+ ): readonly IdentityProvider[] {
178
+ const seen = new Set<string>()
179
+ for (const provider of list) {
180
+ if (seen.has(provider.oidc.id))
181
+ throw new Error(`Two ${label} identity providers have the id "${provider.oidc.id}".`)
182
+ seen.add(provider.oidc.id)
183
+ }
184
+ return list
185
+ }
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'
@@ -34,6 +35,7 @@ export * from './email-postmark.ts'
34
35
  export * from './email-resend.ts'
35
36
  export * from './email-ses.ts'
36
37
  export * from './extensions-notice.ts'
38
+ export * from './external-sign-in.ts'
37
39
  export * from './file-intake.ts'
38
40
  export * from './form-files.ts'
39
41
  export * from './form-submit.ts'
@@ -43,6 +45,8 @@ export * from './forms.ts'
43
45
  export * from './git.ts'
44
46
  export * from './git-github.ts'
45
47
  export * from './git-routes.ts'
48
+ export * from './identity-provider-overrides.ts'
49
+ export * from './identity-providers.ts'
46
50
  export * from './imaging.ts'
47
51
  export * from './jobs.ts'
48
52
  export * from './listen.ts'
@@ -60,6 +64,9 @@ export * from './no-nodes.ts'
60
64
  export * from './oembed.ts'
61
65
  export * from './ports.ts'
62
66
  export * from './redirects.ts'
67
+ export * from './remote.ts'
68
+ export * from './remote-credentials.ts'
69
+ export * from './remote-media.ts'
63
70
  export * from './schema.ts'
64
71
  export * from './schema-store.ts'
65
72
  export * from './secrets.ts'
@@ -67,8 +74,13 @@ export * from './seo.ts'
67
74
  export * from './server.ts'
68
75
  export * from './server-events.ts'
69
76
  export * from './setup.ts'
77
+ export * from './site-file.ts'
78
+ export * from './site-replace.ts'
79
+ export * from './site-sections.ts'
70
80
  export * from './status.ts'
81
+ export * from './tar.ts'
71
82
  export * from './transfer-dashboard.ts'
83
+ export * from './two-factor.ts'
72
84
  export * from './udt.ts'
73
85
  export * from './upgrade-summary.ts'
74
86
  export * from './url-settings.ts'
@@ -156,13 +156,19 @@ export function azureMediaStore(options: AzureMediaStoreOptions): MediaStore {
156
156
  if (!response.ok) throw new Error(`Azure GET ${key} failed with ${response.status}.`)
157
157
  return new Uint8Array(await response.arrayBuffer())
158
158
  },
159
+ stream: async () => {
160
+ const response = await send('GET', url)
161
+ if (!response.ok || !response.body)
162
+ throw new Error(`Azure GET ${key} failed with ${response.status}.`)
163
+ return response.body
164
+ },
159
165
  }
160
166
  },
161
167
 
162
168
  async put(key, body, putOptions) {
163
169
  if (!isSafeMediaKey(key)) throw new Error(`"${key}" is not a usable media key.`)
164
170
  // Copied into a plain ArrayBuffer so `fetch` and the signature agree on its length.
165
- const bytes = new Uint8Array(body instanceof File ? await body.arrayBuffer() : body)
171
+ const bytes = new Uint8Array(body instanceof Blob ? await body.arrayBuffer() : body)
166
172
  const response = await send('PUT', blobUrl(key), {
167
173
  body: bytes,
168
174
  headers: {
@@ -61,6 +61,7 @@ export function s3MediaStore(options: S3MediaStoreOptions): MediaStore {
61
61
  etag: stat.etag.replaceAll('"', ''),
62
62
  contentType: stat.type || contentTypeFor(key),
63
63
  bytes: async () => new Uint8Array(await file.arrayBuffer()),
64
+ stream: async () => file.stream(),
64
65
  }
65
66
  } catch {
66
67
  // A missing object is a thrown NoSuchKey, not a null stat.
@@ -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
@@ -31,6 +32,8 @@ export interface StoredFile {
31
32
  etag: string
32
33
  contentType: string
33
34
  bytes(): Promise<Uint8Array>
35
+ /** The bytes as a stream, for a file too big to hold (story 104); `streamOf` falls back to `bytes()`. */
36
+ stream?(): Promise<ReadableStream<Uint8Array>>
34
37
  /** A path on this machine, when the store has one; lets the server stream it. */
35
38
  localPath?: string
36
39
  }
@@ -39,7 +42,7 @@ export interface MediaStore {
39
42
  /** Named in the boot banner and in logs: `media/`, `s3://bucket`, … */
40
43
  readonly description: string
41
44
  get(key: string): Promise<StoredFile | undefined>
42
- put(key: string, body: Uint8Array | File, options?: { contentType?: string }): Promise<void>
45
+ put(key: string, body: Uint8Array | Blob, options?: { contentType?: string }): Promise<void>
43
46
  delete(key: string): Promise<void>
44
47
  /** Every key under a prefix, which is how temporary uploads are swept. */
45
48
  list(prefix: string): Promise<string[]>
@@ -51,6 +54,24 @@ export interface MediaStore {
51
54
  publicUrl?(key: string): string | undefined
52
55
  }
53
56
 
57
+ /** A stored file's bytes in chunks: streamed where the store can, whole where it cannot. */
58
+ export async function* streamOf(file: StoredFile): AsyncIterable<Uint8Array> {
59
+ if (!file.stream) {
60
+ yield await file.bytes()
61
+ return
62
+ }
63
+ const reader = (await file.stream()).getReader()
64
+ try {
65
+ for (;;) {
66
+ const { done, value } = await reader.read()
67
+ if (done) return
68
+ yield value
69
+ }
70
+ } finally {
71
+ reader.releaseLock()
72
+ }
73
+ }
74
+
54
75
  /** Content types by extension, for stores that do not infer one. */
55
76
  const CONTENT_TYPES: Record<string, string> = {
56
77
  avif: 'image/avif',
@@ -91,7 +112,8 @@ export function isSafeMediaKey(key: string): boolean {
91
112
  part !== '' &&
92
113
  part !== '.' &&
93
114
  part !== '..' &&
94
- (!part.startsWith('.') || (index === 0 && (part === '.temp' || part === '.cache'))),
115
+ (!part.startsWith('.') ||
116
+ (index === 0 && (part === '.temp' || part === '.cache' || part === '.remote'))),
95
117
  )
96
118
  }
97
119
 
@@ -139,6 +161,7 @@ export function fileSystemMediaStore(
139
161
  etag: `${stat.size.toString(16)}-${Math.trunc(stat.mtimeMs).toString(16)}`,
140
162
  contentType: file.type || contentTypeFor(key),
141
163
  bytes: () => file.bytes(),
164
+ stream: async () => file.stream(),
142
165
  localPath: path,
143
166
  }
144
167
  },