Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
76f8224
fix(content): fence Builder pagination refreshes
3mdistal Jul 24, 2026
36700f8
chore(content): redeploy Builder relay preview
3mdistal Jul 24, 2026
4094673
docs(content): clarify Builder refresh fix
3mdistal Jul 24, 2026
cdbb2ab
fix(core): route Builder relay through immutable previews
3mdistal Jul 24, 2026
d75c438
fix(core): derive Netlify relay target at build time
3mdistal Jul 24, 2026
da9e6ec
fix(core): bind Builder relay opener to preview site
3mdistal Jul 24, 2026
ba98303
fix(content): embed Builder relay deploy id
3mdistal Jul 24, 2026
93ca4bf
fix(content): bound Builder review body loading
3mdistal Jul 24, 2026
924caba
fix(content): prioritize new Builder drafts in review
3mdistal Jul 24, 2026
de93115
fix(content): persist first collaborative edits
3mdistal Jul 24, 2026
4ed5c4d
fix(content): scope Builder execution snapshots
3mdistal Jul 24, 2026
4fa62fd
fix(content): preserve Builder field bindings on refresh
3mdistal Jul 24, 2026
6436610
fix(content): distinguish Builder prepare from send
3mdistal Jul 24, 2026
bf47b90
fix(content): keep multi-word slash commands open
3mdistal Jul 24, 2026
e517395
fix(content): activate slash commands on click
3mdistal Jul 24, 2026
27a12f1
fix(content): handle slash command pointer release
3mdistal Jul 24, 2026
e51fdc1
revert(content): keep native slash command click semantics
3mdistal Jul 24, 2026
cc88aa7
chore(content): redeploy BuilderSync QA preview
3mdistal Jul 24, 2026
fe9e04d
fix(content): recover omitted slash command clicks
3mdistal Jul 24, 2026
5357c2e
fix(content): defer slash command execution
3mdistal Jul 24, 2026
2706b53
fix(content): keep deferred slash choice alive
3mdistal Jul 24, 2026
71c6eef
fix(content): execute slash choices before unmount
3mdistal Jul 24, 2026
4a3e4b2
fix(content): stabilize collaborative code blocks
3mdistal Jul 24, 2026
caedc1e
fix(content): make code block slash insertion atomic
3mdistal Jul 24, 2026
52e7463
fix(content): keep slash menu available during reconciliation
3mdistal Jul 24, 2026
5d30561
fix(content): clamp stale collaborative placeholder positions
3mdistal Jul 24, 2026
8495152
fix(content): retry transient Builder pagination
3mdistal Jul 24, 2026
c653a1d
fix(content): reconcile Builder write controls
3mdistal Jul 24, 2026
86107f2
fix(content): optimistically update Builder policy
3mdistal Jul 24, 2026
f34dccc
fix(content): update Builder policy in click transaction
3mdistal Jul 24, 2026
d6122fc
fix(content): preserve media draft sources
3mdistal Jul 24, 2026
d40af59
fix(content): serialize collaborative document saves
3mdistal Jul 24, 2026
ae9c947
fix(content): persist structural slash commands
3mdistal Jul 24, 2026
7a11944
fix(content): guard structural media drafts
3mdistal Jul 24, 2026
9f4e8fa
fix(content): persist committed media sources
3mdistal Jul 24, 2026
9716070
fix(content): persist rich drafts after metadata updates
3mdistal Jul 24, 2026
bb920f0
fix(content): scope Builder review snapshots
3mdistal Jul 24, 2026
dd39ec6
fix(content): convert callout editor sentinels
3mdistal Jul 24, 2026
70793ca
fix(content): republish approved Builder updates
3mdistal Jul 24, 2026
54fb9c8
fix(content): persist native date selections
3mdistal Jul 24, 2026
836de94
fix(content): unlock local Builder draft bodies
3mdistal Jul 24, 2026
3f2d2d6
fix(content): preserve video source panel
3mdistal Jul 25, 2026
6e33d08
fix(content): open video links on insertion
3mdistal Jul 25, 2026
e3c30fd
fix(content): preserve video link selection
3mdistal Jul 25, 2026
47e2b5c
fix(content): stabilize video source tabs
3mdistal Jul 25, 2026
89213a6
fix(content): route selected text to link editor
3mdistal Jul 25, 2026
ccafcce
fix(content): keep link editor visible
3mdistal Jul 25, 2026
6bd026c
fix(content): expose link controls immediately
3mdistal Jul 25, 2026
fc7c681
fix(content): stabilize contextual toolbar actions
3mdistal Jul 25, 2026
280c950
fix(content): execute exact slash commands on enter
3mdistal Jul 25, 2026
4cad8f2
fix(content): reject unfocused collaborative autosaves
3mdistal Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-pages-persist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/toolkit": patch
---

