@avocadostudio-ai/site-sdk 0.1.0 → 0.2.1

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 (50) hide show
  1. package/README.md +212 -2
  2. package/dist/cli/register.js +23 -1
  3. package/dist/create-site-page.d.ts +38 -8
  4. package/dist/create-site-page.js +59 -8
  5. package/dist/draft-common.d.ts +32 -0
  6. package/dist/draft-common.js +58 -0
  7. package/dist/draft-context-core.js +39 -6
  8. package/dist/draft-context-core.test.d.ts +10 -0
  9. package/dist/draft-context-core.test.js +146 -0
  10. package/dist/draft-fetch.d.ts +9 -10
  11. package/dist/draft-fetch.js +71 -5
  12. package/dist/draft-fetch.test.d.ts +1 -0
  13. package/dist/draft-fetch.test.js +87 -0
  14. package/dist/editor-cors.d.ts +12 -0
  15. package/dist/editor-cors.js +31 -6
  16. package/dist/editor-cors.test.d.ts +1 -0
  17. package/dist/editor-cors.test.js +66 -0
  18. package/dist/editor-manifest.d.ts +2 -3
  19. package/dist/editor-manifest.js +12 -64
  20. package/dist/editor-matcher.d.ts +27 -0
  21. package/dist/editor-matcher.js +34 -0
  22. package/dist/editor-query.js +7 -1
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.js +2 -0
  25. package/dist/integration-check.js +11 -1
  26. package/dist/manifest-utils.d.ts +13 -0
  27. package/dist/manifest-utils.js +30 -3
  28. package/dist/manifest-utils.test.d.ts +1 -0
  29. package/dist/manifest-utils.test.js +72 -0
  30. package/dist/middleware.d.ts +21 -19
  31. package/dist/middleware.js +19 -22
  32. package/dist/next-config.test.d.ts +1 -0
  33. package/dist/next-config.test.js +355 -0
  34. package/dist/page-metadata.d.ts +66 -0
  35. package/dist/page-metadata.js +110 -0
  36. package/dist/page-metadata.test.d.ts +1 -0
  37. package/dist/page-metadata.test.js +105 -0
  38. package/dist/proxy.d.ts +95 -0
  39. package/dist/proxy.js +76 -0
  40. package/dist/proxy.test.d.ts +1 -0
  41. package/dist/proxy.test.js +123 -0
  42. package/dist/publish/field-diff.d.ts +191 -0
  43. package/dist/publish/field-diff.js +252 -0
  44. package/dist/publish/field-diff.test.d.ts +1 -0
  45. package/dist/publish/field-diff.test.js +286 -0
  46. package/dist/server/orchestrator.d.ts +1 -117
  47. package/dist/server/orchestrator.js +14 -733
  48. package/next-config.d.ts +78 -0
  49. package/next-config.mjs +468 -0
  50. package/package.json +63 -19
