@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.
@@ -12,6 +12,9 @@
12
12
  * POST /umbraco/members/logout
13
13
  * POST /umbraco/members/register name, email, username, password, returnUrl?
14
14
  * GET /umbraco/members/current
15
+ * POST /umbraco/members/login/2fa code, returnUrl? the second step, when 2FA is on
16
+ * GET /umbraco/members/2fa the signed-in member's 2FA state
17
+ * POST /umbraco/members/2fa/setup | enable | disable turning it on and off (story 032)
15
18
  *
16
19
  * A form post is answered with a redirect, a JSON post with JSON, so the same
17
20
  * endpoint serves a plain `<form>` in a TSX template and a fetch from the page.
@@ -27,14 +30,17 @@ import {
27
30
  passwordConfigJson,
28
31
  readCookie,
29
32
  readMemberTicket,
33
+ readShortLived,
30
34
  serializeCookie,
31
35
  signMemberTicket,
36
+ signShortLived,
32
37
  verifyPassword,
33
38
  } from '@crombolo/auth'
34
39
  import {
35
40
  DEFAULT_PASSWORD_CONFIGURATION,
36
41
  isValidPassword,
37
42
  localReturnUrl,
43
+ memberMayOpen,
38
44
  ObjectTypes,
39
45
  } from '@crombolo/core'