Allow the first local edit in a newly synced empty collaborative document to reach the host application's canonical save path.
5 changes: 5 additions & 0 deletions .changeset/quiet-owls-relay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

Route Builder authorization callbacks for Netlify previews through the immutable deployment URL while returning popup status to the visible preview.
8 changes: 8 additions & 0 deletions packages/core/src/deploy/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3705,6 +3705,14 @@ export default bundle;
"virtual:agents-bundle": agentsBundleModuleSource,
},
replace: {
// Netlify exposes DEPLOY_ID only while building. Embed it into the Nitro
// function so preview OAuth relays can target this immutable deployment
// even though the value is unavailable in the function runtime.
"process.env.AGENT_NATIVE_BUILD_ID": JSON.stringify(
process.env.DEPLOY_ID?.trim() ||
process.env.AGENT_NATIVE_BUILD_ID?.trim() ||
"",
),
"process.env.AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID": JSON.stringify(
process.env.GA_MEASUREMENT_ID?.trim() || "",
),
Expand Down
70 changes: 70 additions & 0 deletions packages/core/src/server/builder-browser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
getBuilderBrowserStatusForEvent,
isBuilderBranchingEnabled,
resolveBuilderCallbackReturnUrl,
resolveBuilderPreviewRelayParentOrigin,
resolveBuilderPreviewRelayTargetOrigin,
runBuilderAgent,
signBuilderConnectToken,
signBuilderCallbackState,
Expand Down Expand Up @@ -585,6 +587,74 @@ describe("Builder callback CSRF state", () => {
).toBeNull();
});

it("uses the immutable Netlify deploy URL as the relay destination", () => {
process.env.AGENT_NATIVE_BUILD_ID = "6a62ed72f518f00008436fa3";
process.env.SITE_NAME = "agent-native-content";

expect(
resolveBuilderPreviewRelayTargetOrigin(
"https://deploy-preview-2382--agent-native-content.netlify.app",
),
).toBe(
"https://6a62ed72f518f00008436fa3--agent-native-content.netlify.app",
);
});

it("rejects an immutable Netlify deploy URL for a different site", () => {
process.env.AGENT_NATIVE_BUILD_ID = "6a62ed72f518f00008436fa3";
process.env.SITE_NAME = "different-site";
const previewOrigin =
"https://deploy-preview-2382--agent-native-content.netlify.app";

expect(resolveBuilderPreviewRelayTargetOrigin(previewOrigin)).toBe(
previewOrigin,
);
});

it("leaves non-Netlify preview origins unchanged", () => {
process.env.AGENT_NATIVE_BUILD_ID = "6a62ed72f518f00008436fa3";
process.env.SITE_NAME = "agent-native-content";
const previewOrigin = "https://preview-example.builderio.xyz";

expect(resolveBuilderPreviewRelayTargetOrigin(previewOrigin)).toBe(
previewOrigin,
);
});

it("keeps the visible preview opener separate from the immutable relay target", () => {
expect(
resolveBuilderPreviewRelayParentOrigin({
openerOrigin:
"https://deploy-preview-2382--agent-native-content.netlify.app",
targetOrigin:
"https://6a62ed72f518f00008436fa3--agent-native-content.netlify.app",
}),
).toBe("https://deploy-preview-2382--agent-native-content.netlify.app");
});

it("falls back to the signed relay target for an unsafe opener", () => {
const targetOrigin =
"https://6a62ed72f518f00008436fa3--agent-native-content.netlify.app";
expect(
resolveBuilderPreviewRelayParentOrigin({
openerOrigin: "https://attacker.example",
targetOrigin,
}),
).toBe(targetOrigin);
});

it("rejects an unsigned Netlify opener for a different site", () => {
const targetOrigin =
"https://6a62ed72f518f00008436fa3--agent-native-content.netlify.app";
expect(
resolveBuilderPreviewRelayParentOrigin({
openerOrigin:
"https://deploy-preview-2382--attacker-site.netlify.app",
targetOrigin,
}),
).toBe(targetOrigin);
});