@@ -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 {};
@@ -0,0 +1,146 @@
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
+ import { test, describe, afterEach } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { resolveDraftContextCore } from "./draft-context-core.js";
13
+ import { resolveTrustedEditorOrigin, editorOriginAllowlist, isLoopbackOrigin } from "./draft-common.js";
14
+ const SECRET = "s3cr3t-draft-key";
15
+ const saved = {};
16
+ const KEYS = ["NODE_ENV", "DRAFT_MODE_SECRET", "NEXT_PUBLIC_EDITOR_ORIGIN", "AVOCADO_EDITOR_ORIGINS"];
17
+ function setEnv(values) {
18
+ for (const key of KEYS) {
19
+ if (!(key in saved))
20
+ saved[key] = process.env[key];
21
+ }
22
+ for (const [key, value] of Object.entries(values)) {
23
+ if (value === undefined)
24
+ delete process.env[key];
25
+ else
26
+ process.env[key] = value;
27
+ }
28
+ }
29
+ afterEach(() => {
30
+ for (const key of KEYS) {
31
+ if (saved[key] === undefined)
32
+ delete process.env[key];
33
+ else
34
+ process.env[key] = saved[key];
35
+ }
36
+ });
37
+ function adapter(overrides) {
38
+ return { isDraftMode: false, getCookie: () => undefined, ...overrides };
39
+ }
40
+ describe("resolveDraftContextCore — production gate", () => {
41
+ test("__editor=1 alone no longer opens the content store", async () => {
42
+ setEnv({ NODE_ENV: "production", DRAFT_MODE_SECRET: SECRET });
43
+ const ctx = await resolveDraftContextCore({ __editor: "1", siteId: "acme", session: "victim" }, adapter());
44
+ assert.equal(ctx, null);
45
+ });
46
+ test("a wrong secret is refused", async () => {
47
+ setEnv({ NODE_ENV: "production", DRAFT_MODE_SECRET: SECRET });
48
+ const ctx = await resolveDraftContextCore({ __editor: "1", siteId: "acme", secret: "guess" }, adapter());
49
+ assert.equal(ctx, null);
50
+ });
51
+ test("the configured secret opens it — the cookie-blocked iframe case", async () => {
52
+ setEnv({ NODE_ENV: "production", DRAFT_MODE_SECRET: SECRET });
53
+ const ctx = await resolveDraftContextCore({ __editor: "1", siteId: "acme", secret: SECRET }, adapter());
54
+ assert.equal(ctx?.siteId, "acme");
55
+ });
56
+ test("draft mode already enabled opens it — the secret-gated cookie path", async () => {
57
+ setEnv({ NODE_ENV: "production", DRAFT_MODE_SECRET: SECRET });
58
+ const ctx = await resolveDraftContextCore({ siteId: "acme" }, adapter({ isDraftMode: true }));
59
+ assert.equal(ctx?.siteId, "acme");
60
+ });
61
+ test("with no secret configured, production stays shut rather than open", async () => {
62
+ setEnv({ NODE_ENV: "production", DRAFT_MODE_SECRET: undefined });
63
+ const ctx = await resolveDraftContextCore({ __editor: "1", siteId: "acme", secret: "anything" }, adapter());
64
+ assert.equal(ctx, null);
65
+ });
66
+ test("development is unchanged — the parameter alone still works", async () => {
67
+ setEnv({ NODE_ENV: "development", DRAFT_MODE_SECRET: undefined });
68
+ const ctx = await resolveDraftContextCore({ __editor: "1", siteId: "acme" }, adapter());
69
+ assert.equal(ctx?.siteId, "acme");
70
+ });
71
+ });
72
+ describe("resolveDraftContextCore — editor origin", () => {
73
+ test("an unlisted origin from the URL is ignored in favour of the configured one", async () => {
74
+ setEnv({
75
+ NODE_ENV: "production",
76
+ DRAFT_MODE_SECRET: SECRET,
77
+ NEXT_PUBLIC_EDITOR_ORIGIN: "https://editor.example.com"
78
+ });
79
+ const ctx = await resolveDraftContextCore({ siteId: "acme", secret: SECRET, editorOrigin: "https://attacker.example" }, adapter());
80
+ assert.equal(ctx?.editorOrigin, "https://editor.example.com");
81
+ });
82
+ test("a listed origin from the URL is honoured", async () => {
83
+ setEnv({
84
+ NODE_ENV: "production",
85
+ DRAFT_MODE_SECRET: SECRET,
86
+ NEXT_PUBLIC_EDITOR_ORIGIN: "https://editor.example.com",
87
+ AVOCADO_EDITOR_ORIGINS: "https://staging-editor.example.com, https://other.example.com"
88
+ });
89
+ const ctx = await resolveDraftContextCore({ siteId: "acme", secret: SECRET, editorOrigin: "https://staging-editor.example.com" }, adapter());
90
+ assert.equal(ctx?.editorOrigin, "https://staging-editor.example.com");
91
+ });
92
+ test("a hostile origin in the cookie is ignored too", async () => {
93
+ setEnv({
94
+ NODE_ENV: "production",
95
+ DRAFT_MODE_SECRET: SECRET,
96
+ NEXT_PUBLIC_EDITOR_ORIGIN: "https://editor.example.com"
97
+ });
98
+ const ctx = await resolveDraftContextCore({ siteId: "acme", secret: SECRET }, adapter({ getCookie: (name) => (name === "editor_origin" ? "https%3A%2F%2Fattacker.example" : undefined) }));
99
+ assert.equal(ctx?.editorOrigin, "https://editor.example.com");
100
+ });
101
+ });
102
+ describe("resolveTrustedEditorOrigin", () => {
103
+ test("keeps the fallback when there is no candidate", () => {
104
+ assert.equal(resolveTrustedEditorOrigin({ candidate: undefined, fallback: "https://a.example", isDev: false, env: {} }), "https://a.example");
105
+ });
106
+ test("accepts loopback in development, at any port", () => {
107
+ for (const candidate of ["http://localhost:4100", "http://localhost:4101", "http://127.0.0.1:5173"]) {
108
+ assert.equal(resolveTrustedEditorOrigin({ candidate, fallback: "http://localhost:4100", isDev: true, env: {} }), candidate);
109
+ }
110
+ });
111
+ test("refuses loopback in production — an operator must list it", () => {
112
+ assert.equal(resolveTrustedEditorOrigin({
113
+ candidate: "http://localhost:4100",
114
+ fallback: "https://editor.example.com",
115
+ isDev: false,
116
+ env: {}
117
+ }), "https://editor.example.com");
118
+ });
119
+ test("the fallback itself is always trusted", () => {
120
+ assert.equal(resolveTrustedEditorOrigin({
121
+ candidate: "https://editor.example.com",
122
+ fallback: "https://editor.example.com",
123
+ isDev: false,
124
+ env: {}
125
+ }), "https://editor.example.com");
126
+ });
127
+ });
128
+ describe("editorOriginAllowlist", () => {
129
+ test("merges both variables, normalises, and de-duplicates", () => {
130
+ const list = editorOriginAllowlist({
131
+ AVOCADO_EDITOR_ORIGINS: "https://a.example/, https://b.example",
132
+ NEXT_PUBLIC_EDITOR_ORIGIN: "https://a.example"
133
+ });
134
+ assert.deepEqual(list, ["https://a.example", "https://b.example"]);
135
+ });
136
+ test("drops entries that are not http(s) origins", () => {
137
+ const list = editorOriginAllowlist({ AVOCADO_EDITOR_ORIGINS: "javascript:alert(1), not-a-url, ftp://x.example" });
138
+ assert.deepEqual(list, []);
139
+ });
140
+ });
141
+ test("isLoopbackOrigin recognises the local spellings and nothing else", () => {
142
+ assert.equal(isLoopbackOrigin("http://localhost:4100"), true);
143
+ assert.equal(isLoopbackOrigin("http://127.0.0.1:3000"), true);
144
+ assert.equal(isLoopbackOrigin("https://localhost.attacker.example"), false);
145
+ assert.equal(isLoopbackOrigin("https://example.com"), false);
146
+ });
@@ -1,14 +1,13 @@
1
1
  import type { PageDoc, SiteConfig } from "@avocadostudio-ai/shared";
