@glw907/cairn-cms 0.56.2 → 0.57.0

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.
Files changed (173) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/dist/components/AdminLayout.svelte +3 -0
  3. package/dist/components/CairnAdmin.svelte +8 -1
  4. package/dist/components/CairnAdmin.svelte.d.ts +2 -0
  5. package/dist/components/CairnMediaLibrary.svelte +929 -0
  6. package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
  7. package/dist/components/EditPage.svelte +347 -7
  8. package/dist/components/EditPage.svelte.d.ts +2 -0
  9. package/dist/components/MarkdownEditor.svelte +283 -1
  10. package/dist/components/MarkdownEditor.svelte.d.ts +37 -1
  11. package/dist/components/MediaCaptureCard.svelte +135 -0
  12. package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
  13. package/dist/components/MediaFigureControl.svelte +247 -0
  14. package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
  15. package/dist/components/MediaHeroField.svelte +569 -0
  16. package/dist/components/MediaHeroField.svelte.d.ts +67 -0
  17. package/dist/components/MediaInsertPopover.svelte +449 -0
  18. package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
  19. package/dist/components/MediaPicker.svelte +257 -0
  20. package/dist/components/MediaPicker.svelte.d.ts +41 -0
  21. package/dist/components/admin-icons.d.ts +12 -0
  22. package/dist/components/admin-icons.js +12 -0
  23. package/dist/components/cairn-admin.css +901 -9
  24. package/dist/components/client-ingest.d.ts +142 -0
  25. package/dist/components/client-ingest.js +297 -0
  26. package/dist/components/editor-media.d.ts +11 -0
  27. package/dist/components/editor-media.js +206 -0
  28. package/dist/components/editor-placeholder.d.ts +26 -0
  29. package/dist/components/editor-placeholder.js +166 -0
  30. package/dist/components/index.d.ts +1 -0
  31. package/dist/components/index.js +1 -0
  32. package/dist/components/markdown-directives.d.ts +12 -0
  33. package/dist/components/markdown-directives.js +42 -0
  34. package/dist/components/markdown-format.d.ts +89 -0
  35. package/dist/components/markdown-format.js +255 -0
  36. package/dist/components/media-upload-outcome.d.ts +52 -0
  37. package/dist/components/media-upload-outcome.js +48 -0
  38. package/dist/content/compose.js +3 -0
  39. package/dist/content/frontmatter.js +17 -0
  40. package/dist/content/manifest.d.ts +4 -0
  41. package/dist/content/manifest.js +41 -1
  42. package/dist/content/media-refs.d.ts +7 -0
  43. package/dist/content/media-refs.js +52 -0
  44. package/dist/content/schema.d.ts +5 -2
  45. package/dist/content/schema.js +17 -0
  46. package/dist/content/types.d.ts +62 -11
  47. package/dist/content/validate.js +27 -0
  48. package/dist/delivery/public-routes.d.ts +16 -0
  49. package/dist/delivery/public-routes.js +46 -3
  50. package/dist/delivery/seo-fields.js +7 -1
  51. package/dist/delivery/seo.d.ts +2 -0
  52. package/dist/delivery/seo.js +3 -0
  53. package/dist/doctor/checks-local.d.ts +1 -0
  54. package/dist/doctor/checks-local.js +21 -0
  55. package/dist/doctor/index.d.ts +3 -1
  56. package/dist/doctor/index.js +11 -2
  57. package/dist/doctor/types.d.ts +3 -0
  58. package/dist/doctor/wrangler-config.d.ts +3 -0
  59. package/dist/doctor/wrangler-config.js +20 -0
  60. package/dist/env.d.ts +19 -0
  61. package/dist/env.js +26 -0
  62. package/dist/index.d.ts +1 -1
  63. package/dist/log/events.d.ts +1 -1
  64. package/dist/media/config.d.ts +24 -0
  65. package/dist/media/config.js +69 -0
  66. package/dist/media/delivery-bucket.d.ts +34 -0
  67. package/dist/media/delivery-bucket.js +10 -0
  68. package/dist/media/index.d.ts +6 -0
  69. package/dist/media/index.js +13 -0
  70. package/dist/media/library-entry.d.ts +30 -0
  71. package/dist/media/library-entry.js +17 -0
  72. package/dist/media/manifest.d.ts +44 -0
  73. package/dist/media/manifest.js +105 -0
  74. package/dist/media/naming.d.ts +18 -0
  75. package/dist/media/naming.js +112 -0
  76. package/dist/media/reconcile.d.ts +36 -0
  77. package/dist/media/reconcile.js +45 -0
  78. package/dist/media/reference.d.ts +12 -0
  79. package/dist/media/reference.js +33 -0
  80. package/dist/media/sniff.d.ts +18 -0
  81. package/dist/media/sniff.js +106 -0
  82. package/dist/media/store.d.ts +25 -0
  83. package/dist/media/store.js +16 -0
  84. package/dist/media/transform-url.d.ts +26 -0
  85. package/dist/media/transform-url.js +38 -0
  86. package/dist/media/usage.d.ts +48 -0
  87. package/dist/media/usage.js +90 -0
  88. package/dist/render/pipeline.d.ts +2 -0
  89. package/dist/render/pipeline.js +13 -2
  90. package/dist/render/registry.js +3 -0
  91. package/dist/render/remark-figure.d.ts +4 -0
  92. package/dist/render/remark-figure.js +103 -0
  93. package/dist/render/resolve-media.d.ts +34 -0
  94. package/dist/render/resolve-media.js +78 -0
  95. package/dist/render/sanitize-schema.d.ts +4 -2
  96. package/dist/render/sanitize-schema.js +5 -3
  97. package/dist/sveltekit/admin-dispatch.d.ts +2 -0
  98. package/dist/sveltekit/admin-dispatch.js +5 -0
  99. package/dist/sveltekit/cairn-admin.d.ts +8 -1
  100. package/dist/sveltekit/cairn-admin.js +10 -2
  101. package/dist/sveltekit/content-routes.d.ts +68 -2
  102. package/dist/sveltekit/content-routes.js +461 -10
  103. package/dist/sveltekit/csrf.d.ts +16 -0
  104. package/dist/sveltekit/csrf.js +18 -0
  105. package/dist/sveltekit/guard.js +10 -3
  106. package/dist/sveltekit/index.d.ts +2 -1
  107. package/dist/sveltekit/index.js +1 -0
  108. package/dist/sveltekit/media-route.d.ts +12 -0
  109. package/dist/sveltekit/media-route.js +137 -0
  110. package/dist/vite/index.d.ts +3 -0
  111. package/dist/vite/index.js +7 -2
  112. package/package.json +7 -1
  113. package/src/lib/components/AdminLayout.svelte +3 -0
  114. package/src/lib/components/CairnAdmin.svelte +8 -1
  115. package/src/lib/components/CairnMediaLibrary.svelte +929 -0
  116. package/src/lib/components/EditPage.svelte +347 -7
  117. package/src/lib/components/MarkdownEditor.svelte +283 -1
  118. package/src/lib/components/MediaCaptureCard.svelte +135 -0
  119. package/src/lib/components/MediaFigureControl.svelte +247 -0
  120. package/src/lib/components/MediaHeroField.svelte +569 -0
  121. package/src/lib/components/MediaInsertPopover.svelte +449 -0
  122. package/src/lib/components/MediaPicker.svelte +257 -0
  123. package/src/lib/components/admin-icons.ts +12 -0
  124. package/src/lib/components/cairn-admin.css +37 -0
  125. package/src/lib/components/client-ingest.ts +380 -0
  126. package/src/lib/components/editor-media.ts +248 -0
  127. package/src/lib/components/editor-placeholder.ts +213 -0
  128. package/src/lib/components/index.ts +1 -0
  129. package/src/lib/components/markdown-directives.ts +46 -0
  130. package/src/lib/components/markdown-format.ts +307 -1
  131. package/src/lib/components/media-upload-outcome.ts +83 -0
  132. package/src/lib/content/compose.ts +3 -0
  133. package/src/lib/content/frontmatter.ts +16 -1
  134. package/src/lib/content/manifest.ts +44 -1
  135. package/src/lib/content/media-refs.ts +58 -0
  136. package/src/lib/content/schema.ts +31 -7
  137. package/src/lib/content/types.ts +78 -13
  138. package/src/lib/content/validate.ts +26 -1
  139. package/src/lib/delivery/public-routes.ts +52 -3
  140. package/src/lib/delivery/seo-fields.ts +6 -1
  141. package/src/lib/delivery/seo.ts +5 -0
  142. package/src/lib/doctor/checks-local.ts +22 -0
  143. package/src/lib/doctor/index.ts +21 -3
  144. package/src/lib/doctor/types.ts +3 -0
  145. package/src/lib/doctor/wrangler-config.ts +23 -0
  146. package/src/lib/env.ts +28 -0
  147. package/src/lib/index.ts +2 -0
  148. package/src/lib/log/events.ts +8 -1
  149. package/src/lib/media/config.ts +103 -0
  150. package/src/lib/media/delivery-bucket.ts +41 -0
  151. package/src/lib/media/index.ts +22 -0
  152. package/src/lib/media/library-entry.ts +58 -0
  153. package/src/lib/media/manifest.ts +122 -0
  154. package/src/lib/media/naming.ts +130 -0
  155. package/src/lib/media/reconcile.ts +79 -0
  156. package/src/lib/media/reference.ts +40 -0
  157. package/src/lib/media/sniff.ts +114 -0
  158. package/src/lib/media/store.ts +57 -0
  159. package/src/lib/media/transform-url.ts +58 -0
  160. package/src/lib/media/usage.ts +152 -0
  161. package/src/lib/render/pipeline.ts +17 -3
  162. package/src/lib/render/registry.ts +5 -0
  163. package/src/lib/render/remark-figure.ts +132 -0
  164. package/src/lib/render/resolve-media.ts +96 -0
  165. package/src/lib/render/sanitize-schema.ts +5 -3
  166. package/src/lib/sveltekit/admin-dispatch.ts +6 -1
  167. package/src/lib/sveltekit/cairn-admin.ts +13 -3
  168. package/src/lib/sveltekit/content-routes.ts +573 -12
  169. package/src/lib/sveltekit/csrf.ts +18 -0
  170. package/src/lib/sveltekit/guard.ts +12 -3
  171. package/src/lib/sveltekit/index.ts +6 -0
  172. package/src/lib/sveltekit/media-route.ts +158 -0
  173. package/src/lib/vite/index.ts +9 -2
