From 1da30d2aa97109b8407206d41698c2f0db716ac6 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 8 Oct 2026 15:01:37 -0300 Subject: [PATCH] feat(web): useContentBackend (probe GitHub, sandbox, deco serve) Adds `useContentBackend`, which probes GitHub, the sandbox and a connected `deco serve` and picks the backend. Carries the `site_editor_content_protocol` org flag (the same hunks as API PR #6, so the tracks merge cleanly); the flag is off by default. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .../ct/harness/stubs/use-content-backend.ts | 16 + apps/web/playwright-ct.config.ts | 7 + .../use-content-backend.test.ts | 125 +++++++ .../sections-editor/use-content-backend.ts | 313 ++++++++++++++++++ .../src/hooks/use-deco-serve-connection.ts | 40 +++ .../src/hooks/use-organization-settings.ts | 15 + apps/web/src/lib/localstorage-keys.ts | 4 + apps/web/src/lib/query-keys.ts | 15 + knip.jsonc | 6 +- packages/shared/src/organization/schema.ts | 6 + packages/shared/src/tools/tool-io.ts | 3 + 11 files changed, 549 insertions(+), 1 deletion(-) create mode 100644 apps/web/ct/harness/stubs/use-content-backend.ts create mode 100644 apps/web/src/components/sections-editor/use-content-backend.test.ts create mode 100644 apps/web/src/components/sections-editor/use-content-backend.ts create mode 100644 apps/web/src/hooks/use-deco-serve-connection.ts diff --git a/apps/web/ct/harness/stubs/use-content-backend.ts b/apps/web/ct/harness/stubs/use-content-backend.ts new file mode 100644 index 0000000000..7753ad6adb --- /dev/null +++ b/apps/web/ct/harness/stubs/use-content-backend.ts @@ -0,0 +1,16 @@ +/** + * CT stub for `sections-editor/use-content-backend`. + * + * The real hook picks a project's content backend from the org flag, the + * project's `deco serve` connection and a probe of its schema, all through + * `useProjectContext()`, which this harness doesn't mount. Field specs cover + * the editor as v7 sites see it, so every project reads as legacy here. + */ +import type { ContentBackend } from "@/components/sections-editor/content-backend"; + +export function useContentBackend( + _virtualMcpId: string | null | undefined, + _branch: string | null | undefined, +): ContentBackend { + return { kind: "legacy" }; +} diff --git a/apps/web/playwright-ct.config.ts b/apps/web/playwright-ct.config.ts index a43a6d06d5..4a191216eb 100644 --- a/apps/web/playwright-ct.config.ts +++ b/apps/web/playwright-ct.config.ts @@ -80,6 +80,13 @@ export default defineConfig({ find: /^@\/hooks\/use-new-blocks-editor$/, replacement: stub("use-new-blocks-editor.ts"), }, + // The content backend reads the org flag and project via + // useProjectContext(); fields are tested as v7 (legacy) sites see them. + // Matches the fields' relative `../use-content-backend` import too. + { + find: /^.*\/use-content-backend$/, + replacement: stub("use-content-backend.ts"), + }, // Editor uploads PUT to the org filesystem via useProjectContext(), which a bare mount lacks. { find: /^@\/components\/markdown-editor\/use-file-upload$/, diff --git a/apps/web/src/components/sections-editor/use-content-backend.test.ts b/apps/web/src/components/sections-editor/use-content-backend.test.ts new file mode 100644 index 0000000000..5b4163b905 --- /dev/null +++ b/apps/web/src/components/sections-editor/use-content-backend.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test"; +import { + type BatchCall, + type BatchOutcome, + type ContentClient, + PROTOCOL_NAME, + PROTOCOL_VERSION, +} from "@decocms/blocks/protocol"; +import { selectContentBackend } from "./content-backend"; +import { probe, probeSandbox } from "./use-content-backend"; + +const deco1 = { + manifest: { blocks: {} }, + schema: { definitions: {}, root: {} }, +}; + +/** The GitHub backend answering `describe` + `schema.get` with `schema`. */ +function githubServing(schema: unknown): ContentClient { + return { + batch: async (calls: BatchCall[]) => + calls.map( + (call): BatchOutcome => + call.method === "describe" + ? { + ok: true, + result: { protocol: PROTOCOL_NAME, version: PROTOCOL_VERSION }, + } + : { + ok: true, + result: { + notModified: false, + version: "s1", + resolvedRef: "main", + schema, + }, + }, + ), + } as unknown as ContentClient; +} + +/** What `useContentBackend` decides for a flag-on Fast Preview session. */ +async function decideWithFlagOn(schema: unknown) { + const probed = await probe(githubServing(schema)); + return selectContentBackend({ + hasProject: true, + flagEnabled: true, + hasServeConnection: false, + hasLocalTunnel: false, + runtime: "cms", + githubSite: probed.v8Schema ? "v8" : "v7", + }); +} + +describe("v7/v8 detection over the GitHub backend", () => { + test('a schema with "blocksMajor": 8 is a v8 site', async () => { + expect(await decideWithFlagOn({ major: 1, blocksMajor: 8, ...deco1 })).toBe( + "protocol-github", + ); + }); + + // The server serves `schema.gen.json`, else a v7 `meta.gen.json`, as + // written: the file name never makes a site v8, only the field does. + test("a committed schema without blocksMajor stays v7 with the flag on", async () => { + // A v7 `meta.gen.json`, and a `schema.gen.json` from an older `deco schema`. + expect(await decideWithFlagOn(deco1)).toBe("legacy"); + expect(await decideWithFlagOn({ major: 1, ...deco1 })).toBe("legacy"); + }); + + test("any other blocksMajor stays v7", async () => { + for (const blocksMajor of [7, "8", null, 9]) { + expect(await decideWithFlagOn({ major: 1, blocksMajor, ...deco1 })).toBe( + "legacy", + ); + } + }); + + test("no committed schema stays v7", async () => { + expect(await decideWithFlagOn(null)).toBe("legacy"); + }); +}); + +describe("v7/v8 detection over a sandbox's daemon", () => { + async function decide(schema: unknown) { + const probed = await probe(githubServing(schema)); + return selectContentBackend({ + hasProject: true, + flagEnabled: true, + hasServeConnection: false, + hasLocalTunnel: false, + runtime: "sandbox", + githubSite: "loading", + sandboxSite: probed.v8Schema ? "v8" : "v7", + }); + } + + test('a working tree whose schema says "blocksMajor": 8 is a v8 site', async () => { + expect(await decide({ major: 1, blocksMajor: 8, ...deco1 })).toBe( + "protocol-sandbox", + ); + }); + + test("a v7 working tree (or none) stays legacy", async () => { + expect(await decide({ major: 1, ...deco1 })).toBe("legacy"); + expect(await decide(null)).toBe("legacy"); + }); +}); + +describe("sandbox probe", () => { + const endpoint = "http://studio.test/rpc"; + + test("an older daemon answering 404 is v7, not an error", async () => { + expect( + await probeSandbox( + endpoint, + async () => new Response("404 page not found", { status: 404 }), + ), + ).toBe(null); + }); + + test("any other failure is an error (retried)", async () => { + await expect( + probeSandbox(endpoint, async () => new Response("boom", { status: 502 })), + ).rejects.toThrow(); + }); +}); diff --git a/apps/web/src/components/sections-editor/use-content-backend.ts b/apps/web/src/components/sections-editor/use-content-backend.ts new file mode 100644 index 0000000000..8811d57b9d --- /dev/null +++ b/apps/web/src/components/sections-editor/use-content-backend.ts @@ -0,0 +1,313 @@ +import { useQuery } from "@tanstack/react-query"; +import { + assertSupportedEndpoint, + type ContentClient, + ContentProtocolError, + createContentClient, + type DescribeResult, + PROTOCOL_NAME, +} from "@decocms/blocks/protocol"; +import { useProjectContext } from "@/sdk"; +import { buildSandboxUrl } from "@/sdk/sandbox-url"; +import { useOptionalChatTask } from "@/components/chat/chat-context"; +import { isWorkingTreeReadyPhase } from "@decocms/sandbox/shared"; +import { useSandboxEvents } from "@/components/sandbox/hooks/use-sandbox-events"; +import { useDecoServeConnection } from "@/hooks/use-deco-serve-connection"; +import { useLocalPreviewUrl } from "@/hooks/use-local-preview-url"; +import { useOrgFlagState } from "@/hooks/use-organization-settings"; +import { useSessionRuntime } from "@/hooks/use-session-runtime"; +import { KEYS } from "@/lib/query-keys"; +import { + type ContentBackend, + isV8Schema, + type ProtocolBackend, + selectContentBackend, +} from "./content-backend"; +import { + classifyServeProbeError, + NotDecoServeError, + probeRetryDelay, +} from "./deco-serve-connection"; +import { isSchemaAbsent } from "./schemaless"; + +export interface Probe { + client: ContentClient; + describe: DescribeResult; + /** Whether the endpoint has a schema (see `isSchemaAbsent`). */ + hasSchema: boolean; + /** Whether that schema declares `"blocksMajor": 8` (see `isV8Schema`). */ + v8Schema: boolean; +} + +/** The sandbox answered 404 on `/_sandbox/rpc`: its daemon predates it. */ +class NoSandboxProtocolError extends Error {} + +/** How often a failed probe is retried, so a backend recovers on its own. */ +const PROBE_RETRY_MS = 5_000; + +/** The Studio GitHub backend's endpoint for one project and branch. */ +function githubContentEndpoint(params: { + orgSlug: string; + virtualMcpId: string; + branch: string; +}): string { + return new URL( + `/api/${params.orgSlug}/decofile/${encodeURIComponent(params.virtualMcpId)}/${encodeURIComponent(params.branch)}/rpc`, + window.location.origin, + ).href; +} + +/** + * A sandbox daemon's content protocol, through Studio's sandbox proxy (the + * same session auth and claim as the sandbox's other routes). + */ +function sandboxContentEndpoint(params: { + orgSlug: string; + virtualMcpId: string; + branch: string; + threadId: string | null; +}): string { + return new URL(buildSandboxUrl(params, "rpc"), window.location.origin).href; +} + +/** + * `describe` plus the schema, in one request. Throws `NotDecoServeError` when + * something else answered (see `classifyServeProbeError`). + */ +export async function probe(client: ContentClient): Promise { + const [described, schema] = await client.batch([ + { method: "describe" }, + { method: "schema.get" }, + ]); + if (!described?.ok) throw described?.error ?? new Error("no describe"); + const result = described.result as Partial | null; + if (result?.protocol !== PROTOCOL_NAME) throw new NotDecoServeError(); + const describe = assertSupportedEndpoint(described.result as DescribeResult); + if (!schema) throw new Error("no schema.get"); + // `schema: null`, or NotFound from an older deco serve: no schema yet. + if (isSchemaAbsent(schema)) { + return { client, describe, hasSchema: false, v8Schema: false }; + } + if (schema.ok) { + const result = schema.result as { schema?: unknown } | null; + return { + client, + describe, + hasSchema: true, + v8Schema: isV8Schema(result?.schema), + }; + } + throw schema.error; +} + +/** + * Probes a sandbox daemon's content protocol: the probe when its working + * tree's schema is a v8 one, else null. As on GitHub, only a + * `"blocksMajor": 8` schema is a v8 site. A daemon without the protocol (an + * older image) answers 404: v7 (null), not an error to poll. + */ +export async function probeSandbox( + endpoint: string, + doFetch: (request: Request) => Promise = (request) => + fetch(request), +): Promise { + const client = createContentClient({ + endpoint, + fetch: async (request) => { + const response = await doFetch(request); + if (response.status === 404) throw new NoSandboxProtocolError(); + return response; + }, + }); + try { + const result = await probe(client); + return result.v8Schema ? result : null; + } catch (error) { + if (error instanceof NoSandboxProtocolError) return null; + throw error; + } +} + +/** + * The content backend of a project's editor (see `selectContentBackend`). + * `pending` while the org flag or the backend probe loads: callers hold their + * reads until then, so a project never reads from one backend and then + * switches to another. + */ +export function useContentBackend( + virtualMcpId: string | null | undefined, + branch: string | null | undefined, +): ContentBackend { + const { org } = useProjectContext(); + const flagEnabled = useOrgFlagState("site_editor_content_protocol"); + const { connection } = useDecoServeConnection(virtualMcpId); + const { url: tunnel } = useLocalPreviewUrl(virtualMcpId); + const { runtime } = useSessionRuntime(virtualMcpId); + const threadId = useOptionalChatTask()?.taskId ?? null; + // The daemon answers once the working tree is there; before that the + // sandbox is booting and the editor keeps today's (legacy) boot UX. + const workingTreeReady = isWorkingTreeReadyPhase( + useSandboxEvents().lifecycle.phase, + ); + + const githubEnabled = + !!org.slug && + !!flagEnabled && + !connection && + !tunnel && + runtime === "cms" && + !!virtualMcpId && + !!branch; + const github = useQuery({ + queryKey: KEYS.contentBackend( + org.slug, + virtualMcpId ?? "", + branch ?? "", + "github", + ), + queryFn: async (): Promise => { + const params = { + orgSlug: org.slug, + virtualMcpId: virtualMcpId!, + branch: branch!, + }; + const client = createContentClient({ + endpoint: githubContentEndpoint(params), + }); + const result = await probe(client); + // Only a committed schema with `"blocksMajor": 8` is a v8 site; no + // schema, or one without the field (a v7 `meta.gen.json`), is legacy. + return result.v8Schema ? result : null; + }, + enabled: githubEnabled, + staleTime: Number.POSITIVE_INFINITY, + retry: 2, + // A failed probe reads as v7 (legacy) and is retried quietly. A site + // already known to be v8 keeps its data through a failed refetch. + refetchInterval: (query) => + query.state.status === "error" ? PROBE_RETRY_MS : false, + }); + + const sandboxEnabled = + !!org.slug && + !!flagEnabled && + !connection && + !tunnel && + runtime === "sandbox" && + workingTreeReady && + !!virtualMcpId && + !!branch; + const sandbox = useQuery({ + queryKey: KEYS.contentBackend( + org.slug, + virtualMcpId ?? "", + branch ?? "", + "sandbox", + threadId ?? "", + ), + queryFn: async (): Promise => { + return probeSandbox( + sandboxContentEndpoint({ + orgSlug: org.slug, + virtualMcpId: virtualMcpId!, + branch: branch!, + threadId, + }), + ); + }, + enabled: sandboxEnabled, + staleTime: Number.POSITIVE_INFINITY, + retry: 2, + refetchInterval: (query) => + query.state.status === "error" ? PROBE_RETRY_MS : false, + }); + + // No flag: a connection exists only once its link was pasted or opened. + const localEnabled = !!connection; + const local = useQuery({ + queryKey: KEYS.contentBackend( + org.slug, + virtualMcpId ?? "", + "local", + connection?.endpoint ?? "", + ), + queryFn: () => + probe(createContentClient({ endpoint: connection!.endpoint })), + enabled: localEnabled, + staleTime: Number.POSITIVE_INFINITY, + // Waits for `deco serve` to (re)start and reconnects on its own, backing + // off up to 10s. A server that answers with an error (say, this origin + // isn't allowed) is polled instead. A failed read or write resets this + // probe (see content-protocol-api). + retry: (_failures, error) => + !(error instanceof ContentProtocolError) && + !(error instanceof NotDecoServeError), + retryDelay: (failures) => probeRetryDelay(failures + 1), + // Also re-probed while there is no schema, so `deco schema` is picked up + // on its own. + refetchInterval: (query) => + query.state.status === "error" || query.state.data?.hasSchema === false + ? PROBE_RETRY_MS + : false, + }); + + const decision = selectContentBackend({ + hasProject: !!virtualMcpId, + flagEnabled, + hasServeConnection: !!connection, + hasLocalTunnel: !!tunnel, + runtime, + githubSite: github.data + ? "v8" + : github.data === null + ? "v7" + : github.isError + ? "error" + : "loading", + sandboxSite: !workingTreeReady + ? "unavailable" + : sandbox.data + ? "v8" + : sandbox.data === null + ? "v7" + : sandbox.isError + ? "error" + : "loading", + }); + + if (decision === "pending") return { kind: "pending" }; + if (decision === "legacy") return { kind: "legacy" }; + if (decision === "protocol-github") { + return toBackend("github", github.data!, ""); + } + if (decision === "protocol-sandbox") { + return toBackend("sandbox", sandbox.data!, ":sandbox"); + } + if (local.data) { + return toBackend("local", local.data, `:serve:${connection!.endpoint}`); + } + // Still retrying after a failure: `deco serve` is down or restarting. + if (local.isError || local.failureCount > 0) { + return { + kind: "unavailable", + source: "local", + problem: classifyServeProbeError(local.error ?? local.failureReason), + }; + } + return { kind: "pending" }; +} + +function toBackend( + source: ProtocolBackend["source"], + probed: Probe, + cacheKeySuffix: string, +): ProtocolBackend { + return { + kind: "protocol", + source, + client: probed.client, + describe: probed.describe, + cacheKeySuffix, + hasSchema: probed.hasSchema, + }; +} diff --git a/apps/web/src/hooks/use-deco-serve-connection.ts b/apps/web/src/hooks/use-deco-serve-connection.ts new file mode 100644 index 0000000000..3b7397520f --- /dev/null +++ b/apps/web/src/hooks/use-deco-serve-connection.ts @@ -0,0 +1,40 @@ +import { createContext, useContext } from "react"; +import { + type DecoServeConnection, + parseStoredConnection, +} from "@/components/sections-editor/deco-serve-connection"; +import { useLocalStorage } from "@/hooks/use-local-storage"; +import { LOCALSTORAGE_KEYS } from "@/lib/localstorage-keys"; + +export interface DecoServeConnectionState { + /** The project's connected `deco serve`, or `null`. */ + connection: DecoServeConnection | null; + set: (next: DecoServeConnection) => void; + clear: () => void; +} + +/** + * `/site-editor`'s connection, which belongs to no project. + * Inside it, every editor surface reads this one instead of a project's. + */ +export const TabDecoServeConnectionContext = + createContext(null); + +/** + * The `deco serve` this browser connected a project to. Per-browser and + * per-project, like the Local tunnel URL: it points at a server only this + * machine can reach, so it never belongs on shared project metadata. + */ +export function useDecoServeConnection( + virtualMcpId: string | null | undefined, +): DecoServeConnectionState { + const tab = useContext(TabDecoServeConnectionContext); + const key = LOCALSTORAGE_KEYS.decoServeConnection(virtualMcpId ?? ""); + const [stored, setStored] = useLocalStorage(key, null); + if (tab) return tab; + return { + connection: virtualMcpId ? parseStoredConnection(stored) : null, + set: (next) => setStored(next), + clear: () => setStored(null), + }; +} diff --git a/apps/web/src/hooks/use-organization-settings.ts b/apps/web/src/hooks/use-organization-settings.ts index b1b783362c..ec67316f62 100644 --- a/apps/web/src/hooks/use-organization-settings.ts +++ b/apps/web/src/hooks/use-organization-settings.ts @@ -111,6 +111,8 @@ function useOrganizationSettings( return useQuery({ ...organizationSettingsQueryOptions(org.slug, org.id), select: select as (data: OrganizationSettings) => T, + // No org (the account-less `/site-editor`): flags keep their defaults. + enabled: !!org.id, }); } @@ -233,6 +235,19 @@ export function useOrgFlag(flag: keyof OrgFlags): boolean { return data ?? DEFAULT_ON_FLAGS.has(flag); } +/** + * {@link useOrgFlag}, but `undefined` while the org settings load — for + * gates that must not act on the pre-load default (routing reads to one + * backend, then switching them to another). + */ +export function useOrgFlagState(flag: keyof OrgFlags): boolean | undefined { + const { data, isError } = useOrganizationSettings((s) => + orgFlagEnabled(s.flags, flag), + ); + // A failed read falls back to the default rather than waiting forever. + return data ?? (isError ? DEFAULT_ON_FLAGS.has(flag) : undefined); +} + /** * Writer for the org's blocks editor. Not `useSetOrgFlag`: that goes through * ORGANIZATION_SETTINGS_UPDATE (org:manage), while any member may switch the diff --git a/apps/web/src/lib/localstorage-keys.ts b/apps/web/src/lib/localstorage-keys.ts index 1fb3bfd00a..2cd4158cb7 100644 --- a/apps/web/src/lib/localstorage-keys.ts +++ b/apps/web/src/lib/localstorage-keys.ts @@ -58,4 +58,8 @@ export const LOCALSTORAGE_KEYS = { * project metadata. */ localPreviewUrl: (virtualMcpId: string) => `studio:local-preview-url:${virtualMcpId}`, + /** Per-project `deco serve` connection (its endpoint), per-browser for + * the same reason as the tunnel URL. */ + decoServeConnection: (virtualMcpId: string) => + `studio:deco-serve-connection:${virtualMcpId}`, } as const; diff --git a/apps/web/src/lib/query-keys.ts b/apps/web/src/lib/query-keys.ts index 08df65010e..1578719b1b 100644 --- a/apps/web/src/lib/query-keys.ts +++ b/apps/web/src/lib/query-keys.ts @@ -754,6 +754,21 @@ export const KEYS = { // Variadic so an invalidation call can pass just the org/vmid/branch prefix // and still partial-match the full org/vmid/branch/previewUrl query key. liveMeta: (...parts: string[]) => ["live-meta", ...parts] as const, + // Which content backend (legacy / content protocol) a project's editor uses. + contentBackend: (...parts: string[]) => + ["content-backend", ...parts] as const, + // Last content-protocol blocks revision and schema version a project saw. + contentRevision: (cacheKey: string) => + ["content-revision", cacheKey] as const, + // A hosted v8 project's draft pointer ({ draft, version }), from the + // session decofile read (GitHub backend). + protocolDraftRead: (orgSlug: string, virtualMcpId: string, branch: string) => + ["protocol-draft-read", orgSlug, virtualMcpId, branch] as const, + // Hosted Deco CMS (v8): a project's releases and site tokens. + hostedReleases: (orgSlug: string, virtualMcpId: string) => + ["hosted-releases", orgSlug, virtualMcpId] as const, + hostedSiteTokens: (orgSlug: string, virtualMcpId: string) => + ["hosted-site-tokens", orgSlug, virtualMcpId] as const, sandboxInvoke: (sandboxKey: string, loaderKey: string) => ["sandbox-invoke", sandboxKey, loaderKey] as const, // `threadId` is part of the key because it is part of the URL: two sessions diff --git a/knip.jsonc b/knip.jsonc index 75a902914c..5e67d42b95 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -81,6 +81,10 @@ // consumer in a later PR of the stack, which removes its entry. "apps/web/src/components/sections-editor/deco-serve-connection.ts": [ "exports" - ] + ], + "apps/web/src/components/sections-editor/use-content-backend.ts": [ + "exports" + ], + "apps/web/src/hooks/use-deco-serve-connection.ts": ["exports"] } } diff --git a/packages/shared/src/organization/schema.ts b/packages/shared/src/organization/schema.ts index 1403d0f3dc..6deb5b8c75 100644 --- a/packages/shared/src/organization/schema.ts +++ b/packages/shared/src/organization/schema.ts @@ -277,6 +277,12 @@ export const OrgFlagsSchema = z.object({ .describe( "Use the redesigned blocks editor for every member of the organization. Off by default — the classic editor stays until an admin opts the org in.", ), + site_editor_content_protocol: z + .boolean() + .optional() + .describe( + "Edit next-major Blocks sites through the content protocol: a project with a committed schema (`.deco/schema.gen.json` or `meta.gen.json`) is edited without running its code, on GitHub or through a connected `deco serve`. Off by default — with it off, every project stays on the legacy editing path.", + ), hide_default_blog_blocks: z .boolean() .optional() diff --git a/packages/shared/src/tools/tool-io.ts b/packages/shared/src/tools/tool-io.ts index 846ea08606..089219a7fa 100644 --- a/packages/shared/src/tools/tool-io.ts +++ b/packages/shared/src/tools/tool-io.ts @@ -133,6 +133,7 @@ export interface StudioToolIO { site_create_enabled?: boolean | undefined; delivery_lanes_enabled?: boolean | undefined; new_blocks_editor?: boolean | undefined; + site_editor_content_protocol?: boolean | undefined; hide_default_blog_blocks?: boolean | undefined; } | null @@ -212,6 +213,7 @@ export interface StudioToolIO { site_create_enabled?: boolean | undefined; delivery_lanes_enabled?: boolean | undefined; new_blocks_editor?: boolean | undefined; + site_editor_content_protocol?: boolean | undefined; hide_default_blog_blocks?: boolean | undefined; } | undefined; @@ -287,6 +289,7 @@ export interface StudioToolIO { site_create_enabled?: boolean | undefined; delivery_lanes_enabled?: boolean | undefined; new_blocks_editor?: boolean | undefined; + site_editor_content_protocol?: boolean | undefined; hide_default_blog_blocks?: boolean | undefined; } | null