@avocadostudio-ai/site-sdk 0.11.0 → 0.11.2

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.
package/README.md CHANGED
@@ -133,6 +133,16 @@ export const { GET, POST, OPTIONS } = createEditorApiHandler({
133
133
  })
134
134
  ```
135
135
 
136
+ **A publish may not remove every page.** The route used to validate shape and
137
+ nothing else — `[]` is an array, so a publish that deleted the whole site was
138
+ indistinguishable from one that fixed a heading, and an empty `pages` is
139
+ overwhelmingly a client that failed to load its own state rather than somebody
140
+ deleting nine pages on purpose. Such a publish is refused with `409`; removing
141
+ *some* pages stays an ordinary edit. To empty a site deliberately, send
142
+ `"allowDelete": true` in the body. Set `maxPagesRemoved` if you want a tighter
143
+ bound than "not all of them" — it is measured against your own `getPages`, so
144
+ it applies only when that read succeeds.
145
+
136
146
  ### 5. Add styles
137
147
 
138
148
  ```tsx
@@ -247,7 +257,7 @@ Your site exposes these endpoints via `createEditorApiHandler`:
247
257
  | `/api/editor/draft/disable` | GET | Disable draft mode |
248
258
  | `/api/editor/blocks` | GET | Return block manifest (schemas + defaults) |
249
259
  | `/api/editor/pages` | GET | Return all published pages |
250
- | `/api/editor/publish` | POST | Receive pages from editor, persist to CMS |
260
+ | `/api/editor/publish` | POST | Receive pages from editor, persist to CMS. Refuses a publish that removes every page unless the body sets `allowDelete: true`. |
251
261
 
252
262
  ## Library mode mounts two handlers, not one
253
263
 
@@ -46,6 +46,16 @@ export interface EditorApiHandlerConfig {
46
46
  onPublish?: OnPublishFn;
47
47
  /** Secret token required for publish requests. Checked against x-publish-token header. */
48
48
  publishSecret?: string;
49
+ /**
50
+ * Refuse a publish that removes more than this many pages, unless the body
51
+ * carries `allowDelete: true`.
52
+ *
53
+ * A publish that removes *every* page is refused whatever this is set to —
54
+ * see `checkDestructivePublish`. This option exists for sites that want a
55
+ * tighter bound than "not all of them", and it is measured against
56
+ * `getPages`, so it is only enforceable when that read succeeds.
57
+ */
58
+ maxPagesRemoved?: number;
49
59
  }
