@avocadostudio-ai/site-sdk 0.1.0 → 0.2.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 (45) hide show
  1. package/README.md +212 -2
  2. package/dist/create-site-page.d.ts +38 -8
  3. package/dist/create-site-page.js +59 -8
  4. package/dist/draft-common.d.ts +32 -0
  5. package/dist/draft-common.js +58 -0
  6. package/dist/draft-context-core.js +39 -6
  7. package/dist/draft-context-core.test.d.ts +10 -0
  8. package/dist/draft-context-core.test.js +146 -0
  9. package/dist/editor-cors.d.ts +12 -0
  10. package/dist/editor-cors.js +31 -6
  11. package/dist/editor-cors.test.d.ts +1 -0
  12. package/dist/editor-cors.test.js +66 -0
  13. package/dist/editor-manifest.d.ts +2 -3
  14. package/dist/editor-manifest.js +12 -64
  15. package/dist/editor-matcher.d.ts +27 -0
  16. package/dist/editor-matcher.js +34 -0
  17. package/dist/editor-query.js +7 -1
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.js +2 -0
  20. package/dist/integration-check.js +11 -1
  21. package/dist/manifest-utils.d.ts +13 -0
  22. package/dist/manifest-utils.js +30 -3
  23. package/dist/manifest-utils.test.d.ts +1 -0
  24. package/dist/manifest-utils.test.js +72 -0
  25. package/dist/middleware.d.ts +21 -19
  26. package/dist/middleware.js +19 -22
  27. package/dist/next-config.test.d.ts +1 -0
  28. package/dist/next-config.test.js +253 -0
  29. package/dist/page-metadata.d.ts +66 -0
  30. package/dist/page-metadata.js +110 -0
  31. package/dist/page-metadata.test.d.ts +1 -0
  32. package/dist/page-metadata.test.js +105 -0
  33. package/dist/proxy.d.ts +58 -0
  34. package/dist/proxy.js +50 -0
  35. package/dist/proxy.test.d.ts +1 -0
  36. package/dist/proxy.test.js +72 -0
  37. package/dist/publish/field-diff.d.ts +191 -0
  38. package/dist/publish/field-diff.js +252 -0
  39. package/dist/publish/field-diff.test.d.ts +1 -0
  40. package/dist/publish/field-diff.test.js +286 -0
  41. package/dist/server/orchestrator.d.ts +1 -117
  42. package/dist/server/orchestrator.js +14 -733
  43. package/next-config.d.ts +68 -0
  44. package/next-config.mjs +358 -0
  45. package/package.json +63 -19
package/README.md CHANGED
@@ -17,8 +17,9 @@ npm install @avocadostudio-ai/site-sdk
17
17
  import { createSitePage } from "@avocadostudio-ai/site-sdk/page"
18
18
  import { getPage, getSlugs, getSiteConfig } from "../../lib/my-cms"
19
19
 
