From 17e7515e82ba20a0d1250367ba23820ffed58471 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:23:31 -0400 Subject: [PATCH] feat(self-host): share one workspace across Cloudflare Access users (#467) --- docs/SELF_HOSTING_CLOUDFLARE.md | 2 + .../features/dashboard/DashboardPage.tsx | 3 + .../dashboard/WorkspaceMergeBanner.tsx | 64 +++++ .../ensure-user/cloudflareAccess.ts | 4 +- src/middleware/ensure-user/delegated.ts | 25 +- src/server/auth/delegated-organization.ts | 15 ++ src/server/auth/workspace-merge.test.ts | 221 ++++++++++++++++++ src/server/auth/workspace-merge.ts | 163 +++++++++++++ src/serverFunctions/workspace.ts | 32 +++ 9 files changed, 525 insertions(+), 4 deletions(-) create mode 100644 src/client/features/dashboard/WorkspaceMergeBanner.tsx create mode 100644 src/server/auth/workspace-merge.test.ts create mode 100644 src/server/auth/workspace-merge.ts create mode 100644 src/serverFunctions/workspace.ts 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());