diff --git a/docs/SELF_HOSTING_CLOUDFLARE.md b/docs/SELF_HOSTING_CLOUDFLARE.md
index 14f5c62..2f9fd20 100644
--- a/docs/SELF_HOSTING_CLOUDFLARE.md
+++ b/docs/SELF_HOSTING_CLOUDFLARE.md
@@ -80,6 +80,8 @@ pnpm deploy:selfhost --yes
Add the teammate to `ACCESS_ALLOWED_EMAILS` in `.env.selfhost` and redeploy. Dashboard edits to that Access policy are overwritten on the next deploy. (If you manage the Access application yourself, edit its Allow policy in Zero Trust instead.)
+Everyone allowed through Cloudflare Access works in one shared workspace and sees the same projects. Deployments upgraded from older versions (which gave each user a separate workspace) show a one-time dashboard banner — clicking it migrates all previous per-user work into the shared workspace.
+
## Troubleshooting
- Login fails: re-check `ACCESS_ALLOWED_EMAILS` in `.env.selfhost` and redeploy.
diff --git a/src/client/features/dashboard/DashboardPage.tsx b/src/client/features/dashboard/DashboardPage.tsx
index 9644d40..4c07fa1 100644
--- a/src/client/features/dashboard/DashboardPage.tsx
+++ b/src/client/features/dashboard/DashboardPage.tsx
@@ -16,6 +16,7 @@ import {
GscCard,
} from "@/client/features/dashboard/DashboardCards";
import { McpConnectCard } from "@/client/features/dashboard/McpConnectCard";
+import { WorkspaceMergeBanner } from "@/client/features/dashboard/WorkspaceMergeBanner";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import type { DashboardActivation } from "@/server/features/dashboard/services/DashboardService";
import {
@@ -306,6 +307,8 @@ export function DashboardPage({ projectId }: { projectId: string }) {
Dashboard
+
+
{/* Every card is half width on large screens (only the checklist spans).
diff --git a/src/client/features/dashboard/WorkspaceMergeBanner.tsx b/src/client/features/dashboard/WorkspaceMergeBanner.tsx
new file mode 100644
index 0000000..03e030a
--- /dev/null
+++ b/src/client/features/dashboard/WorkspaceMergeBanner.tsx
@@ -0,0 +1,64 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { getStandardErrorMessage } from "@/client/lib/error-messages";
+import { isHostedClientAuthMode } from "@/lib/auth-mode";
+import {
+ getWorkspaceMergeStatus,
+ mergeLegacyWorkspaces,
+} from "@/serverFunctions/workspace";
+
+// Shown on self-hosted Cloudflare Access deployments that still have per-user
+// workspaces from before the shared workspace existed. The server decides
+// visibility (AUTH_MODE is a runtime var there); hosted builds skip the query
+// entirely since the mode is known at build time.
+export function WorkspaceMergeBanner() {
+ const queryClient = useQueryClient();
+
+ const statusQuery = useQuery({
+ queryKey: ["workspaceMergeStatus"],
+ queryFn: () => getWorkspaceMergeStatus(),
+ enabled: !isHostedClientAuthMode(),
+ });
+
+ const mergeMutation = useMutation({
+ mutationFn: () => mergeLegacyWorkspaces(),
+ onSuccess: ({ mergedWorkspaces }) => {
+ toast.success(
+ `Migrated ${mergedWorkspaces} workspace${mergedWorkspaces === 1 ? "" : "s"} into the shared workspace.`,
+ );
+ // The merge changes projects, connections, and the banner's own status —
+ // refetch everything rather than enumerating keys.
+ void queryClient.invalidateQueries();
+ },
+ onError: (error) =>
+ toast.error(
+ getStandardErrorMessage(
+ error,
+ "Couldn't migrate the workspaces. Try again.",
+ ),
+ ),
+ });
+
+ if (!statusQuery.data || statusQuery.data.legacyWorkspaceCount === 0) {
+ return null;
+ }
+
+ return (
+
+
+ When self-hosting on Cloudflare, there was a bug where each user had
+ their own workspace. It was intended for all users to be in one
+ workspace. Clicking the button below will migrate everyone's
+ previous work into this shared workspace.
+
+
+
+ );
+}
diff --git a/src/middleware/ensure-user/cloudflareAccess.ts b/src/middleware/ensure-user/cloudflareAccess.ts
index f1ba663..e634b27 100644
--- a/src/middleware/ensure-user/cloudflareAccess.ts
+++ b/src/middleware/ensure-user/cloudflareAccess.ts
@@ -3,7 +3,7 @@ import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
import { AppError } from "@/server/lib/errors";
import { validateTeamDomain } from "@/shared/selfhost-checks";
import { classifyAccessVerificationError } from "./accessTokenErrors";
-import { resolveDelegatedContext } from "./delegated";
+import { resolveSharedWorkspaceContext } from "./delegated";
import type { EnsuredUserContext } from "./types";
const jwksByTeamDomain = new Map<
@@ -94,5 +94,5 @@ export async function resolveCloudflareAccessContext(
throw new AppError("UNAUTHENTICATED");
}
- return resolveDelegatedContext(userId, userEmail);
+ return resolveSharedWorkspaceContext(userId, userEmail);
}
diff --git a/src/middleware/ensure-user/delegated.ts b/src/middleware/ensure-user/delegated.ts
index 1e5f684..804afc5 100644
--- a/src/middleware/ensure-user/delegated.ts
+++ b/src/middleware/ensure-user/delegated.ts
@@ -1,6 +1,9 @@
import { db } from "@/db";
import { user } from "@/db/schema";
-import { ensureDelegatedOrganizationForUser } from "@/server/auth/delegated-organization";
+import {
+ ensureDelegatedOrganizationForUser,
+ ensureSharedWorkspaceOrganization,
+} from "@/server/auth/delegated-organization";
import { eq } from "drizzle-orm";
import type { EnsuredUserContext } from "./types";
@@ -52,7 +55,7 @@ async function ensureUserRecord(userId: string, userEmail: string) {
return existing.email;
}
-export async function resolveDelegatedContext(
+async function resolveDelegatedContext(
userId: string,
userEmail: string,
): Promise {
@@ -71,6 +74,24 @@ export async function resolveDelegatedContext(
};
}
+// Cloudflare Access mode: everyone the Access policy lets in works in one
+// shared workspace, keeping their own user identity. Per-user workspaces were
+// the pre-shared-workspace behavior; workspace-merge.ts folds those in.
+export async function resolveSharedWorkspaceContext(
+ userId: string,
+ userEmail: string,
+): Promise {
+ const ensuredEmail = await ensureUserRecord(userId, userEmail);
+ const organizationId = await ensureSharedWorkspaceOrganization();
+
+ return {
+ userId,
+ userEmail: ensuredEmail,
+ emailVerified: true,
+ organizationId,
+ };
+}
+
export async function resolveLocalNoAuthContext(): Promise {
return resolveDelegatedContext(LOCAL_ADMIN_USER_ID, LOCAL_ADMIN_EMAIL);
}
diff --git a/src/server/auth/delegated-organization.ts b/src/server/auth/delegated-organization.ts
index 12211ae..2077a27 100644
--- a/src/server/auth/delegated-organization.ts
+++ b/src/server/auth/delegated-organization.ts
@@ -1,6 +1,21 @@
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import { slugify, toHex } from "./org-slug";
+// Every Cloudflare Access user on a deployment shares this one workspace. The
+// id deliberately lacks the "delegated-" prefix so the legacy per-user pattern
+// below can be matched (and merged) without excluding it.
+export const SHARED_WORKSPACE_ORGANIZATION_ID = "shared-workspace";
+
+export async function ensureSharedWorkspaceOrganization() {
+ await AuthRepository.upsertDelegatedOrganization({
+ id: SHARED_WORKSPACE_ORGANIZATION_ID,
+ name: "Shared workspace",
+ slug: SHARED_WORKSPACE_ORGANIZATION_ID,
+ });
+
+ return SHARED_WORKSPACE_ORGANIZATION_ID;
+}
+
function getDelegatedOrganizationId(userId: string) {
return `delegated-${userId}`;
}
diff --git a/src/server/auth/workspace-merge.test.ts b/src/server/auth/workspace-merge.test.ts
new file mode 100644
index 0000000..22d2493
--- /dev/null
+++ b/src/server/auth/workspace-merge.test.ts
@@ -0,0 +1,221 @@
+import { createClient, type Client } from "@libsql/client";
+import { drizzle } from "drizzle-orm/libsql";
+import {
+ afterAll,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
+import type * as WorkspaceMergeModule from "./workspace-merge";
+
+// Real in-memory SQLite so the repoint-before-delete ordering, the Default
+// rename against the partial unique index, and the org-delete cascades run
+// against actual SQL — the parts a mocked db can't see.
+
+const mockEnv = vi.hoisted(
+ () =>
+ ({ DATABASE_PROVIDER: "d1" }) as {
+ DATABASE_PROVIDER: string;
+ AUTH_MODE?: string;
+ },
+);
+
+vi.mock("cloudflare:workers", () => ({ env: mockEnv }));
+
+let client: Client;
+let testDb: ReturnType;
+let WorkspaceMergeService: typeof WorkspaceMergeModule.WorkspaceMergeService;
+
+async function rows(sql: string) {
+ return (await client.execute(sql)).rows;
+}
+
+beforeAll(async () => {
+ client = createClient({ url: "file::memory:" });
+ testDb = drizzle(client);
+ // testDb only exists at runtime, so the module under test must load after
+ // these mocks — the one sanctioned use of doMock + dynamic import.
+ vi.doMock("@/db", () => ({ db: testDb }));
+ vi.doMock("@/db/d1/client", () => ({ d1Db: testDb }));
+ vi.doMock("@/db/pg/client", () => ({ pgDb: null }));
+
+ await client.executeMultiple(`
+ PRAGMA foreign_keys = ON;
+ CREATE TABLE organization (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ slug TEXT
+ );
+ CREATE TABLE projects (
+ id TEXT PRIMARY KEY,
+ organization_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ domain TEXT,
+ archived_at TEXT
+ );
+ CREATE UNIQUE INDEX projects_one_default_per_organization_idx
+ ON projects (organization_id)
+ WHERE name = 'Default' AND domain IS NULL AND archived_at IS NULL;
+ CREATE TABLE user_onboarding_answers (
+ user_id TEXT PRIMARY KEY,
+ organization_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE
+ );
+ CREATE TABLE organization_activation_state (
+ organization_id TEXT PRIMARY KEY REFERENCES organization(id) ON DELETE CASCADE,
+ first_mcp_authorized_at TEXT,
+ first_mcp_tool_call_at TEXT,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE TABLE gsc_connections (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ organization_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
+ site_url TEXT NOT NULL,
+ connected_by_user_id TEXT NOT NULL
+ );
+ CREATE TABLE ga4_connections (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ organization_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE
+ );
+ `);
+
+ ({ WorkspaceMergeService } = await import("./workspace-merge"));
+});
+
+afterAll(() => {
+ client.close();
+});
+
+beforeEach(async () => {
+ mockEnv.AUTH_MODE = "cloudflare_access";
+ await client.executeMultiple(`
+ DELETE FROM projects;
+ DELETE FROM user_onboarding_answers;
+ DELETE FROM organization_activation_state;
+ DELETE FROM gsc_connections;
+ DELETE FROM ga4_connections;
+ DELETE FROM organization;
+ `);
+});
+
+async function seedLegacyWorkspaces() {
+ await client.executeMultiple(`
+ INSERT INTO organization (id, name, slug) VALUES
+ ('shared-workspace', 'Shared workspace', 'shared-workspace'),
+ ('delegated-u1', 'ben workspace', 'delegated-ben-1'),
+ ('delegated-u2', 'sam workspace', 'delegated-sam-2'),
+ ('org_hosted', 'Hosted org', 'hosted');
+ INSERT INTO projects (id, organization_id, name, domain, archived_at) VALUES
+ ('p-shared', 'shared-workspace', 'Default', NULL, NULL),
+ ('p1-default', 'delegated-u1', 'Default', NULL, NULL),
+ ('p1-acme', 'delegated-u1', 'Acme', 'acme.com', NULL),
+ ('p2-default', 'delegated-u2', 'Default', NULL, NULL),
+ ('p-hosted', 'org_hosted', 'Default', NULL, NULL);
+ INSERT INTO user_onboarding_answers (user_id, organization_id) VALUES
+ ('u1', 'delegated-u1');
+ INSERT INTO organization_activation_state
+ (organization_id, first_mcp_authorized_at, first_mcp_tool_call_at) VALUES
+ ('delegated-u1', '2026-03-01 09:00:00', '2026-03-02 09:00:00'),
+ ('delegated-u2', '2026-01-02 09:00:00', NULL);
+ INSERT INTO gsc_connections
+ (id, project_id, organization_id, site_url, connected_by_user_id) VALUES
+ ('gsc1', 'p1-acme', 'delegated-u1', 'sc-domain:acme.com', 'u1');
+ `);
+}
+
+describe("WorkspaceMergeService", () => {
+ it("folds legacy workspaces into the shared workspace and leaves other orgs alone", async () => {
+ await seedLegacyWorkspaces();
+
+ await expect(WorkspaceMergeService.countLegacyWorkspaces()).resolves.toBe(
+ 2,
+ );
+ await expect(
+ WorkspaceMergeService.mergeLegacyWorkspaces(),
+ ).resolves.toEqual({ mergedWorkspaces: 2 });
+
+ expect(
+ await rows("SELECT id, organization_id, name FROM projects ORDER BY id"),
+ ).toEqual([
+ expect.objectContaining({
+ id: "p-hosted",
+ organization_id: "org_hosted",
+ }),
+ expect.objectContaining({
+ id: "p-shared",
+ organization_id: "shared-workspace",
+ name: "Default",
+ }),
+ expect.objectContaining({
+ id: "p1-acme",
+ organization_id: "shared-workspace",
+ name: "Acme",
+ }),
+ expect.objectContaining({
+ id: "p1-default",
+ organization_id: "shared-workspace",
+ name: "Default (ben)",
+ }),
+ expect.objectContaining({
+ id: "p2-default",
+ organization_id: "shared-workspace",
+ name: "Default (sam)",
+ }),
+ ]);
+
+ // Legacy orgs are gone; their activation rows cascaded away after the
+ // earliest milestones were folded into the shared row.
+ expect(await rows("SELECT id FROM organization ORDER BY id")).toEqual([
+ expect.objectContaining({ id: "org_hosted" }),
+ expect.objectContaining({ id: "shared-workspace" }),
+ ]);
+ expect(await rows("SELECT * FROM organization_activation_state")).toEqual([
+ expect.objectContaining({
+ organization_id: "shared-workspace",
+ first_mcp_authorized_at: "2026-01-02 09:00:00",
+ first_mcp_tool_call_at: "2026-03-02 09:00:00",
+ }),
+ ]);
+ expect(await rows("SELECT organization_id FROM gsc_connections")).toEqual([
+ expect.objectContaining({ organization_id: "shared-workspace" }),
+ ]);
+ expect(
+ await rows("SELECT organization_id FROM user_onboarding_answers"),
+ ).toEqual([
+ expect.objectContaining({ organization_id: "shared-workspace" }),
+ ]);
+ });
+
+ it("refuses to run outside cloudflare_access mode", async () => {
+ await seedLegacyWorkspaces();
+ mockEnv.AUTH_MODE = "local_noauth";
+
+ await expect(
+ WorkspaceMergeService.mergeLegacyWorkspaces(),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+ // Nothing was touched.
+ expect(await rows("SELECT count(*) AS n FROM organization")).toEqual([
+ expect.objectContaining({ n: 4 }),
+ ]);
+ });
+
+ it("is a no-op when run again", async () => {
+ await seedLegacyWorkspaces();
+ await WorkspaceMergeService.mergeLegacyWorkspaces();
+
+ await expect(
+ WorkspaceMergeService.mergeLegacyWorkspaces(),
+ ).resolves.toEqual({ mergedWorkspaces: 0 });
+ await expect(WorkspaceMergeService.countLegacyWorkspaces()).resolves.toBe(
+ 0,
+ );
+ // Nothing renamed twice.
+ expect(
+ await rows("SELECT name FROM projects WHERE id = 'p1-default'"),
+ ).toEqual([expect.objectContaining({ name: "Default (ben)" })]);
+ });
+});
diff --git a/src/server/auth/workspace-merge.ts b/src/server/auth/workspace-merge.ts
new file mode 100644
index 0000000..75c03f8
--- /dev/null
+++ b/src/server/auth/workspace-merge.ts
@@ -0,0 +1,163 @@
+import { env } from "cloudflare:workers";
+import { and, eq, inArray, isNull, like } from "drizzle-orm";
+import { db } from "@/db";
+import { runBatch } from "@/db/runBatch";
+import { getAuthMode } from "@/lib/auth-mode";
+import { AppError } from "@/server/lib/errors";
+import {
+ ga4Connections,
+ gscConnections,
+ organization,
+ organizationActivationState,
+ projects,
+ userOnboardingAnswers,
+} from "@/db/schema";
+import { SHARED_WORKSPACE_ORGANIZATION_ID } from "./delegated-organization";
+
+// Earliest-wins pick across nullable UTC timestamp strings (string compare is
+// chronological for these).
+function earliest(values: (string | null)[]) {
+ return (
+ values.filter((value): value is string => value !== null).toSorted()[0] ??
+ null
+ );
+}
+
+// Before the shared workspace, every Cloudflare Access user got their own
+// `delegated-${userId}` organization (and local_noauth got delegated-local-admin,
+// which an operator switching modes wants folded in too). The shared workspace
+// id has no such prefix, so this matches exactly the legacy set.
+const legacyWorkspaceFilter = like(organization.id, "delegated-%");
+
+async function countLegacyWorkspaces() {
+ const rows = await db
+ .select({ id: organization.id })
+ .from(organization)
+ .where(legacyWorkspaceFilter);
+
+ return rows.length;
+}
+
+// Folds every legacy per-user workspace into the shared workspace: projects
+// (which carry all their keywords, rank tracking, audits, and agent data with
+// them) plus the org-scoped rows, then deletes the emptied legacy orgs. Safe
+// to re-run and safe under concurrent clicks — every statement is a no-op once
+// the legacy orgs are gone.
+async function mergeLegacyWorkspaces() {
+ // Hard gate, independent of any caller-side check: outside cloudflare_access
+ // mode the "delegated-%" orgs are either live (local_noauth resolves to
+ // delegated-local-admin — merging would strand its data in a workspace that
+ // mode never shows) or should not exist at all (hosted).
+ if (getAuthMode(env.AUTH_MODE) !== "cloudflare_access") {
+ throw new AppError(
+ "FORBIDDEN",
+ "Workspace merge is only available in cloudflare_access auth mode.",
+ );
+ }
+
+ const legacyOrgs = await db
+ .select({ id: organization.id, name: organization.name })
+ .from(organization)
+ .where(legacyWorkspaceFilter);
+
+ if (legacyOrgs.length === 0) {
+ return { mergedWorkspaces: 0 };
+ }
+
+ const legacyIds = legacyOrgs.map((org) => org.id);
+ // Legacy org names are " workspace" — reuse the localpart
+ // to label renamed projects with their previous owner.
+ const ownerLabelByOrgId = new Map(
+ legacyOrgs.map((org) => [org.id, org.name.replace(/ workspace$/, "")]),
+ );
+
+ // Each legacy workspace auto-created a ("Default", no-domain) project, and
+ // the shared workspace allows only one active such project. Rename them
+ // before repointing so the partial unique index can't reject the move —
+ // renaming (never deleting) means a Default that holds work survives intact.
+ const conflictingDefaults = await db
+ .select({ id: projects.id, organizationId: projects.organizationId })
+ .from(projects)
+ .where(
+ and(
+ inArray(projects.organizationId, legacyIds),
+ eq(projects.name, "Default"),
+ isNull(projects.domain),
+ isNull(projects.archivedAt),
+ ),
+ );
+
+ // Earliest-wins merge of activation milestones across the shared row and
+ // all legacy rows.
+ const activationRows = await db
+ .select()
+ .from(organizationActivationState)
+ .where(
+ inArray(organizationActivationState.organizationId, [
+ SHARED_WORKSPACE_ORGANIZATION_ID,
+ ...legacyIds,
+ ]),
+ );
+ const mergedActivation = {
+ firstMcpAuthorizedAt: earliest(
+ activationRows.map((row) => row.firstMcpAuthorizedAt),
+ ),
+ firstMcpToolCallAt: earliest(
+ activationRows.map((row) => row.firstMcpToolCallAt),
+ ),
+ };
+
+ const repointToShared = { organizationId: SHARED_WORKSPACE_ORGANIZATION_ID };
+
+ await runBatch((tx) => [
+ ...conflictingDefaults.map((project) =>
+ tx
+ .update(projects)
+ .set({
+ name: `Default (${ownerLabelByOrgId.get(project.organizationId) ?? "imported"})`,
+ })
+ .where(eq(projects.id, project.id)),
+ ),
+ tx
+ .update(projects)
+ .set(repointToShared)
+ .where(inArray(projects.organizationId, legacyIds)),
+ tx
+ .update(userOnboardingAnswers)
+ .set(repointToShared)
+ .where(inArray(userOnboardingAnswers.organizationId, legacyIds)),
+ tx
+ .update(gscConnections)
+ .set(repointToShared)
+ .where(inArray(gscConnections.organizationId, legacyIds)),
+ tx
+ .update(ga4Connections)
+ .set(repointToShared)
+ .where(inArray(ga4Connections.organizationId, legacyIds)),
+ ...(activationRows.length > 0
+ ? [
+ tx
+ .insert(organizationActivationState)
+ .values({
+ organizationId: SHARED_WORKSPACE_ORGANIZATION_ID,
+ ...mergedActivation,
+ })
+ .onConflictDoUpdate({
+ target: organizationActivationState.organizationId,
+ set: mergedActivation,
+ }),
+ ]
+ : []),
+ // Everything user-visible is repointed above; deleting the legacy orgs
+ // cascades away only the per-org leftovers (members, billing status,
+ // legacy activation rows).
+ tx.delete(organization).where(inArray(organization.id, legacyIds)),
+ ]);
+
+ return { mergedWorkspaces: legacyOrgs.length };
+}
+
+export const WorkspaceMergeService = {
+ countLegacyWorkspaces,
+ mergeLegacyWorkspaces,
+} as const;
diff --git a/src/serverFunctions/workspace.ts b/src/serverFunctions/workspace.ts
new file mode 100644
index 0000000..9ea2a3c
--- /dev/null
+++ b/src/serverFunctions/workspace.ts
@@ -0,0 +1,32 @@
+import { env } from "cloudflare:workers";
+import { createServerFn } from "@tanstack/react-start";
+import { getAuthMode } from "@/lib/auth-mode";
+import { WorkspaceMergeService } from "@/server/auth/workspace-merge";
+import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
+
+// Legacy per-user workspaces only ever existed in cloudflare_access mode, and
+// the merge must not be reachable anywhere else. AUTH_MODE is a runtime var on
+// self-host deployments, so the client can't gate this itself — the status
+// response is what hides the banner in other modes.
+function isCloudflareAccessMode() {
+ return getAuthMode(env.AUTH_MODE) === "cloudflare_access";
+}
+
+export const getWorkspaceMergeStatus = createServerFn({ method: "POST" })
+ .middleware(requireAuthenticatedContext)
+ .handler(async () => {
+ if (!isCloudflareAccessMode()) {
+ return { legacyWorkspaceCount: 0 };
+ }
+
+ return {
+ legacyWorkspaceCount: await WorkspaceMergeService.countLegacyWorkspaces(),
+ };
+ });
+
+// Any authenticated user may run the merge: everyone behind the same Access
+// policy is equally trusted in this deployment model. The service itself
+// refuses to run outside cloudflare_access mode.
+export const mergeLegacyWorkspaces = createServerFn({ method: "POST" })
+ .middleware(requireAuthenticatedContext)
+ .handler(async () => WorkspaceMergeService.mergeLegacyWorkspaces());