40
46
  import {
@@ -45,14 +51,25 @@ import {
45
51
  type NodeSchemaState,
46
52
  PublicAccessRepository,
47
53
  } from '@crombolo/data'
48
- import type { AccessDecision, PublishedContent, RequestMember } from '@crombolo/render'
54
+ import {
55
+ type AccessDecision,
56
+ MEMBER_EXTERNAL_SIGN_IN_PATH,
57
+ type PublishedContent,
58
+ type RequestMember,
59
+ } from '@crombolo/render'
49
60
  import type { CromboloConfig } from './config.ts'
61
+ import { createExternalSignIn, type ExternalAccount } from './external-sign-in.ts'
62
+ import { IdentityProviderOverrides } from './identity-provider-overrides.ts'
63
+ import { MEMBER_TWO_FACTOR_PROVIDER, TwoFactorService } from './two-factor.ts'
50
64
 
51
65
  /** The key member tickets are signed with, generated once and shared by every node. */
52
66
  const SIGNING_KEY = 'crombolo/memberCookieKey'
53
67
 
54
68
  export const MEMBER_ROUTE_PREFIX = '/umbraco/members'
55
69
 
70
+ /** How long a member has to enter their code after their password. */
71
+ const TWO_FACTOR_PENDING_SECONDS = 5 * 60
72
+
56
73
  export interface MemberAuthOptions {
57
74
  /** The schema state this node runs at; a registration is refused without it. */
58
75
  nodeState?: NodeSchemaState
@@ -72,6 +89,11 @@ export interface MemberAuth {
72
89
  handle(request: Request, pathname: string): Promise<Response | undefined>
73
90
  /** The `Set-Cookie` value that ends a member's site session. */
74
91
  signOutCookie(): string
92
+ /**
93
+ * Where a "Sign in with …" link for a member identity provider goes, by its
94
+ * id; undefined for a provider the site does not configure.
95
+ */
96
+ externalSignInUrl(providerId: string, returnUrl?: string): string | undefined
75
97
  }
76
98
 
77
99
  const isFormPost = (request: Request) =>
@@ -149,6 +171,13 @@ export function createMemberAuth(
149
171
  return signingKey
150
172
  }
151
173
 
174
+ const twoFactor = new TwoFactorService(db, {
175
+ secretKey: config.secretKey,
176
+ issuer: config.siteName,
177
+ })
178
+ const pendingCookie = `${cookie}TwoFactor`
179
+ const flagged = (url: string, flags: string) => `${url}${url.includes('?') ? '&' : '?'}${flags}`
180
+
152
181
  const asRequestMember = (member: {
153
182
  key: string
154
183
  name: string
@@ -176,6 +205,93 @@ export function createMemberAuth(
176
205
  })
177
206
  }
178
207
 
208
+ /** Member group keys by name, for the names a registration or a provider names. */
209
+ const groupKeysFor = async (names: readonly string[]): Promise<string[]> => {
210
+ const keys: string[] = []
211
+ for (const groupName of names) {
212
+ const rows = await db.query<{ unique_id: string }>(
213
+ 'SELECT unique_id FROM node WHERE text = ? AND node_object_type = ?',
214
+ [groupName, ObjectTypes.MemberGroup],
215
+ )
216
+ if (rows[0]) keys.push(String(rows[0].unique_id))
217
+ }
218
+ return keys
219
+ }
220
+
221
+ const asAccount = (member: { key: string; email: string; roles: string[] }): ExternalAccount => ({
222
+ key: member.key,
223
+ email: member.email,
224
+ groups: member.roles,
225
+ })
226
+
227
+ const MEMBER_EXTERNAL_PATH = MEMBER_EXTERNAL_SIGN_IN_PATH
228
+ const external =
229
+ config.memberIdentityProviders.length === 0
230
+ ? undefined
231
+ : createExternalSignIn(
232
+ db,
233
+ {
234
+ audience: 'member',
235
+ providers: config.memberIdentityProviders,
236
+ basePath: MEMBER_EXTERNAL_PATH,
237
+ byKey: async (key) => {
238
+ const member = await members.byKey(key)
239
+ return member ? asAccount(member) : undefined
240
+ },
241
+ byEmail: async (email) => {
242
+ const member = await members.byEmail(email)
243
+ return member ? asAccount(member) : undefined
244
+ },
245
+ create: async (identity, provider, groups) => {
246
+ const alias = provider.memberType ?? config.memberRegistrationType
247
+ const type = await memberTypes.byAlias(alias)
248
+ if (!type) throw new Error(`The member type "${alias}" does not exist.`)
249
+ const email = identity.email as string
250
+ // The email is the username, as registration makes it, unless
251
+ // someone already signs in with that name.
252
+ const username = (await members.taken('username', email))
253
+ ? `${provider.oidc.id}-${identity.subject}`
254
+ : email
255
+ const created = await members.create(
256
+ {
257
+ key: crypto.randomUUID(),
258
+ contentTypeKey: type.key,
259
+ componentKey: null,
260
+ parentKey: null,
261
+ values: [],
262
+ variants: [{ culture: null, segment: null, name: identity.name || email }],
263
+ },
264
+ {
265
+ email,
266
+ username,
267
+ isApproved: true,
268
+ isLockedOut: false,
269
+ groupKeys: await groupKeysFor(groups),
270
+ },
271
+ )
272
+ return asAccount(created)
273
+ },
274
+ setGroups: async (account, groups) => {
275
+ await members.setGroups(account.key, await groupKeysFor(groups))
276
+ },
277
+ signIn: async (account) => {
278
+ const member = await members.byKey(account.key)
279
+ if (!member?.isApproved || member.isLockedOut)
280
+ return { refused: 'This account cannot sign in at the moment.' }
281
+ await members.recordLogin(member.key)
282
+ return { cookies: [await issue(member.key, member.securityStamp, false)] }
283
+ },
284
+ failed: (returnUrl, reason) =>
285
+ `${returnUrl}${returnUrl.includes('?') ? '&' : '?'}memberLoginFailed=1&memberLoginError=${encodeURIComponent(reason)}`,
286
+ },
287
+ {
288
+ applicationUrl: config.applicationUrl,
289
+ secureCookies: config.secureCookies,
290
+ resolve: (provider) =>
291
+ new IdentityProviderOverrides(db, config.secretKey).apply('member', provider),
292
+ },
293
+ )
294
+
179
295
  const resolve: MemberAuth['resolve'] = async (request) => {
180
296
  const ticket = await readMemberTicket(await secret(), readCookie(request, cookie))
181
297
  if (!ticket) return undefined
@@ -196,12 +312,7 @@ export function createMemberAuth(
196
312
  // branch they serve, or the substitution would never terminate.
197
313
  if (here === entry.loginNodeKey || here === entry.errorNodeKey) return { status: 'allowed' }
198
314
  if (!member) return { status: 'substitute', contentKey: entry.loginNodeKey }
199
- const allowed =
200
- entry.memberUserNames.some((name) => name.toLowerCase() === member.username.toLowerCase()) ||
201
- entry.memberGroupNames.some((name) =>
202
- member.roles.some((role) => role.toLowerCase() === name.toLowerCase()),
203
- )
204
- return allowed
315
+ return memberMayOpen(entry, { username: member.username, groups: member.roles })
205
316
  ? { status: 'allowed' }
206
317
  : { status: 'substitute', contentKey: entry.errorNodeKey }
207
318
  }
@@ -268,6 +379,28 @@ export function createMemberAuth(
268
379
  await hashPassword(input.password ?? ''),
269
380
  passwordConfigJson(),
270
381
  )
382
+ if ((await twoFactor.providers(member.key)).includes(MEMBER_TWO_FACTOR_PROVIDER)) {
383
+ const pending = await signShortLived(
384
+ await secret(),
385
+ { sub: member.key, persist: input.rememberMe === 'true' ? 1 : 0 },
386
+ TWO_FACTOR_PENDING_SECONDS,
387
+ )
388
+ return answer(
389
+ request,
390
+ { ok: true, body: { twoFactorRequired: true } },
391
+ {
392
+ returnUrl: flagged(back, 'memberTwoFactorRequired=1'),
393
+ cookies: [
394
+ serializeCookie({
395
+ name: pendingCookie,
396
+ value: pending,
397
+ secure: config.secureCookies,
398
+ maxAgeSeconds: TWO_FACTOR_PENDING_SECONDS,
399
+ }),
400
+ ],
401
+ },
402
+ )
403
+ }
271
404
  await members.recordLogin(member.key)
272
405
  return answer(
273
406
  request,
@@ -279,6 +412,87 @@ export function createMemberAuth(
279
412
  )
280
413
  }
281
414
 
415
+ /** The second step: the code from the app, or a recovery code, after the password. */
416
+ const verifyTwoFactor = async (request: Request): Promise<Response> => {
417
+ const input = await readInput(request)
418
+ const back = safeReturnUrl(input.returnUrl) ?? '/'
419
+ const pending = await readShortLived(await secret(), readCookie(request, pendingCookie))
420
+ const key = typeof pending?.sub === 'string' ? pending.sub : undefined
421
+ const member = key ? await members.byKey(key) : undefined
422
+ if (!member || member.isLockedOut || !member.isApproved)
423
+ return answer(
424
+ request,
425
+ { ok: false, status: 401, error: 'Sign in again.' },
426
+ {
427
+ returnUrl: flagged(back, 'memberLoginFailed=1'),
428
+ cookies: [expireCookie(pendingCookie, config.secureCookies)],
429
+ },
430
+ )
431
+ if (!(await twoFactor.verify(member.key, MEMBER_TWO_FACTOR_PROVIDER, input.code ?? ''))) {
432
+ await members.recordFailedLogin(member.key, config.maxFailedPasswordAttempts)
433
+ return answer(
434
+ request,
435
+ { ok: false, status: 400, error: 'That code is not right.' },
436
+ { returnUrl: flagged(back, 'memberTwoFactorRequired=1&memberLoginFailed=1') },
437
+ )
438
+ }
439
+ await members.recordLogin(member.key)
440
+ return answer(
441
+ request,
442
+ { ok: true, body: { member: asRequestMember(member) } },
443
+ {
444
+ returnUrl: back,
445
+ cookies: [
446
+ await issue(member.key, member.securityStamp, pending?.persist === 1),
447
+ expireCookie(pendingCookie, config.secureCookies),
448
+ ],
449
+ },
450
+ )
451
+ }
452
+
453
+ /** The signed-in member's own two-factor settings; JSON, as the recovery codes must be shown. */
454
+ const manageTwoFactor = async (request: Request, step: string): Promise<Response> => {
455
+ const member = await resolve(request)
456
+ if (!member) return Response.json({ error: 'Sign in first.' }, { status: 401 })
457
+ const refused = (message: string, status = 400) => Response.json({ error: message }, { status })
458
+ if (step === '') {
459
+ if (request.method !== 'GET') return new Response(null, { status: 405 })
460
+ return Response.json({
461
+ available: twoFactor.available,
462
+ enabled: (await twoFactor.providers(member.key)).includes(MEMBER_TWO_FACTOR_PROVIDER),
463
+ recoveryCodesLeft: await twoFactor.recoveryCodesLeft(member.key),
464
+ })
465
+ }
466
+ if (request.method !== 'POST') return new Response(null, { status: 405 })
467
+ if (!fromThisSite(request)) return new Response('Forbidden', { status: 403 })
468
+ const input = await readInput(request)
469
+ switch (step) {
470
+ case 'setup': {
471
+ const setup = twoFactor.setup(member.email || member.username)
472
+ return setup.ok ? Response.json(setup.value) : refused(setup.message)
473
+ }
474
+ case 'enable': {
475
+ const enabled = await twoFactor.enable(
476
+ member.key,
477
+ MEMBER_TWO_FACTOR_PROVIDER,
478
+ input.secret ?? '',
479
+ input.code ?? '',
480
+ )
481
+ return enabled.ok ? Response.json(enabled.value) : refused(enabled.message)
482
+ }
483
+ case 'disable': {
484
+ const disabled = await twoFactor.disable(
485
+ member.key,
486
+ MEMBER_TWO_FACTOR_PROVIDER,
487
+ input.code ?? '',
488
+ )
489
+ return disabled.ok ? Response.json({ ok: true }) : refused(disabled.message)
490
+ }
491
+ default:
492
+ return new Response(null, { status: 404 })
493
+ }
494
+ }
495
+
282
496
  const logout = async (request: Request): Promise<Response> => {
283
497
  const input = await readInput(request)
284
498
  return answer(
@@ -334,14 +548,7 @@ export function createMemberAuth(
334
548
  status: 400,
335
549
  error: `A password must be at least ${DEFAULT_PASSWORD_CONFIGURATION.minimumPasswordLength} characters.`,
336
550
  })
337
- const groupKeys: string[] = []
338
- for (const groupName of config.memberRegistrationGroups) {
339
- const rows = await db.query<{ unique_id: string }>(
340
- 'SELECT unique_id FROM node WHERE text = ? AND node_object_type = ?',
341
- [groupName, ObjectTypes.MemberGroup],
342
- )
343
- if (rows[0]) groupKeys.push(String(rows[0].unique_id))
344
- }
551
+ const groupKeys = await groupKeysFor(config.memberRegistrationGroups)
345
552
  const created = await members.create(
346
553
  {
347
554
  key: crypto.randomUUID(),
@@ -375,19 +582,29 @@ export function createMemberAuth(
375
582
  resolve,
376
583
  decide,
377
584
  signOutCookie: () => expireCookie(cookie, config.secureCookies),
585
+ externalSignInUrl: (providerId, returnUrl) =>
586
+ config.memberIdentityProviders.some((p) => p.oidc.id === providerId)
587
+ ? external?.startUrl(providerId, returnUrl)
588
+ : undefined,
378
589
  async handle(request, pathname) {
379
590
  if (!pathname.startsWith(`${MEMBER_ROUTE_PREFIX}/`)) return undefined
591
+ if (pathname.startsWith(`${MEMBER_EXTERNAL_PATH}/`))
592
+ return external?.handle(request, pathname) ?? undefined
380
593
  const action = pathname.slice(MEMBER_ROUTE_PREFIX.length + 1)
381
594
  if (action === 'current') {
382
595
  if (request.method !== 'GET') return new Response(null, { status: 405 })
383
596
  const member = await resolve(request)
384
597
  return member ? Response.json({ member }) : Response.json({ member: null }, { status: 401 })
385
598
  }
599
+ if (action === '2fa' || action.startsWith('2fa/'))
600
+ return manageTwoFactor(request, action.slice(4))
386
601
  if (request.method !== 'POST') return new Response(null, { status: 405 })
387
602
  if (!fromThisSite(request)) return new Response('Forbidden', { status: 403 })
388
603
  switch (action) {
389
604
  case 'login':
390
605
  return login(request)
606
+ case 'login/2fa':
607
+ return verifyTwoFactor(request)
391
608
  case 'logout':
392
609
  return logout(request)
393
610
  case 'register':
package/src/ports.ts CHANGED
@@ -12,6 +12,7 @@ import type {
12
12
  ServerPort,
13
13
  TemporaryFilePort,
14
14
  UserItemPort,
15
+ UserTwoFactorPort,
15
16
  } from '@crombolo/api-management'
16
17
  import { AuthStore, expireCookie, serializeCookie } from '@crombolo/auth'
17
18
  import {
@@ -84,6 +85,7 @@ import { createOEmbedService } from './oembed.ts'
84
85
  import { createRedirectTracker } from './redirects.ts'
85
86
  import { componentAliasesIn, type SchemaBoot } from './schema.ts'
86
87
  import { publishSchema } from './schema-store.ts'
88
+ import { TwoFactorService, USER_TWO_FACTOR_PROVIDER } from './two-factor.ts'
87
89
  import { createWebhookService, type WebhookService } from './webhooks.ts'
88
90
 
89
91
  /** Re-exported for tests; the canonical list lives in @crombolo/core. */
@@ -492,7 +494,22 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
492
494
  await store.endAllSessionsForUser(userId)
493
495
  },
494
496
  })
495
- deps.userManagement = userPorts.users
497
+ // Story 032: the authenticator provider, offered only where secrets can be sealed.
498
+ const twoFactor = new TwoFactorService(options.db, {
499
+ secretKey: options.config.secretKey,
500
+ issuer: options.config.siteName,
501
+ })
502
+ const configuration = userPorts.users.configuration
503
+ const currentConfiguration = userPorts.users.currentConfiguration
504
+ deps.userManagement = {
505
+ ...userPorts.users,
506
+ configuration: () => ({ ...configuration(), allowTwoFactor: twoFactor.available }),
507
+ currentConfiguration: () => ({
508
+ ...currentConfiguration(),
509
+ allowTwoFactor: twoFactor.available,
510
+ }),
511
+ twoFactor: userTwoFactorPort(twoFactor),
512
+ }
496
513
  deps.userGroups = userPorts.groups
497
514
  deps.userData = userPorts.userData
498
515
  deps.media = createMediaPort(options.db, mediaFiles, {
@@ -621,3 +638,40 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
621
638
  }
622
639
  return deps
623
640
  }
641
+
642
+ /** The user operations' view of two-factor sign-in: the one authenticator provider. */
643
+ function userTwoFactorPort(service: TwoFactorService): UserTwoFactorPort {
644
+ const provider = USER_TWO_FACTOR_PROVIDER
645
+ return {
646
+ providerName: provider,
647
+ async list(userKey) {
648
+ return [
649
+ {
650
+ providerName: provider,
651
+ isEnabledOnUser: (await service.providers(userKey)).includes(provider),
652
+ },
653
+ ]
654
+ },
655
+ setup(account) {
656
+ const setup = service.setup(account)
657
+ return setup.ok
658
+ ? {
659
+ ok: true,
660
+ secret: setup.value.secret,
661
+ qrCodeSetupImageUrl: setup.value.qrCodeSetupImageUrl,
662
+ }
663
+ : { ok: false, message: setup.message }
664
+ },
665
+ async enable(userKey, code, secret) {
666
+ const enabled = await service.enable(userKey, provider, secret, code)
667
+ return enabled.ok
668
+ ? { ok: true, recoveryCodes: enabled.value.recoveryCodes }
669
+ : { ok: false, message: enabled.message }
670
+ },
671
+ async disable(userKey, code) {
672
+ const disabled = await service.disable(userKey, provider, code)
673
+ return disabled.ok ? { ok: true } : { ok: false, message: disabled.message }
674
+ },
675
+ reset: (userKey) => service.reset(userKey, provider),
676
+ }
677
+ }
@@ -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
+ }