@stelstone/server 0.26.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.
@@ -0,0 +1,401 @@
1
+ /**
2
+ * Shared helpers used across content and template adapters.
3
+ */
4
+
5
+ /**
6
+ * Strips all characters outside [a-zA-Z0-9._-].
7
+ * Used to sanitize path segments coming from config (collection names, etc.).
8
+ */
9
+ export function sanitize(p) {
10
+ return String(p).replace(/[^a-zA-Z0-9._-]/g, "");
11
+ }
12
+
13
+ /**
14
+ * Validates a filename (e.g. "en-hello.json") from a route param.
15
+ * Returns the input unchanged if it is safe, or null if it should be rejected.
16
+ *
17
+ * Rules:
18
+ * - Must match /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/
19
+ * - Must not be "." or ".." or consist entirely of dots
20
+ */
21
+ export function safeFileName(name) {
22
+ if (typeof name !== "string") return null;
23
+ if (name === "." || name === ".." || /^\.+$/.test(name)) return null;
24
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name)) return null;
25
+ return name;
26
+ }
27
+
28
+ /**
29
+ * True when a filename is a content entry (and not a generated manifest such
30
+ * as `_index.json`). Manifest/index files are list projections, not entries,
31
+ * so every directory-scan that enumerates entries must exclude them.
32
+ *
33
+ * @param {string} file
34
+ * @returns {boolean}
35
+ */
36
+ export function isEntryFile(file) {
37
+ return typeof file === "string" && file.endsWith(".json") && !file.startsWith("_");
38
+ }
39
+
40
+ /**
41
+ * Sorts a pages array in-place and returns it.
42
+ *
43
+ * When sortConfig is provided, sorts by `data.meta?.[sortConfig.field]`.
44
+ * Missing values are always placed at the end, regardless of direction.
45
+ * When sortConfig is null/undefined, sorts by title (localeCompare).
46
+ *
47
+ * @param {Array} pages Each element must have `.meta` and `.title`.
48
+ * @param {Object|null} sortConfig { field: string, direction: "asc"|"desc" }
49
+ */
50
+ export function sortPages(pages, sortConfig) {
51
+ if (sortConfig) {
52
+ const dir = sortConfig.direction === "desc" ? -1 : 1;
53
+ pages.sort((a, b) => {
54
+ const av = a[sortConfig.field] ?? a.meta?.[sortConfig.field];
55
+ const bv = b[sortConfig.field] ?? b.meta?.[sortConfig.field];
56
+ const aMissing = av === undefined || av === null;
57
+ const bMissing = bv === undefined || bv === null;
58
+ if (aMissing && bMissing) return 0;
59
+ if (aMissing) return 1; // missing always last
60
+ if (bMissing) return -1; // missing always last
61
+ if (av < bv) return -1 * dir;
62
+ if (av > bv) return 1 * dir;
63
+ return 0;
64
+ });
65
+ } else {
66
+ pages.sort((a, b) => a.title.localeCompare(b.title));
67
+ }
68
+ return pages;
69
+ }
70
+
71
+ /**
72
+ * Recursively regenerates block IDs.
73
+ * Children columns are arrays of block arrays (columns), so we recurse into each.
74
+ */
75
+ export function reid(blocks) {
76
+ if (!Array.isArray(blocks)) return blocks;
77
+ return blocks.map((b) => {
78
+ const next = { ...b, id: "b-" + Math.random().toString(36).slice(2, 9) };
79
+ if (Array.isArray(b.children)) {
80
+ next.children = b.children.map((col) => reid(col));
81
+ }
82
+ return next;
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Builds a duplicate page payload from a source page object.
88
+ * Returns { data, fileName }.
89
+ */
90
+ export function buildDuplicateData(src) {
91
+ const lang = src.lang || "en";
92
+ const baseSlug = (src.slug || "page").replace(/-copy(-\d+)?$/, "");
93
+ const newSlug = `${baseSlug}-copy-${Math.floor(Date.now() / 1000)}`;
94
+ const data = {
95
+ ...src,
96
+ id: `${lang}/${newSlug}`,
97
+ slug: newSlug,
98
+ meta: { ...(src.meta || {}), title: `${src.meta?.title || newSlug} (Copy)` },
99
+ blocks: reid(src.blocks || []),
100
+ };
101
+ const fileName = `${sanitize(lang)}-${sanitize(newSlug)}.json`;
102
+ return { data, fileName };
103
+ }
104
+
105
+ /**
106
+ * Scans all collections in `adapter` and returns entries whose
107
+ * `meta.publishAt` is in the past.
108
+ *
109
+ * @param {import('./types.mjs').ContentAdapter} adapter
110
+ * @returns {Promise<Array<{collection: string, file: string, data: object}>>}
111
+ */
112
+ export async function listScheduledDue(adapter) {
113
+ const collections = await adapter.listCollections();
114
+ const now = new Date();
115
+ const due = [];
116
+ for (const { name } of collections) {
117
+ const pages = await adapter.listPages(name);
118
+ for (const page of pages || []) {
119
+ if (page.meta?.publishAt && new Date(page.meta.publishAt) <= now) {
120
+ const data = await adapter.readPage(name, page.file);
121
+ if (data?.meta?.publishAt) due.push({ collection: name, file: page.file, data });
122
+ }
123
+ }
124
+ }
125
+ return due;
126
+ }
127
+
128
+ /** Top-level keys read directly from the page object; all others come from page.meta. */
129
+ const TOP_LEVEL_KEYS = new Set(["id", "slug", "lang", "collection", "title", "file"]);
130
+
131
+ /**
132
+ * Resolves a field value from a page object using the admin-UI field resolution rule.
133
+ * Top-level keys (id, slug, lang, collection, title, file) are read from the page directly;
134
+ * every other key is read from page.meta.
135
+ *
136
+ * @param {object} page
137
+ * @param {string} key
138
+ * @returns {string}
139
+ */
140
+ export function pageFieldValue(page, key) {
141
+ if (TOP_LEVEL_KEYS.has(key)) return page[key] ?? "";
142
+ return page.meta?.[key] ?? "";
143
+ }
144
+
145
+ /**
146
+ * Meta keys the server and admin UI always need on a list entry regardless of
147
+ * a collection's `listFields`, because core workflow logic reads them:
148
+ * - `publishAt` — scheduled-publish detection (listScheduledDue) + status badge
149
+ * - `draft` — publish/draft/scheduled status badge
150
+ * These must never be dropped from the list index.
151
+ */
152
+ export const SYSTEM_LIST_META_KEYS = ["draft", "publishAt"];
153
+
154
+ /**
155
+ * Computes the set of `meta` keys that a list index must store for a
156
+ * collection. This is the union of the keys the list view actually consumes —
157
+ * `listFields`, `filters`, and sort keys — plus the always-required
158
+ * SYSTEM_LIST_META_KEYS. Top-level keys (id/slug/lang/collection/title/file)
159
+ * are excluded because they are stored at the top level of each entry, not
160
+ * under `meta`.
161
+ *
162
+ * Driving the index off config (instead of dumping the whole `meta` object)
163
+ * keeps manifests small and guarantees that every searchable/filterable/
164
+ * sortable field is present — so the admin list never shows a configured
165
+ * column or filter as silently empty.
166
+ *
167
+ * @param {object} [colConfig] A single collection's config object.
168
+ * @returns {string[]} Meta keys to project into the list entry.
169
+ */
170
+ export function listMetaKeys(colConfig) {
171
+ const keys = new Set(SYSTEM_LIST_META_KEYS);
172
+ const addFields = (arr) => {
173
+ for (const f of arr || []) {
174
+ if (f && f.key) keys.add(f.key);
175
+ }
176
+ };
177
+ addFields(colConfig?.listFields);
178
+ addFields(colConfig?.filters);
179
+ // Relation fields exist to be queried — "which entries point at this one" is
180
+ // the whole reason to declare one. Leaving them out of the manifest meant a
181
+ // reverse lookup had to open every entry body instead of reading the index.
182
+ for (const key of Object.keys(relationFields(colConfig))) keys.add(key);
183
+
184
+ // `sort` appears in two shapes in the wild. The canonical one — and the only
185
+ // one the API actually sorts by (see routes.mjs) — is a spec:
186
+ // `{ field, direction }`. An array of `{ key }` also exists. This function
187
+ // only needs the key names, so it accepts both; iterating the object form as
188
+ // an array used to throw and take `stelstone build-index` down with it.
189
+ if (Array.isArray(colConfig?.sort)) {
190
+ addFields(colConfig.sort);
191
+ } else {
192
+ const sortKey = colConfig?.sort?.field ?? colConfig?.sort?.key;
193
+ if (sortKey) keys.add(sortKey);
194
+ }
195
+ if (colConfig?.defaultSort?.key) keys.add(colConfig.defaultSort.key);
196
+ for (const k of TOP_LEVEL_KEYS) keys.delete(k);
197
+ return [...keys];
198
+ }
199
+
200
+ /**
201
+ * Builds the list-index record for a single page entry. Top-level
202
+ * id/slug/lang/collection/title/file are always stored; `meta` is projected
203
+ * down to only the keys returned by {@link listMetaKeys} for the collection.
204
+ *
205
+ * This is the single source of truth for list-entry shape, shared by every
206
+ * code path that produces one (fs listing, GitHub index read/write/bootstrap,
207
+ * and the `stelstone build-index` CLI) so they cannot diverge.
208
+ *
209
+ * Relation fields (a metaField with a `collection` target, e.g. a `combobox`)
210
+ * store a slug in the source data. When a `lookups` table for the target
211
+ * collection is supplied, the stored value is resolved to the referenced
212
+ * entry's display name so the admin list shows names instead of raw slugs.
213
+ * Resolution is best-effort: an unknown slug, or a missing lookup table, keeps
214
+ * the original slug. Producers that cannot cheaply load other collections
215
+ * (e.g. the GitHub edge write-path) simply omit `lookups` and store slugs.
216
+ *
217
+ * @param {object} [colConfig] The collection's config (for listFields etc.).
218
+ * @param {string} collection Collection name (fallback for entry.collection).
219
+ * @param {string} file Source file name.
220
+ * @param {object} data Full page object (has id/slug/lang/meta/...).
221
+ * @param {Object<string, Record<string,string>|Map<string,string>>} [lookups]
222
+ * Per-collection slug→displayName tables, keyed by target collection name.
223
+ * @returns {object} The projected list entry.
224
+ */
225
+ export function buildListEntry(colConfig, collection, file, data, lookups = {}) {
226
+ const fullMeta = data.meta || {};
227
+ const meta = {};
228
+ for (const key of listMetaKeys(colConfig)) {
229
+ if (fullMeta[key] !== undefined) meta[key] = fullMeta[key];
230
+ }
231
+ resolveRelationLabels(meta, colConfig, lookups);
232
+ return {
233
+ id: data.id,
234
+ slug: data.slug,
235
+ lang: data.lang,
236
+ collection: data.collection ?? collection,
237
+ title: fullMeta.title || fullMeta.name || file,
238
+ file,
239
+ meta,
240
+ };
241
+ }
242
+
243
+ /** Separates a collection name from a slug in a qualified relation value. */
244
+ export const RELATION_SEPARATOR = ":";
245
+
246
+ /**
247
+ * Splits `"blog:en/my-post"` into its parts. Slugs may contain `/` but never
248
+ * `:`, so the first separator is the boundary. Returns null for an unqualified
249
+ * value.
250
+ */
251
+ export function parseRelationValue(value) {
252
+ if (typeof value !== "string") return null;
253
+ const at = value.indexOf(RELATION_SEPARATOR);
254
+ if (at <= 0) return null;
255
+ return { collection: value.slice(0, at), slug: value.slice(at + 1) };
256
+ }
257
+
258
+ /**
259
+ * Maps each relation field key of a collection to its target collections and
260
+ * cardinality. A metaField is a relation when it declares a `collection`
261
+ * target: `type: "combobox", collection: "authors"`, or several targets,
262
+ * `collection: ["reference", "blog"]`.
263
+ *
264
+ * With more than one target a bare slug no longer identifies an entry — the
265
+ * same slug can exist in two collections — so those values are stored
266
+ * qualified as `"collection:slug"`. Single-target fields keep storing a bare
267
+ * slug, so existing content and configs are unaffected.
268
+ *
269
+ * @param {object} [colConfig]
270
+ * @returns {Record<string, { collections: string[], multiple: boolean, qualified: boolean }>}
271
+ */
272
+ export function relationFields(colConfig) {
273
+ const map = {};
274
+ for (const f of colConfig?.metaFields || []) {
275
+ if (!f || !f.key) continue;
276
+ const targets = typeof f.collection === "string"
277
+ ? [f.collection]
278
+ : Array.isArray(f.collection)
279
+ ? f.collection.filter((c) => typeof c === "string" && c)
280
+ : null;
281
+ if (!targets || targets.length === 0) continue;
282
+ map[f.key] = {
283
+ collections: targets,
284
+ multiple: !!f.multiple,
285
+ qualified: targets.length > 1,
286
+ };
287
+ }
288
+ return map;
289
+ }
290
+
291
+ /**
292
+ * In-place resolution of relation slugs in a projected `meta` object to the
293
+ * referenced entries' display names, using the supplied `lookups` tables.
294
+ * Handles both single values and arrays (multiple-select). Best-effort: leaves
295
+ * the slug untouched when no resolution is available.
296
+ *
297
+ * @param {object} meta The projected meta object (mutated).
298
+ * @param {object} [colConfig]
299
+ * @param {object} [lookups] Per-collection slug→displayName tables.
300
+ */
301
+ export function resolveRelationLabels(meta, colConfig, lookups = {}) {
302
+ const relations = relationFields(colConfig);
303
+ for (const key of Object.keys(meta)) {
304
+ const rel = relations[key];
305
+ if (!rel) continue;
306
+
307
+ const lookIn = (target, slug) => {
308
+ const table = lookups[target];
309
+ if (!table) return null;
310
+ const name = table instanceof Map ? table.get(slug) : table[slug];
311
+ return name != null && name !== "" ? name : null;
312
+ };
313
+
314
+ const resolveOne = (raw) => {
315
+ // A qualified value names its own collection; an unqualified one belongs
316
+ // to the field's single target.
317
+ const parsed = rel.qualified ? parseRelationValue(raw) : null;
318
+ if (parsed) return lookIn(parsed.collection, parsed.slug) ?? raw;
319
+ return lookIn(rel.collections[0], raw) ?? raw;
320
+ };
321
+
322
+ const value = meta[key];
323
+ meta[key] = Array.isArray(value) ? value.map(resolveOne) : resolveOne(value);
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Applies pagination, search, and filtering to a pre-sorted pages array.
329
+ * Does not mutate the input array.
330
+ *
331
+ * @param {object[]} pages Pre-sorted array of page objects.
332
+ * @param {object} opts
333
+ * @param {number} [opts.page=1] Current page (1-based).
334
+ * @param {number|"all"} [opts.perPage=20] Items per page, or "all" / 0 for no pagination.
335
+ * @param {string} [opts.search=""] Substring search term.
336
+ * @param {string[]} [opts.searchFields=[]] Keys to search across.
337
+ * @param {string[]} [opts.filterKeys=[]] Keys for which to compute facet values.
338
+ * @param {object} [opts.filters={}] Active filters: { key: exactValue }.
339
+ * @returns {{ items: object[], total: number, facets: Record<string, string[]> }}
340
+ */
341
+ export function queryPages(pages, {
342
+ page = 1,
343
+ perPage = 20,
344
+ search = "",
345
+ searchFields = [],
346
+ filterKeys = [],
347
+ filters = {},
348
+ } = {}) {
349
+ // 1. Facets — computed over the full pages array
350
+ /** @type {Record<string, string[]>} */
351
+ const facets = {};
352
+ for (const key of filterKeys) {
353
+ const seen = new Set();
354
+ for (const p of pages) {
355
+ const v = String(pageFieldValue(p, key));
356
+ if (v !== "") seen.add(v);
357
+ }
358
+ facets[key] = [...seen].sort();
359
+ }
360
+
361
+ // 2. Filter
362
+ const activeFilters = Object.entries(filters).filter(([, v]) => v);
363
+ const filtered = activeFilters.length === 0
364
+ ? pages
365
+ : pages.filter((p) =>
366
+ activeFilters.every(([k, v]) => String(pageFieldValue(p, k)) === v)
367
+ );
368
+
369
+ // 3. Search
370
+ const term = search ? search.toLowerCase() : "";
371
+ const searched = term
372
+ ? filtered.filter((p) =>
373
+ searchFields.some((k) =>
374
+ String(pageFieldValue(p, k)).toLowerCase().includes(term)
375
+ )
376
+ )
377
+ : filtered;
378
+
379
+ // 4. Total
380
+ const total = searched.length;
381
+
382
+ // 5. Paginate
383
+ const perPageNum = perPage === "all" ? 0 : Number(perPage);
384
+ const items =
385
+ perPage === "all" || perPageNum === 0
386
+ ? searched.slice()
387
+ : searched.slice((page - 1) * perPageNum, page * perPageNum);
388
+
389
+ return { items, total, facets };
390
+ }
391
+
392
+ /**
393
+ * Produces a commit message string.
394
+ *
395
+ * @param {((ts: string) => string) | undefined} commitMessage
396
+ * @param {string} [fallback="Content updated"]
397
+ */
398
+ export function commitMsg(commitMessage, fallback = "Content updated") {
399
+ const ts = new Date().toISOString().replace("T", " ").slice(0, 19);
400
+ return commitMessage ? commitMessage(ts) : `${fallback} ${ts}`;
401
+ }
@@ -0,0 +1,102 @@
1
+ import crypto from "crypto";
2
+
3
+ /**
4
+ * HTTP Basic auth + HMAC-SHA256 JWT for media tokens.
5
+ *
6
+ * @param {Object} opts
7
+ * @param {string} opts.user
8
+ * @param {string|undefined} opts.pass
9
+ * @param {string|undefined} opts.jwtSecret
10
+ * @param {number} opts.jwtTtl
11
+ * @param {string} [opts.realm="Admin"]
12
+ * @returns {import('./types.mjs').AuthAdapter}
13
+ */
14
+ function safeEq(a, b) {
15
+ const bufA = Buffer.from(a || "", "utf8");
16
+ const bufB = Buffer.from(b || "", "utf8");
17
+ if (bufA.length !== bufB.length) return false;
18
+ return crypto.timingSafeEqual(bufA, bufB);
19
+ }
20
+
21
+ export function createBasicAuth({
22
+ user,
23
+ pass,
24
+ users,
25
+ jwtSecret,
26
+ jwtTtl,
27
+ realm = "Admin",
28
+ }) {
29
+ // Normalise: prefer `users` array, fall back to single user/pass pair.
30
+ // Each entry may use `pass` (plaintext) or `passEnv` (env var name).
31
+ // Passwords arrive already resolved (see core/adapter-options.mjs). An
32
+ // adapter that reads process.env itself cannot run in a Worker, which is
33
+ // what forced the Worker to re-resolve this list by hand.
34
+ const userList = (users?.length
35
+ ? users
36
+ : pass ? [{ user: user || "admin", pass, role: "admin" }] : []
37
+ )
38
+ // A user whose password resolves to empty/undefined must be DISABLED,
39
+ // not matchable — otherwise an empty submitted password would log in.
40
+ // Reporting is left to config validation (core/config-schema.mjs), which
41
+ // names the config path and the environment variable; warning here too
42
+ // just prints the same problem twice.
43
+ .filter((entry) => Boolean(entry.pass));
44
+
45
+ const configured = userList.length > 0;
46
+
47
+ function findUser(u, p) {
48
+ return userList.find(
49
+ (entry) => safeEq(u, entry.user) && safeEq(p, entry.pass),
50
+ ) ?? null;
51
+ }
52
+
53
+ function issueMediaToken(tenantId) {
54
+ if (!jwtSecret) throw new Error("JWT_SECRET not set");
55
+ const header = Buffer.from(
56
+ JSON.stringify({ alg: "HS256", typ: "JWT" }),
57
+ ).toString("base64url");
58
+ const payload = Buffer.from(
59
+ JSON.stringify({
60
+ sub: user,
61
+ tenant_id: tenantId,
62
+ iat: Math.floor(Date.now() / 1000),
63
+ exp: Math.floor(Date.now() / 1000) + jwtTtl,
64
+ }),
65
+ ).toString("base64url");
66
+ const sig = crypto
67
+ .createHmac("sha256", jwtSecret)
68
+ .update(`${header}.${payload}`)
69
+ .digest("base64url");
70
+ return `${header}.${payload}.${sig}`;
71
+ }
72
+
73
+ /**
74
+ * Resolve the caller from the request.
75
+ *
76
+ * The CMS never sends WWW-Authenticate on failure: credentials were supplied
77
+ * and rejected, and the header would pop the browser's native Basic Auth
78
+ * dialog over the app's own error UI.
79
+ *
80
+ * @param {Request} request
81
+ * @returns {Promise<object|null>}
82
+ */
83
+ async function verify(request) {
84
+ const authHeader = request.headers.get("authorization");
85
+ if (!authHeader?.startsWith("Basic ")) return null;
86
+ const decoded = Buffer.from(authHeader.slice(6), "base64").toString();
87
+ const colon = decoded.indexOf(":");
88
+ const u = decoded.slice(0, colon);
89
+ const p = decoded.slice(colon + 1);
90
+ const entry = findUser(u, p);
91
+ if (!entry) return null;
92
+ return { login: entry.user, name: entry.name || entry.user, role: entry.role || "editor" };
93
+ }
94
+
95
+ return {
96
+ configured,
97
+ /** @param {string} capability */
98
+ supports: (capability) => capability === "mediaToken",
99
+ issueMediaToken,
100
+ verify,
101
+ };
102
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Netlify deploys API integration.
3
+ *
4
+ * @param {Object} opts
5
+ * @param {string|undefined} opts.token
6
+ * @param {string|undefined} opts.siteId
7
+ * @param {string} opts.defaultBranch
8
+ * @returns {import('./types.mjs').BuildAdapter}
9
+ */
10
+ export function createNetlifyBuild({ token, siteId, defaultBranch }) {
11
+ const configured = Boolean(token && siteId);
12
+
13
+ return {
14
+ configured,
15
+
16
+ async getDeployStatus({ branch, sha } = {}) {
17
+ if (!configured) return { configured: false };
18
+ const targetBranch = branch || defaultBranch;
19
+ const url = `https://api.netlify.com/api/v1/sites/${siteId}/deploys?per_page=20&branch=${encodeURIComponent(
20
+ targetBranch,
21
+ )}`;
22
+ const r = await fetch(url, {
23
+ headers: { Authorization: `Bearer ${token}` },
24
+ });
25
+ if (!r.ok) {
26
+ const err = new Error(`Netlify API ${r.status}`);
27
+ err.upstreamStatus = r.status;
28
+ throw err;
29
+ }
30
+ const deploys = await r.json();
31
+ let deploy = null;
32
+ if (sha) {
33
+ deploy = deploys.find(
34
+ (d) => d.commit_ref && d.commit_ref.startsWith(sha),
35
+ );
36
+ // SHA not found yet — Netlify hasn't picked up the commit, keep waiting
37
+ if (!deploy) return { configured: true, deploy: null };
38
+ }
39
+ if (!deploy) deploy = deploys[0] || null;
40
+ if (!deploy) return { configured: true, deploy: null };
41
+ return {
42
+ configured: true,
43
+ deploy: {
44
+ id: deploy.id,
45
+ state: deploy.state,
46
+ branch: deploy.branch,
47
+ commitRef: deploy.commit_ref,
48
+ deployUrl: deploy.deploy_ssl_url || deploy.deploy_url,
49
+ adminUrl: deploy.admin_url
50
+ ? `${deploy.admin_url}/deploys/${deploy.id}`
51
+ : null,
52
+ createdAt: deploy.created_at,
53
+ updatedAt: deploy.updated_at,
54
+ errorMessage: deploy.error_message || null,
55
+ title: deploy.title || null,
56
+ },
57
+ };
58
+ },
59
+ };
60
+ }
@@ -0,0 +1,79 @@
1
+ import { sanitize } from "./_shared.mjs";
2
+
3
+ /**
4
+ * CDN-backed media adapter that proxies requests to a remote media
5
+ * service expecting Bearer-token auth (e.g. the natilon media-cdn lambda).
6
+ *
7
+ * The admin server calls these methods server-side, so the browser never
8
+ * needs to talk to the CDN directly — eliminating CORS friction and
9
+ * keeping the media JWT off the client.
10
+ *
11
+ * Backend contract (mirrors the lambda):
12
+ * GET / → { folders: string[] }
13
+ * GET /{folder}/ → { items, page, pages, total }
14
+ * POST /_upload → { key, ... }
15
+ *
16
+ * @param {Object} opts
17
+ * @param {string} opts.baseUrl e.g. "https://media.natilon.com"
18
+ * @param {() => string} opts.getToken Returns a fresh JWT each call.
19
+ * @returns {{
20
+ * listFolders: () => Promise<object>,
21
+ * listFolder: (folder: string, q?: {page?: number, perPage?: number, search?: string}) => Promise<object>,
22
+ * upload: (payload: object) => Promise<object>,
23
+ * }}
24
+ */
25
+
26
+ export function createCdnProxyMedia({ baseUrl, getToken }) {
27
+ const ROOT = baseUrl.replace(/\/+$/, "");
28
+
29
+ async function call(path, opts = {}) {
30
+ const r = await fetch(ROOT + path, {
31
+ ...opts,
32
+ headers: {
33
+ Authorization: `Bearer ${getToken()}`,
34
+ ...(opts.body ? { "Content-Type": "application/json" } : {}),
35
+ ...(opts.headers || {}),
36
+ },
37
+ });
38
+ const text = await r.text();
39
+ let body;
40
+ try {
41
+ body = text ? JSON.parse(text) : {};
42
+ } catch {
43
+ const err = new Error(`Media backend returned non-JSON (${r.status})`);
44
+ err.upstreamStatus = r.status;
45
+ throw err;
46
+ }
47
+ if (!r.ok) {
48
+ const err = new Error(body?.error || `Media backend ${r.status}`);
49
+ err.upstreamStatus = r.status;
50
+ err.body = body;
51
+ throw err;
52
+ }
53
+ return body;
54
+ }
55
+
56
+ return {
57
+ listFolders() {
58
+ // `/_list/` matches CloudFront's `*/` behavior so it routes to the
59
+ // lambda; bare `/` would hit the S3 default behavior and 403.
60
+ return call("/_list/");
61
+ },
62
+
63
+ listFolder(folder, { page = 1, perPage = 30, search = "" } = {}) {
64
+ const params = new URLSearchParams({
65
+ page: String(page),
66
+ per_page: String(perPage),
67
+ });
68
+ if (search) params.set("search", search);
69
+ return call(`/${sanitize(folder)}/?${params.toString()}`);
70
+ },
71
+
72
+ upload(payload) {
73
+ return call("/_upload", {
74
+ method: "POST",
75
+ body: JSON.stringify(payload),
76
+ });
77
+ },
78
+ };
79
+ }