50
60
  export interface EditorApiCoreConfig extends EditorApiHandlerConfig {
51
61
  /**
@@ -36,7 +36,15 @@ export function createEditorApiHandlerCore(config) {
36
36
  const blocksHandler = createBlocksHandler(manifestBuilder ? { getManifest: manifestBuilder } : undefined);
37
37
  const pagesHandler = createPagesHandler(config.getPages, config.getSiteConfig);
38
38
  const publishHandler = config.onPublish
39
- ? createPublishHandler(config.onPublish, { publishSecret: config.publishSecret })
39
+ ? createPublishHandler(config.onPublish, {
40
+ publishSecret: config.publishSecret,
41
+ // The same getter `/api/editor/pages` serves, reused as the
42
+ // destructive-publish baseline. Every route built through this handler
43
+ // has one, so the guard can count what a publish would remove rather
44
+ // than only noticing that it removes everything.
45
+ getPages: config.getPages,
46
+ maxPagesRemoved: config.maxPagesRemoved
47
+ })
40
48
  : null;
41
49
  /*
42
50
  * `/api/editor/draft/disable` is two segments, so the fallback cannot simply
@@ -44,9 +44,65 @@ export declare function createPagesHandler(getPages: () => PageDoc[] | Promise<P
44
44
  * An optional guard on a write endpoint is not a guard. This one now fails
45
45
  * closed wherever it matters and says which variable opens it.
46
46
  */
47
+ /**
48
+ * Why a publish that empties the site is refused.
49
+ *
50
+ * The only validation this route ever had was shape: `Array.isArray(body.pages)`.
51
+ * `[]` is an array, so a publish that removes every page was indistinguishable
52
+ * from one that fixes a heading. A clean-room run at 0.11.0 sent
53
+ * `{"pages":[]}` with the token the scaffold itself had generated, under
54
+ * `NODE_ENV=production`, and got `{"ok":true,"slugs":[]}` back: nine pages
55
+ * gone, every URL 404, no backup and no confirmation.
56
+ *
57
+ * The reason this is a guard rather than a warning is what actually produces an
58
+ * empty array. It is not somebody deleting their site one page at a time — it
59
+ * is a client whose state failed to load publishing what it thinks it has.
60
+ * Losing a site to a failed fetch is not a decision anybody made.
61
+ *
62
+ * So the rule is the narrowest one that holds: **a publish may not remove every
63
+ * page.** Deleting one page is an ordinary edit and stays ordinary; deleting
64
+ * the last one needs `allowDelete: true` in the body, which is a thing somebody
65
+ * types on purpose and cannot arrive at by omission. Hosts that want a tighter
66
+ * bound than "not all of them" set `maxPagesRemoved`.
67
+ */
68
+ export declare const DESTRUCTIVE_PUBLISH_HINT: string;
69
+ export type PublishGuardResult = {
70
+ refused: false;
71
+ } | {
72
+ refused: true;
73
+ reason: string;
74
+ };
75
+ /**
76
+ * Decide whether a publish is destructive enough to refuse.
77
+ *
78
+ * Exported because it is the whole of the rule, and a rule nobody can test in
79
+ * isolation is a rule that drifts. `before` is `undefined` when the caller has
80
+ * no way to read the current state — `createPublishHandler` used directly,
81
+ * rather than through `createEditorApiHandler`, which always has `getPages`.
82
+ * Without a baseline an empty publish is still refused: not knowing what is
83
+ * there is not a reason to overwrite it with nothing.
84
+ */
85
+ export declare function checkDestructivePublish(args: {
86
+ next: readonly {
87
+ slug?: string;
88
+ }[];
89
+ before: readonly {
90
+ slug?: string;
91
+ }[] | undefined;
92
+ allowDelete: boolean;
93
+ maxPagesRemoved?: number;
94
+ }): PublishGuardResult;
47
95
  export declare const OPEN_PUBLISH_HINT: string;
48
96
  export declare function createPublishHandler(onPublish: OnPublishFn, options?: {
49
97
  publishSecret?: string;
98
+ /**
99
+ * The currently published pages, used only as the destructive-publish
100
+ * baseline. `createEditorApiHandler` passes its own `getPages`; a handler
101
+ * built by hand may omit it and gets the baseline-free rule.
102
+ */
103
+ getPages?: () => PageDoc[] | Promise<PageDoc[]>;
104
+ /** Refuse a publish removing more than this many pages. See `checkDestructivePublish`. */
105
+ maxPagesRemoved?: number;
50
106
  }): {
51
107
  POST: (request: Request) => Promise<Response>;
52
108
  OPTIONS: (request: Request) => Response;
@@ -46,6 +46,66 @@ export function createPagesHandler(getPages, getSiteConfig) {
46
46
  * An optional guard on a write endpoint is not a guard. This one now fails
47
47
  * closed wherever it matters and says which variable opens it.
48
48
  */
49
+ /**
50
+ * Why a publish that empties the site is refused.
51
+ *
52
+ * The only validation this route ever had was shape: `Array.isArray(body.pages)`.
53
+ * `[]` is an array, so a publish that removes every page was indistinguishable
54
+ * from one that fixes a heading. A clean-room run at 0.11.0 sent
55
+ * `{"pages":[]}` with the token the scaffold itself had generated, under
56
+ * `NODE_ENV=production`, and got `{"ok":true,"slugs":[]}` back: nine pages
57
+ * gone, every URL 404, no backup and no confirmation.
58
+ *
59
+ * The reason this is a guard rather than a warning is what actually produces an
60
+ * empty array. It is not somebody deleting their site one page at a time — it
61
+ * is a client whose state failed to load publishing what it thinks it has.
62
+ * Losing a site to a failed fetch is not a decision anybody made.
63
+ *
64
+ * So the rule is the narrowest one that holds: **a publish may not remove every
65
+ * page.** Deleting one page is an ordinary edit and stays ordinary; deleting
66
+ * the last one needs `allowDelete: true` in the body, which is a thing somebody
67
+ * types on purpose and cannot arrive at by omission. Hosts that want a tighter
68
+ * bound than "not all of them" set `maxPagesRemoved`.
69
+ */
70
+ export const DESTRUCTIVE_PUBLISH_HINT = "This publish would remove every page from the site. If that is intended, resend it with " +
71
+ '`"allowDelete": true` in the body. An empty `pages` array is most often a client that failed ' +
72
+ "to load its own state, so it is refused by default.";
73
+ /**
74
+ * Decide whether a publish is destructive enough to refuse.
75
+ *
76
+ * Exported because it is the whole of the rule, and a rule nobody can test in
77
+ * isolation is a rule that drifts. `before` is `undefined` when the caller has
78
+ * no way to read the current state — `createPublishHandler` used directly,
79
+ * rather than through `createEditorApiHandler`, which always has `getPages`.
80
+ * Without a baseline an empty publish is still refused: not knowing what is
81
+ * there is not a reason to overwrite it with nothing.
82
+ */
83
+ export function checkDestructivePublish(args) {
84
+ const { next, before, allowDelete, maxPagesRemoved } = args;
85
+ if (allowDelete)
86
+ return { refused: false };
87
+ if (next.length === 0) {
88
+ // A site that is already empty cannot be emptied. Publishing nothing onto
89
+ // nothing is a no-op, and refusing it would fail a brand-new integration on
90
+ // its very first publish.
91
+ if (before && before.length === 0)
92
+ return { refused: false };
93
+ const had = before ? `${before.length} page${before.length === 1 ? "" : "s"}` : "its pages";
94
+ return { refused: true, reason: `${DESTRUCTIVE_PUBLISH_HINT} The site currently has ${had}.` };
95
+ }
96
+ if (maxPagesRemoved === undefined || !before)
97
+ return { refused: false };
98
+ const keeping = new Set(next.map((p) => p.slug));
99
+ const removed = before.filter((p) => !keeping.has(p.slug));
100
+ if (removed.length <= maxPagesRemoved)
101
+ return { refused: false };
102
+ return {
103
+ refused: true,
104
+ reason: `This publish would remove ${removed.length} pages (${removed.map((p) => p.slug).join(", ")}), ` +
105
+ `and this site is configured to refuse a publish removing more than ${maxPagesRemoved}. ` +
106
+ 'Resend with `"allowDelete": true` if that is intended.'
107
+ };
108
+ }
49
109
  export const OPEN_PUBLISH_HINT = "This publish endpoint overwrites the site's content and has no publishSecret configured, " +
50
110
  "so it refuses every request under NODE_ENV=production. Set PUBLISH_TOKEN (the same value the " +
51
111
  "orchestrator sends as x-publish-token), or pass publishSecret to createEditorApiHandler().";
@@ -82,6 +142,32 @@ export function createPublishHandler(onPublish, options) {
82
142
  return applyEditorCors(res, request.headers.get("origin"));
83
143
  }
84
144
  const pages = body.pages;
145
+ /*
146
+ * The baseline is best-effort on purpose. `getPages` reaches a CMS on
147
+ * some integrations, and a publish is not the moment to fail because a
148
+ * read timed out — but it is also not the moment to assume the site was
149
+ * empty. An unreadable baseline becomes `undefined`, which still
150
+ * refuses an empty publish and simply cannot enforce `maxPagesRemoved`.
151
+ */
152
+ let before;
153
+ if (options?.getPages) {
154
+ try {
155
+ before = await options.getPages();
156
+ }
157
+ catch {
158
+ before = undefined;
159
+ }
160
+ }
161
+ const guard = checkDestructivePublish({
162
+ next: pages,
163
+ before,
164
+ allowDelete: body.allowDelete === true,
165
+ maxPagesRemoved: options?.maxPagesRemoved
166
+ });
167
+ if (guard.refused) {
168
+ const res = new Response(JSON.stringify({ ok: false, error: "refused", reason: guard.reason }), { status: 409, headers: { "Content-Type": "application/json" } });
169
+ return applyEditorCors(res, request.headers.get("origin"));
170
+ }
85
171
  const config = (body.siteConfig ?? {});
86
172
  const context = { assets: body.assets };
87
173
  const result = await onPublish(pages, config, context);
@@ -3,7 +3,8 @@ export type { DraftRouteAdapter } from "./draft-routes-core.ts";
3
3
  export { createEditorApiHandlerCore } from "./editor-api-handler-core.ts";
4
4
  export type { EditorApiHandlerConfig, EditorApiCoreConfig } from "./editor-api-handler-core.ts";
5
5
  export { createBlocksHandler, createPagesHandler, createPublishHandler } from "./editor-routes.ts";
6
- export type { OnPublishFn, InlineAsset, PublishContext } from "./editor-routes.ts";
6
+ export type { OnPublishFn, InlineAsset, PublishContext, PublishGuardResult } from "./editor-routes.ts";
7
+ export { checkDestructivePublish, DESTRUCTIVE_PUBLISH_HINT } from "./editor-routes.ts";
7
8
  export { isSafeImageUrl, createImageResolver } from "./publish-utils.ts";
8
9
  export type { ImageUploader } from "./publish-utils.ts";
9
10
  export { createRevalidateHandler } from "./revalidate-handler.ts";
@@ -13,6 +13,8 @@ export { createDraftEnableHandlerCore, createDraftDisableHandlerCore } from "./d
13
13
  export { createEditorApiHandlerCore } from "./editor-api-handler-core.js";
14
14
  // Individual editor route handlers — already framework-free.
15
15
  export { createBlocksHandler, createPagesHandler, createPublishHandler } from "./editor-routes.js";
16
+ // The destructive-publish rule, and the text of its refusal.
17
+ export { checkDestructivePublish, DESTRUCTIVE_PUBLISH_HINT } from "./editor-routes.js";
16
18
  // Publish utilities (SSRF check, image resolution).
17
19
  export { isSafeImageUrl, createImageResolver } from "./publish-utils.js";
18
20
  // Revalidation handler factory — takes a `revalidate` callback, so the host's
package/dist/routes.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { createDraftEnableHandler, createDraftDisableHandler } from "./draft-routes.ts";
2
2
  export { createBlocksHandler, createPagesHandler, createPublishHandler } from "./editor-routes.ts";
3
- export type { OnPublishFn, InlineAsset, PublishContext } from "./editor-routes.ts";
3
+ export type { OnPublishFn, InlineAsset, PublishContext, PublishGuardResult } from "./editor-routes.ts";
4
+ export { checkDestructivePublish, DESTRUCTIVE_PUBLISH_HINT } from "./editor-routes.ts";
4
5
  export { createEditorApiHandler } from "./editor-api-handler.ts";
5
6
  export type { EditorApiHandlerConfig } from "./editor-api-handler.ts";
6
7
  export { isSafeImageUrl, createImageResolver } from "./publish-utils.ts";
package/dist/routes.js CHANGED
@@ -2,6 +2,8 @@
2
2
  export { createDraftEnableHandler, createDraftDisableHandler } from "./draft-routes.js";
3
3
  // Editor route handler factories
4
4
  export { createBlocksHandler, createPagesHandler, createPublishHandler } from "./editor-routes.js";
5
+ // The destructive-publish rule, and the text of its refusal.
6
+ export { checkDestructivePublish, DESTRUCTIVE_PUBLISH_HINT } from "./editor-routes.js";
5
7
  // Catch-all editor API handler
6
8
  export { createEditorApiHandler } from "./editor-api-handler.js";
7
9
  // Publish utilities (SSRF check, image resolution)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/site-sdk",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -147,17 +147,17 @@
147
147
  ],
148
148
  "dependencies": {
149
149
  "zod": "^4.3.6",
150
- "@avocadostudio-ai/preview-adapter": "^0.11.0",
151
- "@avocadostudio-ai/shared": "^0.11.0",
152
- "@avocadostudio-ai/richtext": "^0.11.0",
153
- "@avocadostudio-ai/blocks": "^0.11.0"
150
+ "@avocadostudio-ai/blocks": "^0.11.2",
151
+ "@avocadostudio-ai/preview-adapter": "^0.11.2",
152
+ "@avocadostudio-ai/shared": "^0.11.2",
153
+ "@avocadostudio-ai/richtext": "^0.11.2"
154
154
  },
155
155
  "peerDependencies": {
156
156
  "next": ">=15.0.0",
157
157
  "react": ">=19.0.0",
158
158
  "react-dom": ">=19.0.0",
159
159
  "better-sqlite3": ">=12.0.0",
160
- "@avocadostudio-ai/orchestrator-core": "^0.11.0"
160
+ "@avocadostudio-ai/orchestrator-core": "^0.11.2"
161
161
  },
162
162
  "peerDependenciesMeta": {
163
163
  "@avocadostudio-ai/orchestrator-core": {