@@ -46,6 +46,24 @@ export function issueCsrfToken(event: { url: URL; cookies: CookieJar }): string
46
46
  return token;
47
47
  }
48
48
 
49
+ /**
50
+ * Validate the double-submit token on a raw-body upload POST, reading the submitted token from the
51
+ * `X-Cairn-CSRF` request header rather than a form field. The upload's file bytes are the request
52
+ * body and are read once, so the form-field path (which clones the body to read `formData`) does not
53
+ * apply; the action carries the CSRF authority for uploads instead. Compares the header against the
54
+ * csrf cookie the loads issue, constant-time.
55
+ *
56
+ * Security rests on a custom request header being unsettable cross-origin without a CORS preflight:
57
+ * never add a permissive `Access-Control-Allow-Headers: x-cairn-csrf` (or an allow-origin) for
58
+ * `/admin` or `/media`, or this header witness collapses.
59
+ */
60
+ export function validateCsrfHeader(event: { url: URL; request: Request; cookies: CookieJar }): boolean {
61
+ const cookie = event.cookies.get(csrfCookieName(event.url.protocol === 'https:'));
62
+ if (!cookie) return false;
63
+ const submitted = event.request.headers.get('x-cairn-csrf') ?? '';
64
+ return tokensMatch(submitted, cookie);
65
+ }
66
+
49
67
  /** Validate the double-submit token on an admin form POST, reading the field from a body clone. */