20
- const { Page, generateStaticParams } = createSitePage({
20
+ const { Page, generateStaticParams, generateMetadata } = createSitePage({
21
21
  siteId: "my-site",
22
+ siteName: "My Site", // optional, used for og:site_name
22
23
  getPage, // (slug: string) => Promise<PageDoc | null>
23
24
  getSlugs, // () => Promise<string[]>
24
25
  getSiteConfig, // () => Promise<SiteConfig> (optional)
@@ -29,9 +30,14 @@ const { Page, generateStaticParams } = createSitePage({
29
30
  })
30
31
 
31
32
  export default Page
32
- export { generateStaticParams }
33
+ export { generateStaticParams, generateMetadata }
33
34
  ```
34
35
 
36
+ Export `generateMetadata` too, or every page inherits the root layout's
37
+ `<title>` with no description and no social card. The factory derives all three
38
+ from the page and calls `notFound()` for an unknown slug, so add an
39
+ `app/not-found.tsx` if you want your own chrome around the 404.
40
+
35
41
  ### 3. Create the editor API route
36
42
 
37
43
  ```tsx
@@ -101,6 +107,208 @@ Your site exposes these endpoints via `createEditorApiHandler`:
101
107
  | `/api/editor/pages` | GET | Return all published pages |
102
108
  | `/api/editor/publish` | POST | Receive pages from editor, persist to CMS |
103
109
 
110
+ ## Library mode needs a credential
111
+
112
+ `createOrchestrator()` mounts on your own domain and can edit and publish your
113
+ content, so it needs to know who is calling. There are three ways to tell it,
114
+ in precedence order.
115
+
116
+ **1. Reuse the auth your app already has.** The hook receives the `Request` and
117
+ returns a boolean, or an object to attach context:
118
+
119
+ ```ts
120
+ export const { GET, POST, OPTIONS } = createOrchestrator({
121
+ auth: async (request) => {
122
+ const session = await getSession(request) // your own session, your own rules
123
+ return session?.role === "editor"
124
+ }
125
+ })
126
+ ```
127
+
128
+ Throwing counts as a refusal, so a hook that awaits a session lookup does not
129
+ have to catch its own errors.
130
+
131
+ **2. Use the built-in token gate.** Set either variable and it turns on:
132
+
133
+ ```bash
134
+ # a password the editor prompts for; store the SHA-256, never the password
135
+ ACCESS_PASSWORD_HASH=$(printf '%s' 'your-password' | shasum -a 256 | cut -d' ' -f1)
136
+ # and/or a static token for scripts, CI, and MCP clients
137
+ ORCHESTRATOR_ACCESS_TOKEN=…
138
+ ```
139
+
140
+ `POST /auth/verify` exchanges the password for a 12-hour token; every other
141
+ route then requires it as `x-access-token`, `Authorization: Bearer`, or
142
+ `?accessToken=` (the last because `EventSource` cannot send headers). This is
143
+ the same gate the standalone orchestrator enforces, so one editor build talks to
144
+ both.
145
+
146
+ **3. Neither — and then production is closed.** With no hook and no credential,
147
+ the mount is open in development and **refuses every request** under
148
+ `NODE_ENV=production`. Not a warning: a 401. An unauthenticated publish endpoint
149
+ on a customer's domain is not something anyone should reach by forgetting a step.
150
+
151
+ To run open in production on purpose, say so in one line — `auth: () => true`.
152
+
153
+ Two paths stay reachable whatever the gate says, because a caller cannot hold a
154
+ credential yet or at all: `/auth/status` and `/auth/verify` (the exchange that
155
+ produces one), and `GET /generated-images/*` (an `<img>` tag on your rendered
156
+ page cannot attach a header).
157
+
158
+ ### CORS
159
+
160
+ `corsOrigins` unset means *reflect the caller's origin* in development and *send
161
+ no CORS headers* in production — same-origin works, cross-origin has to be named:
162
+
163
+ ```ts
164
+ createOrchestrator({ corsOrigins: ["https://editor.example.com"] })
165
+ ```
166
+
167
+ Pass `"*"` to reflect deliberately, or `null` to turn the SDK's CORS handling off
168
+ and let Next middleware own it.
169
+
170
+ ## Library mode needs `better-sqlite3`
171
+
172
+ `createOrchestrator()` keeps draft pages, undo stacks and the version log in
173
+ SQLite, so the app that mounts it must be able to load the driver:
174
+
175
+ ```bash
176
+ npm install better-sqlite3
177
+ ```
178
+
179
+ It is an **optional** peer dependency: a site that only renders published content
180
+ never touches it. Mount the orchestrator without it and every edit still applies
181
+ — to memory alone, lost on the next restart or instance recycle, after which the
182
+ session silently re-seeds from your CMS. `GET /status/planner` reports
183
+ `persistence: { ok: false, reason }` when that is the situation, and every `/ops`
184
+ response carries `persisted: false` with the cause.
185
+
186
+ A workspace link to `@avocadostudio-ai/orchestrator-core` is **not** enough. A
187
+ linked package's own dependencies are never materialised in the host's tree, and
188
+ a native module has to be resolvable from there.
189
+
190
+ ## Telling the orchestrator where your site is
191
+
192
+ An agent's only way to see what it just edited is `POST /preview/screenshot`,
193
+ which needs two things a site knows about itself and the orchestrator does not:
194
+
195
+ ```ts
196
+ createOrchestrator({
197
+ adapter,
198
+ previewUrl: "http://localhost:3000", // where this site is reachable
199
+ draftPath: "/preview-draft" // the route that renders drafts
200
+ })
201
+ ```
202
+
203
+ `draftPath` is either a prefix the page slug is appended to (`/avocado` →
204
+ `/avocado/de/events`) or a template naming where the slug goes
205
+ (`/preview/{slug}/draft`). It defaults to `/preview-draft`, which is what
206
+ `create-ai-site-editor` scaffolds — **if you wired Avocado into a site you
207
+ already had, set this**, or every draft screenshot comes back a cheerful 200 and
208
+ a picture of your 404 page.
209
+
210
+ Both can also be set at runtime with `POST /sites/register` (the MCP
211
+ `avocado-register-site` tool), which wins over the values above. `GET /sites`
212
+ reports the one site the process serves either way.
213
+
214
+ ## Publishing back to a real CMS
215
+
216
+ `onPublish(pages, config)` reads as "here are the pages, store them". That works
217
+ when the CMS shape *is* the editor shape — a JSON file — and not otherwise.
218
+ Every real CMS read is a projection: an asset reference flattened to a URL
219
+ string, a document reference resolved to one language's href, rich text
220
+ flattened to markdown. Writing the projection back replaces the reference with
221
+ the flattening and destroys the document.
222
+
223
+ So publish a **diff**, not a snapshot. `@ai-site-editor/site-sdk/publish` owns
224
+ the walk:
225
+
226
+ ```ts
227
+ import { diffPage, groupPatches, describeUnsupported } from "@ai-site-editor/site-sdk/publish"
228
+
229
+ const diff = diffPage({
230
+ page,
231
+ ctx: { lang },
232
+ locate: (block) => {
233
+ const source = block.props.__source // what the CMS held when you read it
234
+ if (!source) return null // a block the editor added — reported, not guessed
235
+ return {
236
+ documentId: page.id,
237
+ prefix: `pageBuilder[_key=="${block.id}"].`,
238
+ source,
239
+ specs: {
240
+ heading: { rehydrate: (props) => props.heading },
241
+ image: {
242
+ // `before` is the stored value, so an inversion can be partial: keep
243
+ // the asset reference the projection dropped, replace only the alt.
244
+ rehydrate: (props, before) => ({ ...before, alt: props.imageAlt }),
245
+ write: ({ before, after, path, emit, reject }) => {
246
+ if (before?.alt !== after.alt) emit(`${path}.alt`, after.alt)
247
+ if (before?.url !== after.url) {
248
+ reject("the image was replaced", "Upload it as an asset first.")
249
+ }
250
+ }
251
+ }
252
+ }
253
+ }
254
+ }
255
+ })
256
+
257
+ for (const { documentId, set } of groupPatches(diff.patches)) {
258
+ await client.patch(documentId).set(set).commit()
259
+ }
260
+ return { ok: true, unsupported: describeUnsupported(diff.unsupported) }
261
+ ```
262
+
263
+ Two things worth knowing before you write one.
264
+
265
+ **List items match on their own key, not their position.** An index-addressed
266
+ patch lands on the wrong row the moment anything reorders the array upstream.
267
+ `sanityPaths` (the default) addresses `field[_key=="…"]`; `indexPaths` exists for
268
+ stores with no element identity and is only correct when your publish is the
269
+ only writer.
270
+
271
+ **Refusing is a result, not an error.** Four things a field diff cannot express
272
+ — a replaced image, a retyped link, a list item added or removed, a new block —
273
+ are reported through `unsupported` and returned to the caller. A publisher that
274
+ drops them silently reports success for content the site will never show; one
275
+ that guesses writes a URL where a reference belongs.
276
+
277
+ If you did not embed your own source snapshot in block props, `onPublish`'s
278
+ third argument carries `context.published` — the pages as your adapter last
279
+ reported them. It is the copy taken at bootstrap, not a fresh read, so it is
280
+ absent after a restart that reloaded the draft from storage. Treat `undefined`
281
+ as "no baseline available", never as "the site was empty": publishing every
282
+ field on that assumption is the overwrite all of this exists to prevent.
283
+
284
+ ## Telling the orchestrator which blocks you render
285
+
286
+ Importing anything from `@avocadostudio-ai/shared` registers Avocado's 18
287
+ built-in block types, transitively and unavoidably — that is how the block
288
+ library works. A site that brought its own blocks therefore advertises both
289
+ sets, and an agent reading `/blocks/manifest` can add a `Hero` that applies
290
+ cleanly and renders nothing.
291
+
292
+ If you render Avocado's blocks (a scaffolded site does), skip this. If your site
293
+ brought its own, declare them:
294
+
295
+ ```ts
296
+ export const { GET, POST } = createOrchestrator({
297
+ adapter,
298
+ blockTypes: ["acme_hero", "acme_splitSection", "acme_pricing"]
299
+ })
300
+ ```
301
+
302
+ The declaration is exclusive. The manifest narrows to it, the planner is only
303
+ told about those types, and `add_block` refuses anything outside it with a
304
+ message naming what the site does render. Nothing is removed from the registry,
305
+ so stored content of an excluded type keeps validating and rendering — it simply
306
+ is not offered for insertion.
307
+
308
+ Declaring a type you never registered is not silently dropped: it has no schema
309
+ to describe, so it cannot reach the manifest, and the orchestrator logs a warning
310
+ naming it the first time the manifest is served.
311
+
104
312
  ## Environment Variables
105
313
 
106
314
  | Variable | Required | Description |
@@ -109,6 +317,8 @@ Your site exposes these endpoints via `createEditorApiHandler`:
109
317
  | `DRAFT_MODE_SECRET` | Yes | Secret for enabling Next.js draft mode. Must match editor's `VITE_SITE_DRAFT_SECRET`. |
110
318
  | `PUBLISH_TOKEN` | No | If set, publish requests must include this token in `x-publish-token` header. |
111
319
  | `EDITOR_CORS_ORIGINS` | No | Comma-separated origins allowed for editor API CORS. Defaults to `http://localhost:4100`. |
320
+ | `ACCESS_PASSWORD_HASH` | No | SHA-256 of the editor password. Turns on the built-in gate; `POST /auth/verify` exchanges the password for a token. |
321
+ | `ORCHESTRATOR_ACCESS_TOKEN` | No | Static token for callers that cannot complete a password exchange (CI, scripts, MCP clients). |
112
322
 
113
323
  ## Types
114
324
 
@@ -2,6 +2,7 @@ import type { JSX } from "react";
2
2
  import type { BlockInstance } from "./types.ts";
3
3
  import type { SiteConfig } from "@avocadostudio-ai/shared";
4
4
  import type { PageDoc } from "@avocadostudio-ai/shared";
5
+ import { type PageMetadata } from "./page-metadata.ts";
5
6
  /**
6
7
  * Configuration for creating a site page component.
7
8
  *
@@ -18,7 +19,7 @@ import type { PageDoc } from "@avocadostudio-ai/shared";
18
19
  * import { createSitePage } from "@avocadostudio-ai/site-sdk/page"
19
20
  * import { getPage, getSlugs, getSiteConfig } from "../../lib/my-cms"
20
21
  *
21
- * const { Page, generateStaticParams } = createSitePage({
22
+ * const { Page, generateStaticParams, generateMetadata } = createSitePage({
22
23
  * siteId: "my-site",
23
24
  * getPage,
24
25
  * getSlugs,
@@ -26,7 +27,7 @@ import type { PageDoc } from "@avocadostudio-ai/shared";
26
27
  * })
27
28
  *
28
29
  * export default Page
29
- * export { generateStaticParams }
30
+ * export { generateStaticParams, generateMetadata }
30
31
  * ```
31
32
  *
32
33
  * @example Split-route setup — fully static published path + dynamic preview path
@@ -36,14 +37,15 @@ import type { PageDoc } from "@avocadostudio-ai/shared";
36
37
  * export const { middleware, config } = createEditorMiddleware()
37
38
  *
38
39
  * // app/[[...slug]]/page.tsx (statically generated)
39
- * const { Page, generateStaticParams } = createSitePage({ mode: "static", ... })
40
+ * const { Page, generateStaticParams, generateMetadata } = createSitePage({ mode: "static", ... })
40
41
  * export default Page
41
- * export { generateStaticParams }
42
+ * export { generateStaticParams, generateMetadata }
42
43
  *
43
44
  * // app/preview-draft/[[...slug]]/page.tsx (dynamic editor route)
44
45
  * export const dynamic = "force-dynamic"
45
- * const { Page } = createSitePage({ mode: "preview", ... })
46
+ * const { Page, generateMetadata } = createSitePage({ mode: "preview", ... })
46
47
  * export default Page
48
+ * export { generateMetadata }
47
49
  * ```
48
50
  */
49
51
  export type SitePageConfig = {
@@ -63,12 +65,31 @@ export type SitePageConfig = {
63
65
  footer?: BlockInstance;
64
66
  /** Render site header/footer chrome. Set false when the host layout provides its own. Defaults to true. */
65
67
  chrome?: boolean;
68
+ /**
69
+ * Site name for `og:site_name`. A plain string rather than a lookup on
70
+ * `getSiteConfig` deliberately: `generateMetadata` runs as a separate pass
71
+ * from the render, so reading it from the CMS would double every request's
72
+ * config fetch to decorate one Open Graph field.
73
+ */
74
+ siteName?: string;
75
+ /**
76
+ * Last word on a page's metadata. Receives what the SDK derived and the page
77
+ * it derived it from (`null` when the slug has no page), and returns what to
78
+ * emit. Use it for canonical URLs, per-section title templates, or anything
79
+ * that needs host knowledge the SDK does not have.
80
+ */
81
+ metadata?: (derived: PageMetadata, page: PageDoc | null) => PageMetadata;
66
82
  /**
67
83
  * Rendering mode. Defaults to `"auto"`.
68
84
  *
69
- * - `"auto"`: single-route — handles published and editor modes in one file. Works without middleware.
70
- * - `"static"`: published-only no searchParams, no editor logic, fully static. Pair with `createEditorMiddleware()` and a separate `mode: "preview"` route.
71
- * - `"preview"`: editor-only always reads searchParams + draft content. Set `export const dynamic = "force-dynamic"` on the route file.
85
+ * - `"auto"`: single-route — handles published and editor modes in one file, and works without middleware or a proxy. **Not statically rendered:** deciding
86
+ * between the two modes means reading `searchParams` and draft mode, which
87
+ * opts the route into dynamic rendering for every visitor, not just the
88
+ * editor. `generateStaticParams` is still returned, and still useless here.
89
+ * Convenient for getting started; `"static"` + `"preview"` is what a
90
+ * production site wants.
91
+ * - `"static"`: published-only — no searchParams, no editor logic, genuinely static. Pair with `createEditorProxy()` (Next 16) or `createEditorMiddleware()` (Next 15) and a separate `mode: "preview"` route.
92
+ * - `"preview"`: editor-only — always reads searchParams + draft content, and always `noindex`. Set `export const dynamic = "force-dynamic"` on the route file.
72
93
  */
73
94
  mode?: "auto" | "static" | "preview";
74
95
  };
@@ -80,6 +101,14 @@ type StaticPageProps = {
80
101
  type DynamicPageProps = StaticPageProps & {
81
102
  searchParams: Promise<Record<string, string | string[] | undefined>>;
82
103
  };
104
+ /**
105
+ * Next passes `searchParams` to `generateMetadata` for dynamic routes and omits
106
+ * it for static ones, so the parameter is optional here rather than split
107
+ * across two signatures.
108
+ */
109
+ type MetadataProps = StaticPageProps & {
110
+ searchParams?: Promise<Record<string, string | string[] | undefined>>;
111
+ };
83
112
  /**
84
113
  * Create a Next.js page component with full editor integration.
85
114
  *
@@ -91,5 +120,6 @@ export declare function createSitePage(config: SitePageConfig): {
91
120
  generateStaticParams: () => Promise<{
92
121
  slug: string[] | undefined;
93
122
  }[]>;
123
+ generateMetadata: ({ params, searchParams }: MetadataProps) => Promise<PageMetadata>;
94
124
  };
95
125
  export {};
@@ -1,11 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { unstable_noStore as noStore } from "next/cache";
3
2
  import { draftMode } from "next/headers";
3
+ import { connection } from "next/server";
4
+ import { notFound } from "next/navigation";
4
5
  import { SharedBlockRenderer, BlocksHydrator } from "@avocadostudio-ai/blocks";
5
6
  import { buildSlug } from "./index.js";
6
7
  import { resolveEditorContext, fetchEditorPage, fetchEditorSlugs } from "./draft.js";
7
8
  import { renderBlocks, EditorOverlay } from "./editor.js";
8
9
  import { buildNavItems, buildSiteHeaderBlock } from "./navigation.js";
10
+ import { buildPageMetadata } from "./page-metadata.js";
9
11
  function resolve(config) {
10
12
  return {
11
13
  siteId: config.siteId,
@@ -16,6 +18,8 @@ function resolve(config) {
16
18
  defaultLogo: config.defaultLogo ?? "/logo.svg",
17
19
  footer: config.footer,
18
20
  chrome: config.chrome ?? true,
21
+ siteName: config.siteName,
22
+ metadata: config.metadata,
19
23
  };
20
24
  }
21
25
  function makeGenerateStaticParams(cmsGetSlugs) {
@@ -32,6 +36,17 @@ async function renderStatic(slug, c) {
32
36
  c.cmsGetSlugs(),
33
37
  c.cmsGetSiteConfig ? c.cmsGetSiteConfig() : Promise.resolve({}),
34
38
  ]);
39
+ /*
40
+ * `notFound()`, not a rendered 404 body. The previous version returned an
41
+ * <h1>404</h1> with HTTP 200, which is a soft 404: crawlers index it as a
42
+ * real page, and the host app's own `not-found.tsx` never runs. Consumers who
43
+ * want the site chrome around their 404 get it by adding `app/not-found.tsx`,
44
+ * which is where Next puts that and where a layout can wrap it.
45
+ *
46
+ * Before building the nav, since none of it survives the throw.
47
+ */
48
+ if (!page)
49
+ notFound();
35
50
  const { navItems, siteName, siteLogo } = buildNavItems({
36
51
  navSlugs,
37
52
  currentSlug: slug,
@@ -41,13 +56,48 @@ async function renderStatic(slug, c) {
41
56
  defaultLogo: c.defaultLogo,
42
57
  });
43
58
  const chromeHeader = buildSiteHeaderBlock({ navItems, siteName, siteLogo, activePath: slug });
44
- if (!page) {
45
- return (_jsxs(_Fragment, { children: [c.chrome && _jsx(SharedBlockRenderer, { block: chromeHeader }), _jsxs("main", { style: { padding: "4rem", textAlign: "center" }, children: [_jsx("h1", { children: "404" }), _jsx("p", { children: "Page not found." })] }), c.chrome && c.footer ? _jsx(SharedBlockRenderer, { block: c.footer }) : null] }));
46
- }
47
59
  return (_jsxs(_Fragment, { children: [c.chrome && _jsx(SharedBlockRenderer, { block: chromeHeader }), _jsxs("main", { children: [renderBlocks(page.blocks), _jsx(BlocksHydrator, {})] }), c.chrome && c.footer ? _jsx(SharedBlockRenderer, { block: c.footer }) : null] }));
48
60
  }
61
+ /**
62
+ * Metadata for a page, mirroring the render's own choice of source.
63
+ *
64
+ * Editor and draft-mode requests get `noindex` and nothing else: a preview URL
65
+ * that leaks into an index is a bug, and the draft fetch that would produce a
66
+ * better title belongs to the render pass, not to a second round-trip here.
67
+ */
68
+ const NOINDEX = { robots: { index: false, follow: false } };
69
+ function makeGenerateMetadata(c, mode) {
70
+ const decorate = (derived, page) => c.metadata ? c.metadata(derived, page) : derived;
71
+ return async function generateMetadata({ params, searchParams }) {
72
+ const { slug: slugParts } = await params;
73
+ const slug = buildSlug(slugParts);
74
+ // Every response from a preview route is draft content, by definition.
75
+ if (mode === "preview")
76
+ return decorate(NOINDEX, null);
77
+ if (mode === "auto") {
78
+ const search = searchParams ? await searchParams : {};
79
+ const editorCtx = await resolveEditorContext(search, {
80
+ defaultSession: c.defaultSession,
81
+ defaultSiteId: c.siteId,
82
+ });
83
+ const draft = await draftMode();
84
+ if (draft.isEnabled || editorCtx)
85
+ return decorate(NOINDEX, null);
86
+ }
87
+ const page = await c.cmsGetPage(slug);
88
+ if (!page)
89
+ return decorate({}, null);
90
+ return decorate(buildPageMetadata(page, { siteName: c.siteName }), page);
91
+ };
92
+ }
49
93
  async function renderPreview(slug, search, c) {
50
- noStore();
94
+ /*
95
+ * `connection()`, not `unstable_noStore()`. Same effect — this render depends
96
+ * on a live request and must never be prerendered or cached — but it is the
97
+ * stable primitive Next ships for saying so, and the SDK had exactly one
98
+ * `unstable_*` import left in it.
99
+ */
100
+ await connection();
51
101
  const editorCtx = await resolveEditorContext(search, {
52
102
  defaultSession: c.defaultSession,
53
103
  defaultSiteId: c.siteId,
@@ -102,13 +152,14 @@ export function createSitePage(config) {
102
152
  const c = resolve(config);
103
153
  const mode = config.mode ?? "auto";
104
154
  const generateStaticParams = makeGenerateStaticParams(c.cmsGetSlugs);
155
+ const generateMetadata = makeGenerateMetadata(c, mode);
105
156
  if (mode === "static") {
106
157
  async function Page({ params }) {
107
158
  const { slug: slugParts } = await params;
108
159
  const slug = buildSlug(slugParts);
109
160
  return renderStatic(slug, c);
110
161
  }
111
- return { Page, generateStaticParams };
162
+ return { Page, generateStaticParams, generateMetadata };
112
163
  }
113
164
  if (mode === "preview") {
114
165
  async function Page({ params, searchParams }) {
@@ -116,12 +167,12 @@ export function createSitePage(config) {
116
167
  const slug = buildSlug(slugParts);
117
168
  return renderPreview(slug, search, c);
118
169
  }
119
- return { Page, generateStaticParams };
170
+ return { Page, generateStaticParams, generateMetadata };
120
171
  }
121
172
  async function Page({ params, searchParams }) {
122
173
  const [{ slug: slugParts }, search] = await Promise.all([params, searchParams]);
123
174
  const slug = buildSlug(slugParts);
124
175
  return renderAuto(slug, search, c);
125
176
  }
126
- return { Page, generateStaticParams };
177
+ return { Page, generateStaticParams, generateMetadata };
127
178
  }
@@ -2,3 +2,35 @@ export declare const DRAFT_SESSION_COOKIE = "editor_draft_session";
2
2
  export declare const DRAFT_SITE_COOKIE = "editor_draft_site_id";
3
3
  export declare const EDITOR_ORIGIN_COOKIE = "editor_origin";
4
4
  export declare function normalizeOrigin(value: string | null | undefined): string | undefined;
5
+ /**
6
+ * Editor origins this site will talk to.
7
+ *
8
+ * `normalizeOrigin` above answers "is this a syntactically valid http(s)
9
+ * origin", which is a parsing question, not a trust question — every attacker's
10
+ * origin passes it. The editor origin becomes the `targetOrigin` of the
11
+ * postMessage bridge and the frame allowed to drive inline edits, so it has to
12
+ * come from configuration the site's operator controls, not from the URL that
13
+ * asked for the page.
14
+ *
15
+ * Configured via AVOCADO_EDITOR_ORIGINS (comma-separated) and
16
+ * NEXT_PUBLIC_EDITOR_ORIGIN. Both are read at call time rather than at module
17
+ * load so a test, or a host that assigns env late, sees the current value.
18
+ */
19
+ export declare function editorOriginAllowlist(env?: Record<string, string | undefined>, extra?: string): string[];
20
+ /** True for a loopback origin on any port — the shape a local editor takes. */
21
+ export declare function isLoopbackOrigin(origin: string): boolean;
22
+ /**
23
+ * Decide which editor origin to trust for this request.
24
+ *
25
+ * A caller-supplied origin is honoured only when the operator listed it. In
26
+ * development any loopback origin passes, because the editor's port moves
27
+ * around and a page served from your own machine is not the threat here.
28
+ * Anything else falls back to the configured default, so a request carrying a
29
+ * hostile origin degrades to "no bridge" rather than "bridge to the attacker".
30
+ */
31
+ export declare function resolveTrustedEditorOrigin(options: {
32
+ candidate: string | undefined;
33
+ fallback: string;
34
+ isDev: boolean;
35
+ env?: Record<string, string | undefined>;
36
+ }): string;
@@ -15,3 +15,61 @@ export function normalizeOrigin(value) {
15
15
  return undefined;
16
16
  }
17
17
  }
18
+ /**
19
+ * Editor origins this site will talk to.
20
+ *
21
+ * `normalizeOrigin` above answers "is this a syntactically valid http(s)
22
+ * origin", which is a parsing question, not a trust question — every attacker's
23
+ * origin passes it. The editor origin becomes the `targetOrigin` of the
24
+ * postMessage bridge and the frame allowed to drive inline edits, so it has to
25
+ * come from configuration the site's operator controls, not from the URL that
26
+ * asked for the page.
27
+ *
28
+ * Configured via AVOCADO_EDITOR_ORIGINS (comma-separated) and
29
+ * NEXT_PUBLIC_EDITOR_ORIGIN. Both are read at call time rather than at module
30
+ * load so a test, or a host that assigns env late, sees the current value.
31
+ */
32
+ export function editorOriginAllowlist(env = process.env, extra) {
33
+ const raw = [
34
+ ...(env.AVOCADO_EDITOR_ORIGINS ?? "").split(","),
35
+ ...(env.NEXT_PUBLIC_EDITOR_ORIGIN ?? "").split(","),
36
+ extra ?? ""
37
+ ];
38
+ const out = [];
39
+ for (const candidate of raw) {
40
+ const origin = normalizeOrigin(candidate);
41
+ if (origin && !out.includes(origin))
42
+ out.push(origin);
43
+ }
44
+ return out;
45
+ }
46
+ /** True for a loopback origin on any port — the shape a local editor takes. */
47
+ export function isLoopbackOrigin(origin) {
48
+ try {
49
+ const { hostname } = new URL(origin);
50
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ /**
57
+ * Decide which editor origin to trust for this request.
58
+ *
59
+ * A caller-supplied origin is honoured only when the operator listed it. In
60
+ * development any loopback origin passes, because the editor's port moves
61
+ * around and a page served from your own machine is not the threat here.
62
+ * Anything else falls back to the configured default, so a request carrying a
63
+ * hostile origin degrades to "no bridge" rather than "bridge to the attacker".
64
+ */
65
+ export function resolveTrustedEditorOrigin(options) {
66
+ const { candidate, fallback, isDev, env } = options;
67
+ if (!candidate)
68
+ return fallback;
69
+ const allowed = editorOriginAllowlist(env ?? process.env, fallback);
70
+ if (allowed.includes(candidate))
71
+ return candidate;
72
+ if (isDev && isLoopbackOrigin(candidate))
73
+ return candidate;
74
+ return fallback;
75
+ }
@@ -1,10 +1,34 @@
1
- import { DRAFT_SESSION_COOKIE, DRAFT_SITE_COOKIE, EDITOR_ORIGIN_COOKIE, normalizeOrigin } from "./draft-common.js";
1
+ import { validateDraftSecret } from "@avocadostudio-ai/shared";
2
+ import { DRAFT_SESSION_COOKIE, DRAFT_SITE_COOKIE, EDITOR_ORIGIN_COOKIE, normalizeOrigin, resolveTrustedEditorOrigin } from "./draft-common.js";
2
3
  import { single } from "./draft-context.js";
3
4
  export { single } from "./draft-context.js";
4
5
  export async function resolveDraftContextCore(searchParams, adapter, options) {
5
6
  const isDev = process.env.NODE_ENV !== "production";
6
- const isEditorParam = single(searchParams.__editor) === "1";
7
- const isContentStoreEnabled = isDev || adapter.isDraftMode || isEditorParam;
7
+ /*
8
+ * What proves this request may see unpublished content.
9
+ *
10
+ * `__editor=1` used to be enough on its own, in production, on the customer's
11
+ * own domain. It is a routing hint the middleware puts in the URL — no
12
+ * secret, no cookie — so one GET to a live site rendered another session's
13
+ * drafts and mounted the editing bridge. The parameter still selects the
14
+ * preview route; it no longer authorizes anything.
15
+ *
16
+ * Two things authorize in production. Draft mode being already enabled means
17
+ * the secret-gated /api/draft handler was passed and set the cookie. A valid
18
+ * `secret` on the request itself covers the case that handler cannot: the
19
+ * editor renders the site in a cross-origin iframe, where the draft cookie is
20
+ * frequently blocked outright, and a credential in the URL is what remains.
21
+ *
22
+ * That credential is a long-lived shared secret travelling in a query string.
23
+ * It is weaker than a signed, short-lived token and should become one — but
24
+ * it is the credential this stack already has, it is what /api/draft checks,
25
+ * and requiring it is the difference between "an attacker needs a secret" and
26
+ * "an attacker needs a URL".
27
+ *
28
+ * Development is unchanged: the parameter alone still works.
29
+ */
30
+ const hasValidSecret = validateDraftSecret(single(searchParams.secret), process.env).ok;
31
+ const isContentStoreEnabled = isDev || adapter.isDraftMode || hasValidSecret;
8
32
  if (!isContentStoreEnabled)
9
33
  return null;
10
34
  const defaultSession = options?.defaultSession ?? process.env.DRAFT_DEFAULT_SESSION?.trim() ?? "dev";
@@ -19,8 +43,17 @@ export async function resolveDraftContextCore(searchParams, adapter, options) {
19
43
  if (!siteId)
20
44
  return null;
21
45
  const session = single(searchParams.session) ?? adapter.getCookie(DRAFT_SESSION_COOKIE)?.trim() ?? defaultSession;
22
- const editorOrigin = normalizeOrigin(single(searchParams.editorOrigin))
23
- ?? normalizeOrigin(adapter.getCookie(EDITOR_ORIGIN_COOKIE))
24
- ?? defaultEditorOrigin;
46
+ /*
47
+ * The editor origin becomes the postMessage `targetOrigin` and the frame
48
+ * permitted to drive inline edits, so it cannot simply be whatever the URL
49
+ * says. A candidate is honoured only when the operator listed it; anything
50
+ * else degrades to the configured default.
51
+ */
52
+ const candidateOrigin = normalizeOrigin(single(searchParams.editorOrigin)) ?? normalizeOrigin(adapter.getCookie(EDITOR_ORIGIN_COOKIE));
53
+ const editorOrigin = resolveTrustedEditorOrigin({
54
+ candidate: candidateOrigin,
55
+ fallback: defaultEditorOrigin,
56
+ isDev
57
+ });
25
58
  return { session, siteId, editorOrigin };
26
59
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Who may see unpublished content, and which frame may drive the editor.
3
+ *
4
+ * Both questions used to be answered by the query string. `?__editor=1` alone
5
+ * turned on the draft content store — in production, on the customer's own
6
+ * domain — and `editorOrigin` was accepted from the same URL after a check that
7
+ * only asked whether it parsed as http(s). One GET rendered another session's
8
+ * drafts and pointed the postMessage bridge wherever the caller said.
9
+ */
10
+ export {};