@avocadostudio-ai/orchestrator-core 0.3.3 → 0.4.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.
@@ -67,14 +67,33 @@ export type CmsPublishResult = void | {
67
67
  export interface CmsMediaItem {
68
68
  /** Stable id in the source store. Used as the picker's selection key. */
69
69
  id: string;
70
+ /**
71
+ * What this asset is.
72
+ *
73
+ * Absent means `"image"`, because every adapter written before documents
74
+ * existed returns images and must keep working unchanged. `"file"` is a
75
+ * document — a menu PDF, a price list, a consent form — which the editor
76
+ * offers into a `file` field or a prose link rather than an `<img>`.
77
+ */
78
+ kind?: "image" | "file";
70
79
  /** Human-facing filename or title, if the store has one. */
71
80
  name?: string;
72
81
  /** Full-size URL to insert into the page. Omitted only if the store has none. */
73
82
  imageUrl?: string;
74
- /** Grid thumbnail. May be the same URL as `imageUrl` when no derivative exists. */
83
+ /**
84
+ * Grid thumbnail. May be the same URL as `imageUrl` when no derivative
85
+ * exists. A `file` has nothing to show, so this is `""` for one and the
86
+ * picker draws an icon from `contentType` instead.
87
+ */
75
88
  thumbUrl: string;
76
89
  /** Alt text the store already holds, so the editor does not invent one. */
77
90
  alt?: string;
91
+ /** For `kind: "file"`: the URL to link to. */
92
+ url?: string;
93
+ /** MIME type, when the store knows it. Drives the picker's icon and filter. */
94
+ contentType?: string;
95
+ /** Bytes, when the store knows it. Shown to a person; nothing decides on it. */
96
+ size?: number;
78
97
  }
79
98
  /** One page of a media-library search. */
80
99
  export interface CmsMediaQuery {
@@ -84,6 +103,16 @@ export interface CmsMediaQuery {
84
103
  page: number;
85
104
  /** Items per page. */
86
105
  limit: number;
106
+ /**
107
+ * Which kind the caller wants.
108
+ *
109
+ * Absent means "images", which is what every existing caller meant and every
110
+ * existing adapter answers. An adapter that does not understand this field
111
+ * returns images to a request for documents; the route filters the response
112
+ * by `kind` as well, so an old adapter degrades to an empty document tab
113
+ * rather than to a grid of images offered as PDFs.
114
+ */
115
+ kind?: "image" | "file";
87
116
  }
88
117
  /** What `getMedia` returns: the page, and how many there are in total. */
89
118
  export interface CmsMediaPage {
@@ -200,6 +229,43 @@ export interface CmsAdapter {
200
229
  * requirement, and nothing in the route path knows it exists.
201
230
  */
202
231
  getMedia?(query: CmsMediaQuery): Promise<CmsMediaPage>;
232
+ /**
233
+ * Optional media *write*, the other half of `getMedia`.
234
+ *
235
+ * Somebody has to be able to add a document, and until this existed the only
236
+ * way was to put the file on disk yourself and commit it — which is not a
237
+ * path a non-technical editor has, and is the whole reason the editor could
238
+ * list a site's PDFs but never gain one.
239
+ *
240
+ * The adapter decides where a file goes, for the same reason it decides where
241
+ * pages come from: we do not know. A static site writes into `public/`; a
242
+ * Sanity site uploads an asset and gets a CDN URL; a site on a read-only
243
+ * filesystem implements nothing and the editor offers no upload button.
244
+ * Owning storage here instead would mean owning it *badly* — the existing
245
+ * `POST /image/upload` writes to the orchestrator's local disk, which on an
246
+ * ephemeral container loses every upload on redeploy, and building the
247
+ * document path on that foundation would spread the defect rather than fix
248
+ * it.
249
+ *
250
+ * Implement it and the editor offers upload. Leave it off and it does not —
251
+ * silence means no, as everywhere else on this interface.
252
+ *
253
+ * Throwing rejects the upload with the thrown message shown to the editor, so
254
+ * a refusal ("we only accept PDFs", "that name is taken") is expressible
255
+ * without a second return shape.
256
+ */
257
+ uploadMedia?(input: CmsMediaUpload): Promise<CmsMediaItem>;
258
+ }
259
+ /** One file on its way in. */
260
+ export interface CmsMediaUpload {
261
+ /** Original filename as the browser reported it. Treat as untrusted. */
262
+ filename: string;
263
+ /** MIME type the browser reported, or "" — also untrusted. */
264
+ contentType: string;
265
+ /** The bytes. */
266
+ data: Uint8Array;
267
+ /** What the caller says this is. The adapter may disagree and throw. */
268
+ kind: "image" | "file";
203
269
  }
204
270
  /**
205
271
  * Per-operation capability declaration. Every field is tri-state, and the
@@ -266,6 +332,13 @@ export interface ResolvedCapabilities {
266
332
  * opens onto a method nobody implemented is a worse answer than no tab.
267
333
  */
268
334
  readsMedia: boolean;
335
+ /**
336
+ * Whether this site can take a new file — derived from `uploadMedia`, the
337
+ * same way `readsMedia` is derived from `getMedia`. The editor offers an
338
+ * upload control only where there is something behind it; a button that
339
+ * always fails is worse than no button.
340
+ */
341
+ writesMedia: boolean;
269
342
  /**
270
343
  * The subset the adapter actually declared. A caller that needs to
271
344
  * distinguish "this site says no" from "nobody said" reads this; everything
@@ -15,6 +15,7 @@ export function resolveCapabilities(adapter, override) {
15
15
  // `=== true`, not truthiness: an adapter that says nothing has not said yes.
16
16
  readsDraftPerspective: adapter?.perspectives === true,
17
17
  readsMedia: typeof adapter?.getMedia === "function",
18
+ writesMedia: typeof adapter?.uploadMedia === "function",
18
19
  declared
19
20
  };
20
21
  }
@@ -3,4 +3,4 @@ export { resolveCapabilities } from "./adapter.ts";
3
3
  export { jsonFileAdapter, type JsonFileAdapterOptions } from "./json-file-adapter.ts";
4
4
  export { editorApiAdapter, type EditorApiAdapterOptions } from "./editor-api-adapter.ts";
5
5
  export { ensureSessionBootstrapped, createCmsBootstrapCache, _resetCmsBootstrapCache, type CmsBootstrapCache, type CmsBootstrapCacheOptions, warmSessionBootstrap } from "./bootstrap.ts";
6
- export { cmsMediaSource, cmsMediaLabel, mediaSourceFromUnknown, type CmsMediaSource, type CmsMediaSourceConfig } from "./media-sources.ts";
6
+ export { cmsMediaSource, cmsMediaUploader, cmsMediaLabel, mediaSourceFromUnknown, type CmsMediaSource, type CmsMediaUploader, type CmsMediaSourceConfig } from "./media-sources.ts";
package/dist/cms/index.js CHANGED
@@ -2,4 +2,4 @@ export { resolveCapabilities } from "./adapter.js";
2
2
  export { jsonFileAdapter } from "./json-file-adapter.js";
3
3
  export { editorApiAdapter } from "./editor-api-adapter.js";
4
4
  export { ensureSessionBootstrapped, createCmsBootstrapCache, _resetCmsBootstrapCache, warmSessionBootstrap } from "./bootstrap.js";
5
- export { cmsMediaSource, cmsMediaLabel, mediaSourceFromUnknown } from "./media-sources.js";
5
+ export { cmsMediaSource, cmsMediaUploader, cmsMediaLabel, mediaSourceFromUnknown } from "./media-sources.js";
@@ -1,4 +1,4 @@
1
- import type { CmsMediaPage, CmsMediaQuery } from "./adapter.ts";
1
+ import type { CmsMediaItem, CmsMediaPage, CmsMediaQuery, CmsMediaUpload } from "./adapter.ts";
2
2
  /** Connection details for one of the built-in media readers. */
3
3
  export type CmsMediaSourceConfig = {
4
4
  provider: "contentful";
@@ -17,6 +17,8 @@ export type CmsMediaSourceConfig = {
17
17
  };
18
18
  /** A media reader: the exact shape of `CmsAdapter.getMedia`. */
19
19
  export type CmsMediaSource = (query: CmsMediaQuery) => Promise<CmsMediaPage>;
20
+ /** A media writer: the exact shape of `CmsAdapter.uploadMedia`. */
21
+ export type CmsMediaUploader = (input: CmsMediaUpload) => Promise<CmsMediaItem>;
20
22
  /** Display name for a provider id, for the picker's tab. */
21
23
  export declare function cmsMediaLabel(provider: CmsMediaSourceConfig["provider"]): string;
22
24
  /**
@@ -37,6 +39,32 @@ export declare function cmsMediaLabel(provider: CmsMediaSourceConfig["provider"]
37
39
  * promise inside a modal is not.
38
40
  */
39
41
  export declare function cmsMediaSource(config: CmsMediaSourceConfig): CmsMediaSource;
42
+ /**
43
+ * Build a media writer from the same connection details as the reader, or
44
+ * `null` when this provider (or this configuration) cannot take an upload.
45
+ *
46
+ * ```ts
47
+ * const uploadMedia = cmsMediaUploader({ provider: "sanity", projectId, dataset, token })
48
+ * return { id: "sanity", getPages, getMedia, ...(uploadMedia ? { uploadMedia } : {}) }
49
+ * ```
50
+ *
51
+ * Null rather than a stub that throws, and spread rather than assigned,
52
+ * because `writesMedia` is derived from whether the method *exists*
53
+ * (`resolveCapabilities`) and the editor shows its upload control on the
54
+ * strength of that. A method that is always present and always fails is a
55
+ * button that is always there and never works.
56
+ *
57
+ * So a project with a read-only token gets no upload control at all, which is
58
+ * the truth about it: Sanity's query API answers a viewer token and its asset
59
+ * API does not, and that difference is invisible until somebody picks a file.
60
+ *
61
+ * **Sanity only, for now.** Uploading to Contentful is a three-step
62
+ * asynchronous dance — create the asset, ask for processing, poll, publish —
63
+ * and Strapi's is a multipart POST to its upload plugin; neither is written
64
+ * yet, and both answer `null` so that an adapter which spreads the result
65
+ * simply offers no upload rather than offering a broken one.
66
+ */
67
+ export declare function cmsMediaUploader(config: CmsMediaSourceConfig): CmsMediaUploader | null;
40
68
  /**
41
69
  * Build a reader from an untrusted object, or `null` if it is not one of the
42
70
  * three shapes.
@@ -1,3 +1,5 @@
1
+ /** Sanity's dated API: one version for both the query and the asset endpoints. */
2
+ const SANITY_API_VERSION = "v2024-01-01";
1
3
  const LABELS = {
2
4
  contentful: "Contentful",
3
5
  sanity: "Sanity",
@@ -69,20 +71,33 @@ async function contentfulMedia(config, { query, page, limit }) {
69
71
  // ---------------------------------------------------------------------------
70
72
  // Sanity — GROQ, slice paging, a second query for the count.
71
73
  // ---------------------------------------------------------------------------
72
- async function sanityMedia(config, { query, page, limit }) {
74
+ async function sanityMedia(config, { query, page, limit, kind }) {
73
75
  const label = LABELS.sanity;
74
76
  const dataset = config.dataset ?? "production";
75
77
  const offset = (page - 1) * limit;
76
78
  const end = offset + limit - 1;
79
+ const wantsFiles = kind === "file";
80
+ /*
81
+ * Sanity keeps documents in a second asset type. A PDF uploaded through the
82
+ * Studio — or through `cmsMediaUploader` below — is a `sanity.fileAsset`,
83
+ * and asking for `sanity.imageAsset` finds none of them however many the
84
+ * project holds. Before this branch existed, a request for documents was
85
+ * answered with the image list, and the route's own kind filter then dropped
86
+ * every item: an empty Documents tab on a project full of menus.
87
+ */
88
+ const assetType = wantsFiles ? "sanity.fileAsset" : "sanity.imageAsset";
77
89
  // GROQ is interpolated, so the filter term is stripped of the two characters
78
90
  // that could close the string literal it lands inside.
79
91
  const safeQuery = (query ?? "").replace(/["\\]/g, "");
80
92
  const filter = safeQuery
81
- ? `_type == "sanity.imageAsset" && originalFilename match "*${safeQuery}*"`
82
- : `_type == "sanity.imageAsset"`;
83
- const groq = `*[${filter}] | order(_createdAt desc) [${offset}..${end}] { _id, url, originalFilename, metadata { dimensions } }`;
93
+ ? `_type == "${assetType}" && originalFilename match "*${safeQuery}*"`
94
+ : `_type == "${assetType}"`;
95
+ const projection = wantsFiles
96
+ ? `{ _id, url, originalFilename, mimeType, size }`
97
+ : `{ _id, url, originalFilename, metadata { dimensions } }`;
98
+ const groq = `*[${filter}] | order(_createdAt desc) [${offset}..${end}] ${projection}`;
84
99
  const countGroq = `count(*[${filter}])`;
85
- const base = `https://${config.projectId}.api.sanity.io/v2024-01-01/data/query/${dataset}`;
100
+ const base = `https://${config.projectId}.api.sanity.io/${SANITY_API_VERSION}/data/query/${dataset}`;
86
101
  const headers = {};
87
102
  if (config.token)
88
103
  headers.authorization = `Bearer ${config.token}`;
@@ -95,17 +110,37 @@ async function sanityMedia(config, { query, page, limit }) {
95
110
  const assets = (await assetsRes.json());
96
111
  const count = countRes.ok ? (await countRes.json()).result ?? 0 : 0;
97
112
  return {
98
- items: (assets.result ?? []).map((asset) => ({
113
+ items: (assets.result ?? []).map((asset) => wantsFiles ? sanityFileItem(asset) : {
99
114
  id: asset._id,
100
115
  name: asset.originalFilename,
101
116
  alt: asset.originalFilename,
102
117
  imageUrl: asset.url,
103
118
  thumbUrl: `${asset.url}?w=200&h=200&fit=crop`
104
- })),
119
+ }),
105
120
  totalPages: Math.ceil(count / limit),
106
121
  label
107
122
  };
108
123
  }
124
+ /**
125
+ * One `sanity.fileAsset` as the picker's document tab renders it.
126
+ *
127
+ * `url` rather than `imageUrl`, because this is a link target and not something
128
+ * to draw, and `thumbUrl: ""` for the same reason — the picker draws an icon
129
+ * from `contentType` when there is nothing to show. The URL is Sanity's own
130
+ * `cdn.sanity.io/files/…` address: durable, CDN-served, and unchanged by any
131
+ * redeploy of the site that links to it.
132
+ */
133
+ function sanityFileItem(asset) {
134
+ return {
135
+ id: asset._id,
136
+ kind: "file",
137
+ name: asset.originalFilename,
138
+ url: asset.url,
139
+ thumbUrl: "",
140
+ contentType: asset.mimeType ?? "application/octet-stream",
141
+ ...(typeof asset.size === "number" ? { size: asset.size } : {})
142
+ };
143
+ }
109
144
  // ---------------------------------------------------------------------------
110
145
  // Strapi — upload plugin, page/pageSize paging, count in a response header.
111
146
  // ---------------------------------------------------------------------------
@@ -142,6 +177,152 @@ async function strapiMedia(config, { query, page, limit }) {
142
177
  label
143
178
  };
144
179
  }
180
+ // ---------------------------------------------------------------------------
181
+ // Adding one — the write half, for the CMSes whose asset API is a single call.
182
+ // ---------------------------------------------------------------------------
183
+ /**
184
+ * Build a media writer from the same connection details as the reader, or
185
+ * `null` when this provider (or this configuration) cannot take an upload.
186
+ *
187
+ * ```ts
188
+ * const uploadMedia = cmsMediaUploader({ provider: "sanity", projectId, dataset, token })
189
+ * return { id: "sanity", getPages, getMedia, ...(uploadMedia ? { uploadMedia } : {}) }
190
+ * ```
191
+ *
192
+ * Null rather than a stub that throws, and spread rather than assigned,
193
+ * because `writesMedia` is derived from whether the method *exists*
194
+ * (`resolveCapabilities`) and the editor shows its upload control on the
195
+ * strength of that. A method that is always present and always fails is a
196
+ * button that is always there and never works.
197
+ *
198
+ * So a project with a read-only token gets no upload control at all, which is
199
+ * the truth about it: Sanity's query API answers a viewer token and its asset
200
+ * API does not, and that difference is invisible until somebody picks a file.
201
+ *
202
+ * **Sanity only, for now.** Uploading to Contentful is a three-step
203
+ * asynchronous dance — create the asset, ask for processing, poll, publish —
204
+ * and Strapi's is a multipart POST to its upload plugin; neither is written
205
+ * yet, and both answer `null` so that an adapter which spreads the result
206
+ * simply offers no upload rather than offering a broken one.
207
+ */
208
+ export function cmsMediaUploader(config) {
209
+ if (config.provider !== "sanity")
210
+ return null;
211
+ const token = config.token?.trim();
212
+ if (!token)
213
+ return null;
214
+ return (input) => sanityUpload({ ...config, token }, input);
215
+ }
216
+ /**
217
+ * MIME types for the document extensions a site is likely to be handed, used
218
+ * only when the browser reported none.
219
+ *
220
+ * Sanity sniffs the bytes and would usually get there on its own; sending
221
+ * `application/octet-stream` for a PDF, though, is how an asset ends up in the
222
+ * library with a type the picker cannot draw an icon for.
223
+ */
224
+ const UPLOAD_MIME_BY_EXT = {
225
+ pdf: "application/pdf",
226
+ doc: "application/msword",
227
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
228
+ xls: "application/vnd.ms-excel",
229
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
230
+ csv: "text/csv",
231
+ txt: "text/plain",
232
+ png: "image/png",
233
+ jpg: "image/jpeg",
234
+ jpeg: "image/jpeg",
235
+ webp: "image/webp",
236
+ gif: "image/gif",
237
+ svg: "image/svg+xml"
238
+ };
239
+ function uploadContentType(input) {
240
+ const declared = input.contentType.trim();
241
+ if (declared)
242
+ return declared;
243
+ const ext = input.filename.toLowerCase().split(".").pop() ?? "";
244
+ return UPLOAD_MIME_BY_EXT[ext] ?? "application/octet-stream";
245
+ }
246
+ /**
247
+ * The filename Sanity records as `originalFilename`.
248
+ *
249
+ * It is not a path — the asset's own URL is derived from a content hash — so
250
+ * this strips directory segments and control characters and otherwise leaves
251
+ * the name a person chose intact, accents and spaces included. The picker
252
+ * shows this string, and "Menükarte Winter 2026.pdf" is what makes a document
253
+ * recognisable in a list of twenty.
254
+ */
255
+ function uploadFilename(raw) {
256
+ const base = raw.split(/[\\/]/).pop() ?? "";
257
+ // Control characters, written as escapes: a literal one in the source is
258
+ // invisible to every reader and turns the file binary to `grep`.
259
+ return base.replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 120);
260
+ }
261
+ async function sanityUpload(config, input) {
262
+ const dataset = config.dataset ?? "production";
263
+ /*
264
+ * Two buckets, and picking the wrong one is not a detail: `images` runs the
265
+ * bytes through image processing and refuses anything that is not a picture,
266
+ * which is exactly the refusal a PDF sent there produces. `kind` is what the
267
+ * editor said it was uploading, so it decides.
268
+ */
269
+ const bucket = input.kind === "file" ? "files" : "images";
270
+ const filename = uploadFilename(input.filename);
271
+ const params = new URLSearchParams();
272
+ if (filename)
273
+ params.set("filename", filename);
274
+ const res = await fetch(`https://${config.projectId}.api.sanity.io/${SANITY_API_VERSION}/assets/${bucket}/${dataset}?${params}`, {
275
+ method: "POST",
276
+ headers: {
277
+ authorization: `Bearer ${config.token}`,
278
+ "content-type": uploadContentType(input)
279
+ },
280
+ body: new Uint8Array(input.data)
281
+ });
282
+ /*
283
+ * Throwing is the contract's way of refusing an upload, and the message
284
+ * reaches the person who picked the file — so it has to say what Sanity
285
+ * said, not that something went wrong.
286
+ */
287
+ if (!res.ok)
288
+ throw new Error(await sanityUploadError(res));
289
+ const body = (await res.json());
290
+ const doc = body.document;
291
+ if (!doc?._id || !doc.url) {
292
+ throw new Error("Sanity accepted the upload but returned no asset.");
293
+ }
294
+ if (input.kind === "file") {
295
+ return sanityFileItem({ _id: doc._id, url: doc.url, originalFilename: doc.originalFilename, mimeType: doc.mimeType, size: doc.size });
296
+ }
297
+ return {
298
+ id: doc._id,
299
+ name: doc.originalFilename,
300
+ alt: doc.originalFilename,
301
+ imageUrl: doc.url,
302
+ thumbUrl: `${doc.url}?w=200&h=200&fit=crop`
303
+ };
304
+ }
305
+ async function sanityUploadError(res) {
306
+ /*
307
+ * 401/403 has one overwhelmingly likely cause and it is not obvious from
308
+ * Sanity's own wording: the token reads fine — the picker's grid is full of
309
+ * images, which is what makes it confusing — and simply has no write grant.
310
+ */
311
+ const suffix = res.status === 401 || res.status === 403
312
+ ? " The API token needs write access to this dataset; a read token fills the picker but cannot upload."
313
+ : "";
314
+ const fallback = `Sanity refused the upload (${res.status}).${suffix}`;
315
+ try {
316
+ const body = (await res.json());
317
+ const described = typeof body.error === "string"
318
+ ? body.error
319
+ : body.error?.description ?? body.error?.message ?? body.message;
320
+ return described ? `Sanity refused the upload: ${described}${suffix}` : fallback;
321
+ }
322
+ catch {
323
+ return fallback;
324
+ }
325
+ }
145
326
  /**
146
327
  * Build a reader from an untrusted object, or `null` if it is not one of the
147
328
  * three shapes.