2
2
  export declare function getOrchestratorUrl(): string | null;
3
- export declare function fetchEditorPage(slug: string, session: string, siteId: string, options?: {
3
+ type DraftFetchOptions = {
4
4
  timeoutMs?: number;
5
5
  orchestratorUrl?: string;
6
- }): Promise<PageDoc | null>;
7
- export declare function fetchEditorSlugs(session: string, siteId: string, options?: {
8
- timeoutMs?: number;
9
- orchestratorUrl?: string;
10
- }): Promise<string[]>;
11
- export declare function fetchEditorSiteConfig(session: string, siteId: string, options?: {
12
- timeoutMs?: number;
13
- orchestratorUrl?: string;
14
- }): Promise<SiteConfig>;
6
+ accessToken?: string;
7
+ };
8
+ /** For tests: forget which refusals have already been reported. */
9
+ export declare function resetDraftFetchWarnings(): void;
10
+ export declare function fetchEditorPage(slug: string, session: string, siteId: string, options?: DraftFetchOptions): Promise<PageDoc | null>;
11
+ export declare function fetchEditorSlugs(session: string, siteId: string, options?: DraftFetchOptions): Promise<string[]>;
12
+ export declare function fetchEditorSiteConfig(session: string, siteId: string, options?: DraftFetchOptions): Promise<SiteConfig>;
13
+ export {};
@@ -7,6 +7,25 @@ export function getOrchestratorUrl() {
7
7
  return "http://127.0.0.1:4200";
8
8
  return null;
9
9
  }