50
68
  export async function validateCsrfToken(event: RequestContext): Promise<boolean> {
51
69
  const cookie = event.cookies.get(csrfCookieName(event.url.protocol === 'https:'));
@@ -4,7 +4,7 @@
4
4
  import { redirect, error } from '@sveltejs/kit';
5
5
  import { resolveSession } from '../auth/store.js';
6
6
  import { sessionCookieName } from '../auth/crypto.js';
7
- import { isUnsafeFormRequest, originMatches, validateCsrfToken } from './csrf.js';
7
+ import { isUnsafeFormRequest, originMatches, validateCsrfToken, validateCsrfHeader } from './csrf.js';
8
8
  import { applySecurityHeaders } from './admin-response.js';
9
9
  import { renderConditionResponse, REASON_CONDITION } from './condition-response.js';
10
10
  import { log } from '../log/index.js';
@@ -75,8 +75,17 @@ export function createAuthGuard() {
75
75
  }
76
76
 
77
77
  // Rule 1 - admin: every unsafe form POST carries a valid double-submit token, else the branded
78
- // 403 before resolve() runs. This covers the public login/auth posts too.
79
- if (isUnsafeFormRequest(event.request) && !(await validateCsrfToken(event))) {
78
+ // 403 before resolve() runs. This covers the public login/auth posts too. The header witness is
79
+ // tried first: a valid X-Cairn-CSRF header clears the request without cloning the body, which is
80
+ // how the raw-body media upload (a text/plain POST) passes CSRF. A custom header cannot be set
81
+ // cross-origin without a CORS preflight, so it is as strong a token witness as the form field.
82
+ // Only with no valid header does the form-field path run and clone the body to read the token,
83
+ // the unchanged path for every ordinary admin form post.
84
+ if (
85
+ isUnsafeFormRequest(event.request) &&
86
+ !validateCsrfHeader(event) &&
87
+ !(await validateCsrfToken(event))
88
+ ) {
80
89
  log.warn('guard.rejected', { reason: 'csrf', path: pathname });
81
90
  return renderConditionResponse('auth.csrf-token-invalid');
82
91
  }
@@ -4,18 +4,24 @@ export { createAuthGuard, requireSession, requireOwner } from './guard.js';
4
4
  export { createAuthRoutes, type AuthRoutesConfig, type RequestResult } from './auth-routes.js';
5
5
  export { createEditorRoutes } from './editors-routes.js';
6
6
  export { createContentRoutes } from './content-routes.js';
7
+ export { createMediaRoute } from './media-route.js';
7
8
  export type {
8
9
  NavConcept,
9
10
  LayoutData,
10
11
  EntrySummary,
11
12
  ListData,
12
13
  EditData,
14
+ MediaUsageInfo,
15
+ MediaLibraryData,
13
16
  ContentEvent,
14
17
  ContentRoutesDeps,
15
18
  SaveFailure,
16
19
  DeleteRefusal,
17
20
  RenameFailure,
21
+ MediaDeleteRefusal,
22
+ MediaUpdateFailure,
18
23
  ContentFormFailure,
24
+ UploadResult,
19
25
  } from './content-routes.js';
20
26
  export { createNavRoutes } from './nav-routes.js';
21
27
  export type { NavLoadData, NavPageOption, NavRoutesDeps } from './nav-routes.js';
@@ -0,0 +1,158 @@
1
+ // The media delivery route: an engine-provided SvelteKit RequestHandler a site mounts at
2
+ // `/media/[...path]`. It streams content-addressed bytes from R2 with the security headers that are
3
+ // the load-bearing XSS control for served media. The route sits outside `/admin`, so the admin
4
+ // security headers never run on it; it owns its own.
5
+ //
6
+ // It lives on the `/sveltekit` barrel, not the node-safe `/media` subpath, because it reads
7
+ // `platform.env`, which pulls `@sveltejs/kit` into its graph. Its public signature names only kit
8
+ // (a peer dependency) and web globals, never an `@cloudflare/workers-types` type (decision 5).
9
+ import type { RequestHandler } from '@sveltejs/kit';
10
+ import { requireBucket } from '../env.js';
11
+ import { CairnError } from '../diagnostics/index.js';
12
+ import { r2Key } from '../media/naming.js';
13
+ import { log } from '../log/index.js';
14
+ import type { DeliveryObject, DeliveryObjectBody } from '../media/delivery-bucket.js';
15
+ import type { ResolvedAssetConfig } from '../media/config.js';
16
+
17
+ /** A 16-character lowercase hex content-hash prefix, validated before any R2 lookup. */
18
+ const HASH_RE = /^[0-9a-f]{16}$/;
19
+
20
+ /** The closed delivery extension allow-list. A filename ext outside this set is a 404 with no R2
21
+ * read, so the route can never serve a type it cannot vouch for. */
22
+ const DELIVERY_EXTS: ReadonlySet<string> = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif']);
23
+
24
+ /** The load-bearing XSS control: set on every non-404 response, so a served object can never run as
25
+ * active content. `Content-Type` comes from the stored, server-validated metadata via
26
+ * `writeHttpMetadata`; these override or add to it. */
27
+ function applySecurityHeaders(headers: Headers, etag: string): void {
28
+ headers.set('X-Content-Type-Options', 'nosniff');
29
+ headers.set('Content-Disposition', 'inline');
30
+ headers.set('Content-Security-Policy', "default-src 'none'; sandbox");
31
+ headers.set('Cache-Control', 'public, max-age=31536000, immutable');
32
+ headers.set('ETag', etag);
33
+ // An object stored outside the upload pipeline (a manual put, a future import) may carry no
34
+ // content type, so writeHttpMetadata would set none. Pair `nosniff` with an explicit safe
35
+ // default rather than serving a typeless response.
36
+ if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/octet-stream');
37
+ }
38
+
39
+ /** True when the returned object carries a body (a full or ranged read), narrowing it to the body
40
+ * variant. R2 returns a body-less object on an `If-None-Match` hit. */
41
+ function hasBody(obj: DeliveryObject | DeliveryObjectBody): obj is DeliveryObjectBody {
42
+ return 'body' in obj && (obj as DeliveryObjectBody).body != null;
43
+ }
44
+
45
+ /**
46
+ * Build the media delivery `RequestHandler` for a site's resolved media config.
47
+ *
48
+ * The handler validates the hash and extension before any R2 call, derives the object key from the
49
+ * validated values only (never trusting the URL's fan-out), guards the Cloudflare Images self-loop,
50
+ * and sets the security headers on every served response.
51
+ *
52
+ * @param resolved the adapter's resolved media config; when media is off the handler always 404s.
53
+ */
54
+ export function createMediaRoute(resolved: ResolvedAssetConfig): RequestHandler {
55
+ return async (event) => {
56
+ // Media off: the route is mounted but serves nothing.
57
+ if (!resolved.enabled) return new Response(null, { status: 404 });
58
+
59
+ // The catch-all param is conventionally `path` (route `/media/[...path]`). Decode each segment
60
+ // on its own and reject a traversal or an embedded slash, so no undecoded or `..` path reaches
61
+ // R2. params.path is `string | undefined` (kit's fallback ambient types), so guard the absence.
62
+ const raw = event.params.path;
63
+ if (typeof raw !== 'string' || raw === '') return new Response(null, { status: 404 });
64
+ const segments: string[] = [];
65
+ for (const part of raw.split('/')) {
66
+ let decoded: string;
67
+ try {
68
+ decoded = decodeURIComponent(part);
69
+ } catch {
70
+ return new Response(null, { status: 404 });
71
+ }
72
+ if (decoded === '' || decoded.includes('/') || decoded === '..') {
73
+ return new Response(null, { status: 404 });
74
+ }
75
+ segments.push(decoded);
76
+ }
77
+
78
+ // The filename is the last segment; its dot fields end with `<hash>.<ext>`. The slug, if any, is
79
+ // attacker-controlled and ignored. parseMediaToken does not run here.
80
+ const filename = segments[segments.length - 1];
81
+ const fields = filename.split('.');
82
+ if (fields.length < 2) return new Response(null, { status: 404 });
83
+ const ext = fields[fields.length - 1].toLowerCase();
84
+ const hash = fields[fields.length - 2];
85
+
86
+ // Validate before any R2 call: a bad hash or ext is a 404 with no read.
87
+ if (!HASH_RE.test(hash) || !DELIVERY_EXTS.has(ext)) {
88
+ return new Response(null, { status: 404 });
89
+ }
90
+
91
+ // Derive the key from the validated values only; r2Key recomputes the fan-out from the hash.
92
+ const key = r2Key(hash, ext);
93
+
94
+ // Resolve the bucket. A missing binding is a drained 503 with a log, never a thrown 500.
95
+ let bucket;
96
+ try {
97
+ // `event.platform` is `App.Platform`, which the engine does not declare (a site does, with an
98
+ // `env`), so read it through a structural cast rather than naming the site's ambient type.
99
+ const platform = event.platform as { env?: Record<string, unknown> } | undefined;
100
+ bucket = requireBucket(platform?.env ?? {}, resolved.bucketBinding);
101
+ } catch (err) {
102
+ if (err instanceof CairnError && err.conditionId === 'config.bindings-missing') {
103
+ log.warn('media.delivery_failed', {
104
+ reason: 'binding-missing',
105
+ binding: resolved.bucketBinding,
106
+ });
107
+ return new Response(null, { status: 503 });
108
+ }
109
+ throw err;
110
+ }
111
+
112
+ // Self-loop guard: the Cloudflare Images origin subrequest carries `Via: image-resizing`. Serve
113
+ // it a clean full-body 200 with no conditional or range handling, so a transform cannot loop.
114
+ const via = event.request.headers.get('Via') ?? '';
115
+ const isImageResizing = via.includes('image-resizing');
116
+
117
+ // Only forward `range` when the request actually carried a `Range` header. R2 populates the
118
+ // returned object's `.range` whenever a `range` option is passed (even a header-less one), so
119
+ // passing it unconditionally would turn every full GET into a 206.
120
+ const hasRangeRequest = !isImageResizing && event.request.headers.has('Range');
121
+ const getOpts = isImageResizing
122
+ ? undefined
123
+ : {
124
+ onlyIf: event.request.headers,
125
+ ...(hasRangeRequest ? { range: event.request.headers } : {}),
126
+ };
127
+ const obj = await bucket.get(key, getOpts);
128
+
129
+ if (obj === null) return new Response(null, { status: 404 });
130
+
131
+ // A body-less object is R2's `If-None-Match` hit: 304, no body. Skipped for the self-loop path,
132
+ // which always requested the full body.
133
+ if (!isImageResizing && !hasBody(obj)) {
134
+ const headers = new Headers();
135
+ obj.writeHttpMetadata(headers);
136
+ applySecurityHeaders(headers, obj.httpEtag);
137
+ return new Response(null, { status: 304, headers });
138
+ }
139
+
140
+ const headers = new Headers();
141
+ obj.writeHttpMetadata(headers);
142
+ applySecurityHeaders(headers, obj.httpEtag);
143
+
144
+ // A ranged read carries `obj.range`: respond 206 with a Content-Range. R2 fills the served
145
+ // window; derive the bounds defensively against the full size.
146
+ if (hasRangeRequest && obj.range) {
147
+ const start = obj.range.offset ?? 0;
148
+ const length = obj.range.length ?? obj.size - start;
149
+ const end = start + length - 1;
150
+ headers.set('Content-Range', `bytes ${start}-${end}/${obj.size}`);
151
+ const body = hasBody(obj) ? obj.body : null;
152
+ return new Response(body, { status: 206, headers });
153
+ }
154
+
155
+ const body = hasBody(obj) ? obj.body : null;
156
+ return new Response(body, { status: 200, headers });
157
+ };
158
+ }
@@ -220,20 +220,26 @@ export interface AdapterFacts {
220
220
  repo?: string;
221
221
  /** `cairn.sender.from`. */
222
222
  from?: string;
223
+ /** `cairn.assets.bucketBinding`, the media R2 binding name; undefined when the adapter declares no
224
+ * assets. The doctor's conditional media-bucket check reads it. */
225
+ mediaBucketBinding?: string;
223
226
  }
224
227
 
225
228
  /** Build the virtual module that reads only the adapter facts the doctor derives. It imports the
226
- * configured config module and exports the string-typed `owner`, `repo`, and `from` as JSON, so
227
- * nothing else of the adapter (least of all a secret) crosses the boundary. */
229
+ * configured config module and exports the string-typed `owner`, `repo`, `from`, and the media
230
+ * `bucketBinding` as JSON, so nothing else of the adapter (least of all a secret) crosses the
231
+ * boundary. */
228
232
  function adapterFactsSource(opts: CairnManifestOptions): string {
229
233
  return `
230
234
  import { cairn } from ${JSON.stringify(opts.configModule)};
231
235
  const backend = cairn?.backend ?? {};
232
236
  const sender = cairn?.sender ?? {};
237
+ const assets = cairn?.assets ?? {};
233
238
  const facts = {};
234
239
  if (typeof backend.owner === 'string') facts.owner = backend.owner;
235
240
  if (typeof backend.repo === 'string') facts.repo = backend.repo;
236
241
  if (typeof sender.from === 'string') facts.from = sender.from;
242
+ if (typeof assets.bucketBinding === 'string') facts.mediaBucketBinding = assets.bucketBinding;
237
243
  export const result = JSON.stringify(facts);
238
244
  `;
239
245
  }
@@ -264,6 +270,7 @@ export async function readAdapterFacts(cwd: string = process.cwd()): Promise<Ada
264
270
  if (typeof parsed.owner === 'string') facts.owner = parsed.owner;
265
271
  if (typeof parsed.repo === 'string') facts.repo = parsed.repo;
266
272
  if (typeof parsed.from === 'string') facts.from = parsed.from;
273
+ if (typeof parsed.mediaBucketBinding === 'string') facts.mediaBucketBinding = parsed.mediaBucketBinding;
267
274
  return facts;
268
275
  } catch {
269
276
  return null;