it("returns users to the preview opener after a gateway callback", () => {
process.env.NODE_ENV = "production";
process.env.AGENT_NATIVE_WORKSPACE = "1";
Expand Down
76 changes: 76 additions & 0 deletions packages/core/src/server/builder-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ const BUILDER_RELAY_PURPOSE = "builder-preview-callback-relay";
const BUILDER_RELAY_STATE_VERSION = 1;
const BUILDER_RELAY_TTL_MS = 10 * 60 * 1000;
const BUILDER_RELAY_REQUEST_SKEW_MS = 2 * 60 * 1000;
const IMMUTABLE_NETLIFY_RELAY_HOST =
/^(?<deploy>[a-f0-9]{24})--(?<site>[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.netlify\.app$/;
const NETLIFY_DEPLOY_PREVIEW_HOST =
/^deploy-preview-\d+--(?<site>[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.netlify\.app$/;

export interface BuilderPreviewRelayState {
v: 1;
Expand Down Expand Up @@ -165,6 +169,45 @@ export function isTrustedBuilderRelayTargetOrigin(value: string): boolean {
)
);
}

/**
* Netlify's deploy-preview alias is convenient for people but mutable, so it
* must never be the signed relay destination. The deploy builder embeds
* Netlify's DEPLOY_ID into the Nitro server bundle, while SITE_NAME remains
* available to Functions at runtime. Use that pair only when it
* identifies the same site as the visible preview alias; otherwise preserve
* the visible origin so callback validation fails closed.
*/
export function resolveBuilderPreviewRelayTargetOrigin(
previewOrigin: string,
): string {
let previewUrl: URL;
try {
previewUrl = new URL(previewOrigin);
} catch {
return previewOrigin;
}
const previewMatch = NETLIFY_DEPLOY_PREVIEW_HOST.exec(
previewUrl.hostname.toLowerCase(),
);
if (!previewMatch?.groups?.site) return previewOrigin;

const buildId = process.env.AGENT_NATIVE_BUILD_ID?.trim().toLowerCase();
const siteName = process.env.SITE_NAME?.trim().toLowerCase();
if (
!buildId ||
!siteName ||
!/^[a-f0-9]{24}$/.test(buildId) ||
siteName !== previewMatch.groups.site
) {
return previewOrigin;
}

const immutableOrigin = `https://${buildId}--${siteName}.netlify.app`;
return IMMUTABLE_NETLIFY_RELAY_HOST.test(new URL(immutableOrigin).hostname)
? immutableOrigin
: previewOrigin;
}
export function signBuilderPreviewRelayState(input: {
ownerEmail: string;
targetOrigin: string;
Expand Down Expand Up @@ -1228,6 +1271,39 @@ export function resolveSafePreviewUrl(
return getBuilderBrowserOriginForEvent(event);
}

export function resolveBuilderPreviewRelayParentOrigin(options: {
openerOrigin?: string | null;
targetOrigin: string;
}): string {
if (!options.openerOrigin) return options.targetOrigin;
let openerUrl: URL;
let targetUrl: URL;
try {
openerUrl = new URL(options.openerOrigin);
targetUrl = new URL(options.targetOrigin);
} catch {
return options.targetOrigin;
}
if (
openerUrl.origin !== options.openerOrigin ||
!isSafeBuilderRelayTargetOrigin(openerUrl.origin)
) {
return options.targetOrigin;
}
if (openerUrl.origin === targetUrl.origin) return openerUrl.origin;

const openerMatch = NETLIFY_DEPLOY_PREVIEW_HOST.exec(
openerUrl.hostname.toLowerCase(),
);
const targetMatch = IMMUTABLE_NETLIFY_RELAY_HOST.exec(
targetUrl.hostname.toLowerCase(),
);
return openerMatch?.groups?.site &&
openerMatch.groups.site === targetMatch?.groups?.site
? openerUrl.origin
: options.targetOrigin;
}

export function resolveBuilderCallbackReturnUrl(options: {
event: H3Event;
openerOrigin?: string | null;
Expand Down
20 changes: 15 additions & 5 deletions packages/core/src/server/core-routes-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ import {
resolveBuilderCallbackReturnUrl,
getBuilderBrowserStatusForEvent,
resolveBuilderBranchProjectId,
resolveBuilderPreviewRelayParentOrigin,
resolveBuilderPreviewRelayTargetOrigin,
resolveSafePreviewUrl,
runBuilderAgent,
signBuilderCallbackState,
Expand Down Expand Up @@ -2049,7 +2051,8 @@ export function createCoreRoutesPlugin(
try {
relay = signBuilderPreviewRelayState({
ownerEmail,
targetOrigin: previewOrigin,
targetOrigin:
resolveBuilderPreviewRelayTargetOrigin(previewOrigin),
basePath: getAppBasePath(),
});
} catch (err) {
Expand Down Expand Up @@ -2422,6 +2425,13 @@ export function createCoreRoutesPlugin(
);
}

const relayOpenerOrigin =
requestUrl.searchParams.get(BUILDER_OPENER_PARAM);
const relayParentOrigin = resolveBuilderPreviewRelayParentOrigin({
openerOrigin: relayOpenerOrigin,
targetOrigin: relayPayload.targetOrigin,
});

const privateKey = requestUrl.searchParams.get("p-key");
const publicKey = requestUrl.searchParams.get("api-key");
if (!privateKey || !publicKey) {
Expand All @@ -2433,7 +2443,7 @@ export function createCoreRoutesPlugin(
);
return createBuilderBrowserCallbackErrorPage(
"Builder didn't return credentials. Restart the connect flow from settings.",
{ parentOrigin: relayPayload.targetOrigin },
{ parentOrigin: relayParentOrigin },
);
}

Expand Down Expand Up @@ -2489,7 +2499,7 @@ export function createCoreRoutesPlugin(
"text/html; charset=utf-8",
);
return createBuilderBrowserCallbackErrorPage(message, {
parentOrigin: relayPayload.targetOrigin,
parentOrigin: relayParentOrigin,
});
}

Expand All @@ -2499,8 +2509,8 @@ export function createCoreRoutesPlugin(
"text/html; charset=utf-8",
);
return createBuilderBrowserCallbackPage(
`${relayPayload.targetOrigin}${relayPayload.basePath || "/"}`,
{ parentOrigin: relayPayload.targetOrigin },
`${relayParentOrigin}${relayPayload.basePath || "/"}`,
{ parentOrigin: relayParentOrigin },
);
}

Expand Down
77 changes: 77 additions & 0 deletions packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEditor, type Editor } from "@tiptap/react";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import * as Y from "yjs";

import { createRichMarkdownExtensions } from "./RichMarkdownEditor.js";
import { useCollabReconcile, getEditorMarkdown } from "./useCollabReconcile.js";
Expand Down Expand Up @@ -179,6 +180,52 @@ function render(
}

describe("useCollabReconcile — concurrent edit / lost-update guards", () => {
it("persists the first local edit after a synced empty collaborative document", async () => {
const captured: Captured = {
editor: null,
emitted: [],
setContentCalls: 0,
};

function EmptyDocumentHarness() {
const guardsRef = React.useRef<ReturnType<
typeof useCollabReconcile
> | null>(null);
const ydoc = React.useMemo(() => new Y.Doc(), []);
const editor = useEditor({
extensions: createRichMarkdownExtensions({ dialect: "gfm", ydoc }),
onUpdate: ({ editor, transaction }) => {
const guards = guardsRef.current;
if (!guards || guards.shouldIgnoreUpdate(transaction)) return;
const markdown = getEditorMarkdown(editor);
if (!guards.registerEmitted(markdown)) return;
captured.emitted.push(markdown);
},
});
captured.editor = editor;
guardsRef.current = useCollabReconcile({
editor,
ydoc,
collabSynced: true,
value: "",
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
editable: true,
getMarkdown: getEditorMarkdown,
});
return React.createElement("div", null);
}

act(() => root.render(React.createElement(EmptyDocumentHarness)));
await flush();

act(() => {
captured.editor?.commands.insertContent("First persisted edit");
});
await flush();

expect(captured.emitted).toContain("First persisted edit");
});

it("does not seed until initial collab sync has completed", async () => {
const { captured, Harness } = makeCollabSeedHarness();

Expand Down Expand Up @@ -330,6 +377,36 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => {
expect(results).toEqual([true]);
});

it("allows a local edit that arrives as a synced empty document finishes mounting", async () => {
const results: boolean[] = [];

function Probe() {
const editor = useEditor({
extensions: createRichMarkdownExtensions({ dialect: "gfm" }),
content: "",
});
const fakeYdoc = { clientID: 1, getXmlFragment: () => ({ length: 0 }) };
const guards = useCollabReconcile({
editor,
ydoc: fakeYdoc as never,
collabSynced: true,
value: "",
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
editable: true,
});
// Runs during render, before the passive empty-seed completion effect.
if (editor && results.length === 0) {
results.push(guards.shouldIgnoreUpdate(editor.state.tr));
}
return React.createElement("div", null);
}

act(() => root.render(React.createElement(Probe)));
await flush();

expect(results).toEqual([false]);
});

it("refuses to persist an empty doc in collab mode (registerEmitted guard)", async () => {
// Directly exercise the guard contract: in collab mode an empty markdown
// string must not be registered/persisted (would clobber stored content
Expand Down
Loading
Loading