10
+ /**
11
+ * The credential these reads present, if there is one.
12
+ *
13
+ * A library-mode orchestrator *must* be credentialed in production —
14
+ * `createOrchestrator` refuses every request under `NODE_ENV=production` with
15
+ * neither `ORCHESTRATOR_ACCESS_TOKEN` nor `ACCESS_PASSWORD_HASH` set. Until
16
+ * this existed, these three helpers sent no headers at all, so a site could not
17
+ * read its own drafts through the SDK its own docs told it to use.
18
+ *
19
+ * Read from the environment rather than passed in because the caller is a
20
+ * server component that has no session of its own. `ORCHESTRATOR_ACCESS_TOKEN`
21
+ * has no `NEXT_PUBLIC_` prefix, so it is `undefined` in a client bundle: if one
22
+ * of these ever gets pulled clientward the request degrades to no header
23
+ * instead of shipping the token to a browser.
24
+ */
25
+ function resolveAccessToken(explicit) {
26
+ const token = explicit?.trim() || process.env.ORCHESTRATOR_ACCESS_TOKEN?.trim();
27
+ return token || undefined;
28
+ }
10
29
  function buildCandidateBaseUrls(configuredBaseUrl) {
11
30
  const candidates = [configuredBaseUrl];
12
31
  try {
@@ -25,27 +44,64 @@ function buildCandidateBaseUrls(configuredBaseUrl) {
25
44
  }
26
45
  return candidates;
27
46
  }
28
- async function fetchWithTimeout(url, timeoutMs) {
47
+ async function fetchWithTimeout(url, timeoutMs, token) {
29
48
  const controller = new AbortController();
30
49
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
31
50
  try {
32
- const response = await fetch(url, { cache: "no-store", signal: controller.signal });
51
+ const response = await fetch(url, {
52
+ cache: "no-store",
53
+ signal: controller.signal,
54
+ ...(token ? { headers: { "x-access-token": token } } : {})
55
+ });
33
56
  return response;
34
57
  }
35
58
  finally {
36
59
  clearTimeout(timeoutId);
37
60
  }
38
61
  }
62
+ /**
63
+ * A refusal is not an absence, and the difference is the whole bug.
64
+ *
65
+ * These helpers run per page render, so a 401 that fell through to `return
66
+ * null` produced a preview showing *published* content — no error, no log line,
67
+ * and a page that looks right. It is the hardest failure in the stack to see.
68
+ *
69
+ * Retrying the next candidate base URL cannot help either: the same missing
70
+ * credential will be missing there too. So a refusal stops the loop and says so
71
+ * once, rather than being spent on latency and silence.
72
+ */
73
+ const REFUSAL_STATUSES = new Set([401, 403]);
74
+ const warned = new Set();
75
+ function warnRefused(fn, status, hadToken) {
76
+ const key = `${fn}:${status}:${hadToken}`;
77
+ if (warned.has(key))
78
+ return;
79
+ warned.add(key);
80
+ console.warn(`[site-sdk/draft] ${fn}: the orchestrator answered ${status}. ` +
81
+ (hadToken
82
+ ? "The token that was sent is not the one it accepts — check ORCHESTRATOR_ACCESS_TOKEN on both sides."
83
+ : "No credential was sent. Set ORCHESTRATOR_ACCESS_TOKEN, or pass `accessToken`.") +
84
+ " Drafts are unavailable, so this page is rendering published content.");
85
+ }
86
+ /** For tests: forget which refusals have already been reported. */
87
+ export function resetDraftFetchWarnings() {
88
+ warned.clear();
89
+ }
39
90
  export async function fetchEditorPage(slug, session, siteId, options) {
40
91
  const configuredBaseUrl = options?.orchestratorUrl ?? getOrchestratorUrl();
41
92
  if (!configuredBaseUrl)
42
93
  return null;
43
94
  const timeout = options?.timeoutMs ?? 5000;
95
+ const token = resolveAccessToken(options?.accessToken);
44
96
  const baseUrls = buildCandidateBaseUrls(configuredBaseUrl);
45
97
  for (const baseUrl of baseUrls) {
46
98
  try {
47
99
  const url = `${baseUrl}/draft/pages?session=${encodeURIComponent(session)}&siteId=${encodeURIComponent(siteId)}&slug=${encodeURIComponent(slug)}`;
48
- const res = await fetchWithTimeout(url, timeout);
100
+ const res = await fetchWithTimeout(url, timeout, token);
101
+ if (REFUSAL_STATUSES.has(res.status)) {
102
+ warnRefused("fetchEditorPage", res.status, Boolean(token));
103
+ return null;
104
+ }
49
105
  if (!res.ok)
50
106
  continue;
51
107
  const payload = (await res.json());
@@ -70,11 +126,16 @@ export async function fetchEditorSlugs(session, siteId, options) {
70
126
  if (!configuredBaseUrl)
71
127
  return [];
72
128
  const timeout = options?.timeoutMs ?? 5000;
129
+ const token = resolveAccessToken(options?.accessToken);
73
130
  const baseUrls = buildCandidateBaseUrls(configuredBaseUrl);
74
131
  for (const baseUrl of baseUrls) {
75
132
  try {
76
133
  const url = `${baseUrl}/draft/slugs?session=${encodeURIComponent(session)}&siteId=${encodeURIComponent(siteId)}`;
77
- const res = await fetchWithTimeout(url, timeout);
134
+ const res = await fetchWithTimeout(url, timeout, token);
135
+ if (REFUSAL_STATUSES.has(res.status)) {
136
+ warnRefused("fetchEditorSlugs", res.status, Boolean(token));
137
+ return [];
138
+ }
78
139
  if (!res.ok)
79
140
  continue;
80
141
  const payload = (await res.json());
@@ -95,11 +156,16 @@ export async function fetchEditorSiteConfig(session, siteId, options) {
95
156
  if (!configuredBaseUrl)
96
157
  return {};
97
158
  const timeout = options?.timeoutMs ?? 5000;
159
+ const token = resolveAccessToken(options?.accessToken);
98
160
  const baseUrls = buildCandidateBaseUrls(configuredBaseUrl);
99
161
  for (const baseUrl of baseUrls) {
100
162
  try {
101
163
  const url = `${baseUrl}/draft/site-config?session=${encodeURIComponent(session)}&siteId=${encodeURIComponent(siteId)}`;
102
- const res = await fetchWithTimeout(url, timeout);
164
+ const res = await fetchWithTimeout(url, timeout, token);
165
+ if (REFUSAL_STATUSES.has(res.status)) {
166
+ warnRefused("fetchEditorSiteConfig", res.status, Boolean(token));
167
+ return {};
168
+ }
103
169
  if (!res.ok)
104
170
  continue;
105
171
  const payload = (await res.json());
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
1
+ import { test, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { fetchEditorPage, fetchEditorSlugs, resetDraftFetchWarnings } from "./draft-fetch.js";
4
+ let calls = [];
5
+ let warnings = [];
6
+ const realFetch = globalThis.fetch;
7
+ const realWarn = console.warn;
8
+ /** Answer every request with `status`, recording what was asked and with what. */
9
+ function stubFetch(status, body = {}) {
10
+ globalThis.fetch = (async (input, init) => {
11
+ const headers = new Headers(init?.headers ?? {});
12
+ calls.push({ url: String(input), token: headers.get("x-access-token") });
13
+ return new Response(JSON.stringify(body), {
14
+ status,
15
+ headers: { "content-type": "application/json" }
16
+ });
17
+ });
18
+ }
19
+ beforeEach(() => {
20
+ calls = [];
21
+ warnings = [];
22
+ resetDraftFetchWarnings();
23
+ console.warn = (msg) => { warnings.push(String(msg)); };
24
+ delete process.env.ORCHESTRATOR_ACCESS_TOKEN;
25
+ });
26
+ afterEach(() => {
27
+ globalThis.fetch = realFetch;
28
+ console.warn = realWarn;
29
+ delete process.env.ORCHESTRATOR_ACCESS_TOKEN;
30
+ });
31
+ test("the configured access token travels with the request", async () => {
32
+ process.env.ORCHESTRATOR_ACCESS_TOKEN = "s3cret";
33
+ stubFetch(404);
34
+ await fetchEditorPage("/about", "dev", "site", { orchestratorUrl: "http://example.test" });
35
+ assert.equal(calls.length, 1);
36
+ assert.equal(calls[0].token, "s3cret");
37
+ });
38
+ test("an explicit token beats the environment", async () => {
39
+ process.env.ORCHESTRATOR_ACCESS_TOKEN = "from-env";
40
+ stubFetch(404);
41
+ await fetchEditorPage("/about", "dev", "site", {
42
+ orchestratorUrl: "http://example.test",
43
+ accessToken: "explicit"
44
+ });
45
+ assert.equal(calls[0].token, "explicit");
46
+ });
47
+ test("no credential configured sends no header, rather than an empty one", async () => {
48
+ stubFetch(404);
49
+ await fetchEditorPage("/about", "dev", "site", { orchestratorUrl: "http://example.test" });
50
+ assert.equal(calls[0].token, null);
51
+ });
52
+ test("a refusal stops the walk instead of retrying the same missing credential", async () => {
53
+ // `localhost` yields a second candidate (127.0.0.1); a 404 tries it, a 401
54
+ // must not — the credential that was missing is missing there too.
55
+ stubFetch(401, { error: "unauthorized" });
56
+ const page = await fetchEditorPage("/about", "dev", "site", {
57
+ orchestratorUrl: "http://localhost:4200"
58
+ });
59
+ assert.equal(page, null);
60
+ assert.equal(calls.length, 1, "a 401 must not be spent on the next candidate URL");
61
+ });
62
+ test("a refusal is reported, because otherwise the page just looks right", async () => {
63
+ stubFetch(401, { error: "unauthorized" });
64
+ await fetchEditorPage("/about", "dev", "site", { orchestratorUrl: "http://example.test" });
65
+ assert.equal(warnings.length, 1);
66
+ assert.match(warnings[0], /401/);
67
+ assert.match(warnings[0], /ORCHESTRATOR_ACCESS_TOKEN/);
68
+ assert.match(warnings[0], /published content/);
69
+ });
70
+ test("the same refusal is not reported once per rendered page", async () => {
71
+ stubFetch(401);
72
+ await fetchEditorPage("/a", "dev", "site", { orchestratorUrl: "http://example.test" });
73
+ await fetchEditorPage("/b", "dev", "site", { orchestratorUrl: "http://example.test" });
74
+ assert.equal(warnings.length, 1);
75
+ });
76
+ test("a 404 still falls through to the other candidate host", async () => {
77
+ stubFetch(404);
78
+ await fetchEditorPage("/about", "dev", "site", { orchestratorUrl: "http://localhost:4200" });
79
+ assert.equal(calls.length, 2, "the localhost/127.0.0.1 fallback must survive this change");
80
+ });
81
+ test("fetchEditorSlugs refuses the same way, and returns an empty list", async () => {
82
+ stubFetch(401);
83
+ const slugs = await fetchEditorSlugs("dev", "site", { orchestratorUrl: "http://localhost:4200" });
84
+ assert.deepEqual(slugs, []);
85
+ assert.equal(calls.length, 1);
86
+ assert.match(warnings[0], /fetchEditorSlugs/);
87
+ });
@@ -1,3 +1,15 @@
1
+ /**
2
+ * CORS for the editor API routes a host app mounts.
3
+ *
4
+ * The dev editor runs on its own origin (`localhost:4100`), so the routes have
5
+ * to answer cross-origin requests from it. That default used to be added
6
+ * unconditionally, which meant a deployed site kept answering CORS-approved
7
+ * requests to a `localhost` origin: any page a visitor had open could read that
8
+ * site's draft content out of its own browser. Now the development default is
9
+ * only assumed in development, and a deployment says what it allows.
10
+ */
1
11
  export declare function getEditorCorsOrigins(): Set<string>;
12
+ /** Test seam — the cache is a module global, and env changes must be able to take effect. */
13
+ export declare function resetEditorCorsOrigins(): void;
2
14
  export declare function applyEditorCors(response: Response, requestOrigin: string | null): Response;
3
15
  export declare function createEditorCorsOptionsHandler(): (request: Request) => Response;
@@ -1,15 +1,40 @@
1
+ /**
2
+ * CORS for the editor API routes a host app mounts.
3
+ *
4
+ * The dev editor runs on its own origin (`localhost:4100`), so the routes have
5
+ * to answer cross-origin requests from it. That default used to be added
6
+ * unconditionally, which meant a deployed site kept answering CORS-approved
7
+ * requests to a `localhost` origin: any page a visitor had open could read that
8
+ * site's draft content out of its own browser. Now the development default is
9
+ * only assumed in development, and a deployment says what it allows.
10
+ */
11
+ const DEV_EDITOR_ORIGIN = "http://localhost:4100";
1
12
  let cachedOrigins;
13
+ function parseOrigins(value) {
14
+ return (value ?? "")
15
+ .split(",")
16
+ .map((entry) => entry.trim().replace(/\/+$/, ""))
17
+ .filter(Boolean);
18
+ }
2
19
  export function getEditorCorsOrigins() {
3
20
  if (cachedOrigins)
4
21
  return cachedOrigins;
5
- const defaults = ["http://localhost:4100"];
6
- const extra = (process.env.EDITOR_CORS_ORIGINS ?? "")
7
- .split(",")
8
- .map((value) => value.trim())
9
- .filter(Boolean);
10
- cachedOrigins = new Set([...defaults, ...extra]);
22
+ const configured = parseOrigins(process.env.EDITOR_CORS_ORIGINS);
23
+ /*
24
+ * `NEXT_PUBLIC_EDITOR_ORIGIN` is what the app already tells the browser to
25
+ * talk to, so a deployment that set it has already named its editor and
26
+ * should not have to name it twice.
27
+ */
28
+ const declaredEditor = parseOrigins(process.env.NEXT_PUBLIC_EDITOR_ORIGIN);
29
+ const isProduction = process.env.NODE_ENV === "production";
30
+ const defaults = isProduction ? [] : [DEV_EDITOR_ORIGIN];
31
+ cachedOrigins = new Set([...defaults, ...declaredEditor, ...configured]);
11
32
  return cachedOrigins;
12
33
  }
34
+ /** Test seam — the cache is a module global, and env changes must be able to take effect. */
35
+ export function resetEditorCorsOrigins() {
36
+ cachedOrigins = undefined;
37
+ }
13
38
  export function applyEditorCors(response, requestOrigin) {
14
39
  const vary = response.headers.get("Vary") ?? "";
15
40
  const hasOrigin = vary.split(",").map((v) => v.trim().toLowerCase()).includes("origin");
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,66 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { applyEditorCors, getEditorCorsOrigins, resetEditorCorsOrigins } from "./editor-cors.js";
4
+ function withEnv(env, fn) {
5
+ const previous = {};
6
+ for (const [key, value] of Object.entries(env)) {
7
+ previous[key] = process.env[key];
8
+ if (value === undefined)
9
+ delete process.env[key];
10
+ else
11
+ process.env[key] = value;
12
+ }
13
+ resetEditorCorsOrigins();
14
+ try {
15
+ fn();
16
+ }
17
+ finally {
18
+ for (const [key, value] of Object.entries(previous)) {
19
+ if (value === undefined)
20
+ delete process.env[key];
21
+ else
22
+ process.env[key] = value;
23
+ }
24
+ resetEditorCorsOrigins();
25
+ }
26
+ }
27
+ const CLEAN = { EDITOR_CORS_ORIGINS: undefined, NEXT_PUBLIC_EDITOR_ORIGIN: undefined };
28
+ test("the dev editor origin is allowed in development", () => {
29
+ withEnv({ ...CLEAN, NODE_ENV: "development" }, () => {
30
+ assert.ok(getEditorCorsOrigins().has("http://localhost:4100"));
31
+ });
32
+ });
33
+ test("the dev editor origin is not allowed in production", () => {
34
+ /*
35
+ * The bug this replaces: a deployed site answered CORS-approved requests to
36
+ * localhost:4100, so any page a visitor had open could read the site's drafts
37
+ * out of that visitor's browser.
38
+ */
39
+ withEnv({ ...CLEAN, NODE_ENV: "production" }, () => {
40
+ assert.equal(getEditorCorsOrigins().has("http://localhost:4100"), false);
41
+ });
42
+ });
43
+ test("a production deployment allows exactly what it names", () => {
44
+ withEnv({ ...CLEAN, NODE_ENV: "production", EDITOR_CORS_ORIGINS: "https://editor.example.com" }, () => {
45
+ const origins = getEditorCorsOrigins();
46
+ assert.ok(origins.has("https://editor.example.com"));
47
+ assert.equal(origins.size, 1);
48
+ });
49
+ });
50
+ test("the editor origin the app already advertises does not have to be named twice", () => {
51
+ withEnv({ ...CLEAN, NODE_ENV: "production", NEXT_PUBLIC_EDITOR_ORIGIN: "https://editor.example.com/" }, () => {
52
+ // Trailing slash stripped — an Origin header never has one, so a config
53
+ // that carries one would silently match nothing.
54
+ assert.ok(getEditorCorsOrigins().has("https://editor.example.com"));
55
+ });
56
+ });
57
+ test("an allowed origin gets the CORS headers and a disallowed one does not", () => {
58
+ withEnv({ ...CLEAN, NODE_ENV: "production", EDITOR_CORS_ORIGINS: "https://editor.example.com" }, () => {
59
+ const allowed = applyEditorCors(new Response(null), "https://editor.example.com");
60
+ assert.equal(allowed.headers.get("Access-Control-Allow-Origin"), "https://editor.example.com");
61
+ const denied = applyEditorCors(new Response(null), "https://evil.example.com");
62
+ assert.equal(denied.headers.get("Access-Control-Allow-Origin"), null);
63
+ // Vary is set either way, or a cache would serve one origin's answer to another.
64
+ assert.equal(denied.headers.get("Vary"), "Origin");
65
+ });
66
+ });
@@ -1,3 +1,2 @@
1
- import { blockDefinitionSchema, blockManifestSchema, validateByJsonSchemaLike, validateManifestDefaultProps, type BlockDefinition, type BlockManifest } from "@avocadostudio-ai/shared";
2
- export { blockDefinitionSchema, blockManifestSchema, validateByJsonSchemaLike, validateManifestDefaultProps, type BlockDefinition, type BlockManifest };
3
- export declare function buildBlockManifest(): BlockManifest;
1
+ import { buildBlockManifest, blockDefinitionSchema, blockManifestSchema, validateByJsonSchemaLike, validateManifestDefaultProps, type BlockDefinition, type BlockManifest } from "@avocadostudio-ai/shared";
2
+ export { buildBlockManifest, blockDefinitionSchema, blockManifestSchema, validateByJsonSchemaLike, validateManifestDefaultProps, type BlockDefinition, type BlockManifest };