diff --git a/apps/api/src/hosted/claim-site.test.ts b/apps/api/src/hosted/claim-site.test.ts new file mode 100644 index 0000000000..ab7e634a1e --- /dev/null +++ b/apps/api/src/hosted/claim-site.test.ts @@ -0,0 +1,526 @@ +import { describe, expect, it } from "bun:test"; +import { OrgSiteConflictError, OrgSiteLinkError } from "@/storage/org-sites"; +import type { OrgSite } from "@/storage/types"; +import { + backfillSiteClaims, + claimProjectSite, + linkProjectSite, + type SiteClaimCandidate, + type SiteClaimDeps, +} from "./claim-site"; + +interface Row { + /** null: a tombstone (its org was deleted). */ + organizationId: string | null; + projectId: string | null; + /** Used before: its project was deleted, or it predates the link. */ + used?: boolean; +} + +/** + * An in-memory `org_sites` with the storage's claim and link rules. `initial` + * maps a slug to its owning org, or to a full row. + */ +function memorySites(initial: Record = {}) { + const rows = new Map( + Object.entries(initial).map(([slug, row]) => [ + slug, + typeof row === "string" + ? { organizationId: row, projectId: null } + : { ...row }, + ]), + ); + const claims: { slug: string; organizationId: string; source?: string }[] = + []; + const links: { slug: string; projectId: string }[] = []; + const site = (slug: string, row: Row): OrgSite => ({ + slug, + organizationId: row.organizationId, + projectId: row.projectId, + linkedAt: row.projectId || row.used ? "2026-01-01T00:00:00.000Z" : null, + source: "test", + createdBy: "t", + createdAt: "", + updatedBy: "t", + updatedAt: "", + }); + const orgSites = { + async getBySlug(slug: string) { + const row = rows.get(slug); + return row ? site(slug, row) : null; + }, + async getByProject(projectId: string) { + for (const [slug, row] of rows) { + if (row.projectId === projectId) return site(slug, row); + } + return null; + }, + async claimSite(params: { + slug: string; + organizationId: string; + source?: string; + by: string; + }) { + const row = rows.get(params.slug); + if (row?.organizationId === null) { + throw new OrgSiteLinkError("reserved", params.slug); + } + if (row && row.organizationId !== params.organizationId) { + throw new OrgSiteConflictError(params.slug, row.organizationId); + } + const next = row ?? { + organizationId: params.organizationId, + projectId: null, + }; + rows.set(params.slug, next); + claims.push(params); + return site(params.slug, next); + }, + async link(params: { + slug: string; + organizationId: string; + projectId: string; + by: string; + }) { + const row = rows.get(params.slug); + if (!row) throw new OrgSiteLinkError("not_found", params.slug); + if (row.organizationId === null) { + throw new OrgSiteLinkError("reserved", params.slug); + } + if (row.organizationId !== params.organizationId) { + throw new OrgSiteLinkError("not_owned", params.slug); + } + if (row.projectId === params.projectId) return site(params.slug, row); + if (row.projectId !== null) { + throw new OrgSiteLinkError("linked_elsewhere", params.slug); + } + row.projectId = params.projectId; + links.push({ slug: params.slug, projectId: params.projectId }); + return site(params.slug, row); + }, + } satisfies SiteClaimDeps["orgSites"]; + return { rows, claims, links, orgSites }; +} + +const noDecoSites = async () => false; +const input = { + organizationId: "org-a", + projectId: "p1", + by: "u1", + source: "test", + slug: "shop", +}; + +describe("linkProjectSite", () => { + it("claims a free slug for the org, then links the project", async () => { + const sites = memorySites(); + const deps = { orgSites: sites.orgSites, isDecoSite: noDecoSites }; + expect(await linkProjectSite(deps, input)).toEqual({ + status: "linked", + slug: "shop", + }); + expect(sites.rows.get("shop")).toEqual({ + organizationId: "org-a", + projectId: "p1", + }); + }); + + it("links a free row the org already owns, without claiming", async () => { + const sites = memorySites({ shop: "org-a" }); + const deps = { orgSites: sites.orgSites, isDecoSite: noDecoSites }; + expect(await linkProjectSite(deps, input)).toEqual({ + status: "linked", + slug: "shop", + }); + expect(sites.claims).toEqual([]); + expect(sites.links).toEqual([{ slug: "shop", projectId: "p1" }]); + }); + + it("is a no-op for the project's own site", async () => { + const sites = memorySites({ + shop: { organizationId: "org-a", projectId: "p1" }, + }); + const deps = { orgSites: sites.orgSites, isDecoSite: noDecoSites }; + expect(await linkProjectSite(deps, input)).toEqual({ + status: "already-linked", + slug: "shop", + }); + expect(sites.links).toEqual([]); + }); + + it("never gives a linked project a second site", async () => { + const sites = memorySites({ + other: { organizationId: "org-a", projectId: "p1" }, + }); + const deps = { orgSites: sites.orgSites, isDecoSite: noDecoSites }; + expect(await linkProjectSite(deps, input)).toEqual({ + status: "refused", + slug: "shop", + reason: "project-has-other-slug", + }); + expect(sites.claims).toEqual([]); + }); + + it("never takes another project's site", async () => { + const sites = memorySites({ + shop: { organizationId: "org-a", projectId: "p2" }, + }); + const deps = { orgSites: sites.orgSites, isDecoSite: noDecoSites }; + expect(await linkProjectSite(deps, input)).toEqual({ + status: "refused", + slug: "shop", + reason: "linked-elsewhere", + }); + expect(sites.rows.get("shop")?.projectId).toBe("p2"); + }); + + it("never takes a slug another org owns", async () => { + const sites = memorySites({ shop: "org-b" }); + const deps = { orgSites: sites.orgSites, isDecoSite: noDecoSites }; + expect(await linkProjectSite(deps, input)).toEqual({ + status: "refused", + slug: "shop", + reason: "other-org", + owner: "org-b", + }); + expect(sites.rows.get("shop")?.organizationId).toBe("org-b"); + }); + + it("relinks the org's used slug once its project is gone", async () => { + const sites = memorySites({ + shop: { organizationId: "org-a", projectId: null, used: true }, + }); + const deps = { orgSites: sites.orgSites, isDecoSite: noDecoSites }; + expect(await linkProjectSite(deps, input)).toEqual({ + status: "linked", + slug: "shop", + }); + expect(sites.claims).toEqual([]); + expect(sites.links).toEqual([{ slug: "shop", projectId: "p1" }]); + }); + + it("never relinks another org's used slug", async () => { + const sites = memorySites({ + shop: { organizationId: "org-b", projectId: null, used: true }, + }); + const deps = { orgSites: sites.orgSites, isDecoSite: noDecoSites }; + expect(await linkProjectSite(deps, input)).toEqual({ + status: "refused", + slug: "shop", + reason: "other-org", + owner: "org-b", + }); + expect(sites.links).toEqual([]); + }); + + it("never reuses a deleted org's slug", async () => { + const sites = memorySites({ + shop: { organizationId: null, projectId: null }, + }); + const deps = { orgSites: sites.orgSites, isDecoSite: noDecoSites }; + expect(await linkProjectSite(deps, input)).toEqual({ + status: "refused", + slug: "shop", + reason: "reserved", + }); + expect(sites.claims).toEqual([]); + expect(sites.links).toEqual([]); + }); + + it("leaves a deco.cx site to the deco import", async () => { + const sites = memorySites(); + const deps = { + orgSites: sites.orgSites, + isDecoSite: async (slug: string) => slug === "shop", + }; + expect(await linkProjectSite(deps, input)).toEqual({ + status: "refused", + slug: "shop", + reason: "deco.cx", + }); + expect(sites.claims).toEqual([]); + }); + + it("reports a claim another org won in a race", async () => { + const sites = memorySites(); + const deps = { + orgSites: { + ...sites.orgSites, + claimSite: async () => { + throw new OrgSiteConflictError("shop", "org-b"); + }, + }, + isDecoSite: noDecoSites, + }; + expect(await linkProjectSite(deps, input)).toEqual({ + status: "refused", + slug: "shop", + reason: "other-org", + owner: "org-b", + }); + }); + + it("reports a link another project won in a race", async () => { + const sites = memorySites({ shop: "org-a" }); + const deps = { + orgSites: { + ...sites.orgSites, + link: async () => { + throw new OrgSiteLinkError("linked_elsewhere", "shop"); + }, + }, + isDecoSite: noDecoSites, + }; + expect(await linkProjectSite(deps, input)).toEqual({ + status: "refused", + slug: "shop", + reason: "linked-elsewhere", + }); + }); + + it("writes nothing on a dry run", async () => { + const sites = memorySites(); + const deps = { orgSites: sites.orgSites, isDecoSite: noDecoSites }; + expect(await linkProjectSite(deps, { ...input, dryRun: true })).toEqual({ + status: "linked", + slug: "shop", + }); + expect(sites.claims).toEqual([]); + expect(sites.links).toEqual([]); + }); +}); + +describe("claimProjectSite", () => { + const noOtherOrg = async () => null; + const project = { organizationId: "org-a", projectId: "p1", by: "u1" }; + + it("links the project to its siteSlug", async () => { + const sites = memorySites(); + const deps = { + orgSites: sites.orgSites, + isDecoSite: noDecoSites, + otherOrgNamingSlug: noOtherOrg, + }; + expect( + await claimProjectSite(deps, { + ...project, + metadata: { siteSlug: "shop" }, + }), + ).toEqual({ status: "linked", slug: "shop" }); + expect(sites.claims[0]?.source).toBe("project-create"); + expect(sites.rows.get("shop")?.projectId).toBe("p1"); + }); + + it("skips a project with no valid siteSlug", async () => { + const sites = memorySites(); + const deps = { + orgSites: sites.orgSites, + isDecoSite: noDecoSites, + otherOrgNamingSlug: noOtherOrg, + }; + for (const metadata of [null, {}, { siteSlug: "Not A Slug" }]) { + expect(await claimProjectSite(deps, { ...project, metadata })).toBe(null); + } + expect(sites.claims).toEqual([]); + }); + + it("doesn't take an unowned slug another org's project already names", async () => { + const sites = memorySites(); + const asked: [string, string][] = []; + const deps = { + orgSites: sites.orgSites, + isDecoSite: noDecoSites, + otherOrgNamingSlug: async (slug: string, organizationId: string) => { + asked.push([slug, organizationId]); + return "org-b"; + }, + }; + expect( + await claimProjectSite(deps, { + ...project, + metadata: { siteSlug: "shop" }, + }), + ).toEqual({ + status: "refused", + slug: "shop", + reason: "other-org-project", + owner: "org-b", + }); + expect(asked).toEqual([["shop", "org-a"]]); + expect(sites.claims).toEqual([]); + }); + + it("an owned slug is answered by its owner, not other orgs' projects", async () => { + const sites = memorySites({ shop: "org-a" }); + const deps = { + orgSites: sites.orgSites, + isDecoSite: noDecoSites, + otherOrgNamingSlug: async () => "org-b", + }; + expect( + await claimProjectSite(deps, { + ...project, + metadata: { siteSlug: "shop" }, + }), + ).toEqual({ status: "linked", slug: "shop" }); + }); + + it("never fails the project's creation", async () => { + const sites = memorySites(); + const deps = { + orgSites: sites.orgSites, + isDecoSite: async () => { + throw new Error("supabase down"); + }, + otherOrgNamingSlug: noOtherOrg, + }; + expect( + await claimProjectSite(deps, { + ...project, + metadata: { siteSlug: "shop" }, + }), + ).toBe(null); + // Fails closed: an unanswered deco.cx lookup claims nothing. + expect(sites.claims).toEqual([]); + }); +}); + +describe("backfillSiteClaims", () => { + const candidate = ( + organizationId: string, + projectId: string, + slug: string, + ): SiteClaimCandidate => ({ organizationId, projectId, slug }); + + it("links v8 projects to their sites and reports everything else", async () => { + const sites = memorySites({ + owned: "org-a", + taken: "org-z", + gone: { organizationId: null, projectId: null }, + }); + const candidates = [ + candidate("org-a", "p1", "free"), + candidate("org-a", "p3", "owned"), + candidate("org-a", "p4", "taken"), + candidate("org-a", "p5", "v7-site"), + candidate("org-a", "p6", "broken"), + candidate("org-a", "p7", "both"), + candidate("org-b", "p8", "both"), + candidate("org-a", "p9", "legacy"), + candidate("org-a", "p10", "gone"), + candidate("org-a", "p11", "twice"), + candidate("org-a", "p12", "twice"), + ]; + const report = await backfillSiteClaims({ + orgSites: sites.orgSites, + isDecoSite: async (slug) => slug === "legacy", + candidates, + by: "admin", + dryRun: false, + isV8: async ({ slug }) => { + if (slug === "broken") throw new Error("repo gone"); + return slug !== "v7-site"; + }, + }); + expect(report.linked.map((c) => c.projectId)).toEqual(["p1", "p3"]); + expect(report.alreadyLinked).toEqual([]); + expect(report.refused).toEqual([ + { ...candidate("org-a", "p10", "gone"), reason: "reserved" }, + { ...candidate("org-a", "p9", "legacy"), reason: "deco.cx" }, + { + ...candidate("org-a", "p4", "taken"), + reason: "other-org", + owner: "org-z", + }, + ]); + expect(report.ambiguous).toEqual([ + { + slug: "both", + organizationIds: ["org-a", "org-b"], + projectIds: ["p7", "p8"], + }, + { slug: "twice", organizationIds: ["org-a"], projectIds: ["p11", "p12"] }, + ]); + expect(report.notV8.map((c) => c.projectId)).toEqual(["p5"]); + expect(report.errors).toEqual([ + { ...candidate("org-a", "p6", "broken"), error: "repo gone" }, + ]); + // Nothing taken from another org, nothing guessed. + expect(sites.claims.map((c) => c.slug)).toEqual(["free"]); + expect(sites.rows.get("taken")?.organizationId).toBe("org-z"); + expect(sites.rows.has("both")).toBe(false); + expect(sites.rows.has("twice")).toBe(false); + expect(sites.rows.get("gone")).toEqual({ + organizationId: null, + projectId: null, + }); + }); + + it("several projects of one org: only the linked one is its site", async () => { + const sites = memorySites({ + shop: { organizationId: "org-a", projectId: "p2" }, + }); + const report = await backfillSiteClaims({ + orgSites: sites.orgSites, + isDecoSite: noDecoSites, + candidates: [ + candidate("org-a", "p1", "shop"), + candidate("org-a", "p2", "shop"), + ], + by: "admin", + dryRun: false, + isV8: async () => true, + }); + expect(report.ambiguous).toEqual([]); + expect(report.alreadyLinked.map((c) => c.projectId)).toEqual(["p2"]); + expect(report.refused).toEqual([ + { ...candidate("org-a", "p1", "shop"), reason: "linked-elsewhere" }, + ]); + }); + + it("is safe to re-run, and a dry run writes nothing", async () => { + const sites = memorySites(); + const run = { + orgSites: sites.orgSites, + isDecoSite: noDecoSites, + candidates: [candidate("org-a", "p1", "shop")], + by: "admin", + isV8: async () => true, + }; + const dry = await backfillSiteClaims({ ...run, dryRun: true }); + expect(dry.linked).toHaveLength(1); + expect(sites.claims).toEqual([]); + expect(sites.links).toEqual([]); + + await backfillSiteClaims({ ...run, dryRun: false }); + const again = await backfillSiteClaims({ ...run, dryRun: false }); + expect(again.linked).toEqual([]); + expect(again.alreadyLinked).toHaveLength(1); + expect(sites.claims).toHaveLength(1); + expect(sites.links).toHaveLength(1); + }); + + it("an owned slug that two orgs name is reported, not ambiguous", async () => { + const sites = memorySites({ shop: "org-a" }); + const report = await backfillSiteClaims({ + orgSites: sites.orgSites, + isDecoSite: noDecoSites, + candidates: [ + candidate("org-a", "p1", "shop"), + candidate("org-b", "p2", "shop"), + ], + by: "admin", + dryRun: false, + isV8: async () => true, + }); + expect(report.ambiguous).toEqual([]); + expect(report.linked.map((c) => c.projectId)).toEqual(["p1"]); + expect(report.refused).toEqual([ + { + ...candidate("org-b", "p2", "shop"), + reason: "other-org", + owner: "org-a", + }, + ]); + }); +}); diff --git a/apps/api/src/hosted/claim-site.ts b/apps/api/src/hosted/claim-site.ts new file mode 100644 index 0000000000..96d04ff902 --- /dev/null +++ b/apps/api/src/hosted/claim-site.ts @@ -0,0 +1,415 @@ +/** + * Linking a project to its site in `org_sites` (`link`), so the hosted + * ownership check (`ownedProjectSite`, which reads the link) works for it. + * + * The site id is public and immutable: a project is linked once, and a slug + * never leaves its org (a deleted org's slug stays reserved). After its + * project is deleted, the same org may link it to another of its projects. + * `org_sites` ownership also lets an org mint asset-storage credentials for + * `/*` (`managed` file configs), and the slug a project is created with + * is caller-chosen. So a slug no org owns is claimed only when it isn't a + * deco.cx site (the deco import claims those, after proving access) and no + * other org's project names it. Nothing here ever takes a slug from another + * org or another project. + */ + +import { type Kysely, sql } from "kysely"; +import type { Database } from "@/storage/types"; +import type { OrgSiteStoragePort } from "@/storage/ports"; +import { + OrgSiteConflictError, + OrgSiteLinkError, + type OrgSiteLinkErrorCode, +} from "@/storage/org-sites"; +import { getDecoSupabaseConfig, supabaseGet } from "@/deco-legacy/supabase"; +import { projectSite } from "./scope"; + +/** Why a project was not linked to the site it names. */ +export type SiteClaimRefusal = + /** Another org owns the slug (`owner`: its id). */ + | "other-org" + /** No org owns it, but another org's project names it (`owner`: that org). */ + | "other-org-project" + /** No org owns it, and deco.cx has a site by that name. */ + | "deco.cx" + /** A deleted org's slug: reserved forever. */ + | "reserved" + /** The slug is already another project's site. */ + | "linked-elsewhere" + /** The project already has a different site. */ + | "project-has-other-slug" + /** The project isn't one of this org's projects. */ + | "project-not-found" + /** The org's row for the slug vanished between the claim and the link. */ + | "not-found"; + +export type SiteClaimOutcome = + /** Linked now (the slug was claimed for the org first when nobody had it). */ + | { status: "linked"; slug: string } + | { status: "already-linked"; slug: string } + | { + status: "refused"; + slug: string; + reason: SiteClaimRefusal; + owner?: string; + }; + +export interface SiteClaimDeps { + orgSites: Pick< + OrgSiteStoragePort, + "getBySlug" | "getByProject" | "claimSite" | "link" + >; + /** Whether deco.cx has a site by this name (see {@link decoSiteExists}). */ + isDecoSite: (slug: string) => Promise; + /** The id of another org with a project naming `slug`, or null. */ + otherOrgNamingSlug?: ( + slug: string, + organizationId: string, + ) => Promise; +} + +const LINK_REFUSALS: Record = { + not_found: "not-found", + reserved: "reserved", + not_owned: "other-org", + linked_elsewhere: "linked-elsewhere", + project_has_other_slug: "project-has-other-slug", + project_not_found: "project-not-found", + in_use: "linked-elsewhere", +}; + +/** + * Makes `slug` the site of `projectId`, once: claims it for the org first when + * no org owns it (and it is safe to), then links. Idempotent. `dryRun` answers + * what would happen, writing nothing. + */ +export async function linkProjectSite( + deps: SiteClaimDeps, + input: { + slug: string; + organizationId: string; + projectId: string; + by: string; + source: string; + dryRun?: boolean; + }, +): Promise { + const { slug, organizationId, projectId } = input; + const refused = ( + reason: SiteClaimRefusal, + owner?: string, + ): SiteClaimOutcome => ({ + status: "refused", + slug, + reason, + ...(owner ? { owner } : {}), + }); + + const current = await deps.orgSites.getByProject(projectId); + if (current) { + return current.slug === slug + ? { status: "already-linked", slug } + : refused("project-has-other-slug"); + } + const row = await deps.orgSites.getBySlug(slug); + if (row) { + if (row.organizationId === null) return refused("reserved"); + if (row.organizationId !== organizationId) { + return refused("other-org", row.organizationId); + } + if (row.projectId !== null) return refused("linked-elsewhere"); + // Unlinked: never used, or its project was deleted — the org's to link. + } else { + const other = await deps.otherOrgNamingSlug?.(slug, organizationId); + if (other) return refused("other-org-project", other); + if (await deps.isDecoSite(slug)) return refused("deco.cx"); + if (input.dryRun) return { status: "linked", slug }; + try { + await deps.orgSites.claimSite({ + slug, + organizationId, + source: input.source, + by: input.by, + }); + } catch (error) { + // Another org claimed it, or it became a tombstone, since the read. + if (error instanceof OrgSiteConflictError) { + return refused("other-org", error.ownerOrganizationId); + } + if (error instanceof OrgSiteLinkError) { + return refused(LINK_REFUSALS[error.code]); + } + throw error; + } + } + if (input.dryRun) return { status: "linked", slug }; + try { + await deps.orgSites.link({ + slug, + organizationId, + projectId, + by: input.by, + }); + return { status: "linked", slug }; + } catch (error) { + if (error instanceof OrgSiteLinkError) { + return refused(LINK_REFUSALS[error.code]); + } + throw error; + } +} + +/** + * The link made when a project is created or imported: its `siteSlug`, if it + * has a valid one. Best-effort — logs and answers null instead of failing the + * project's creation. A refused project keeps the slug unlinked (it can't + * change), so it gets no hosted features. + */ +export async function claimProjectSite( + deps: SiteClaimDeps, + input: { + organizationId: string; + projectId: string; + metadata: Record | null | undefined; + by: string; + }, +): Promise { + const slug = projectSite(input.metadata); + if (!slug) return null; + try { + const outcome = await linkProjectSite(deps, { + slug, + organizationId: input.organizationId, + projectId: input.projectId, + by: input.by, + source: "project-create", + }); + if (outcome.status === "refused") { + console.warn("hosted: project site not linked", { + organizationId: input.organizationId, + projectId: input.projectId, + slug, + reason: outcome.reason, + owner: outcome.owner, + }); + } + return outcome; + } catch (error) { + console.error("hosted: project site link failed", { + organizationId: input.organizationId, + projectId: input.projectId, + slug, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +/** + * Whether deco.cx's admin has a site named `slug`. False on a deployment with + * no deco.cx Supabase (self-host: there is no deco.cx site to protect). Throws + * when the lookup fails or takes longer than `timeoutMs`, so a claim fails + * closed (and project creation is never held up by deco.cx). + */ +export async function decoSiteExists( + slug: string, + timeoutMs = DECO_SITE_LOOKUP_TIMEOUT_MS, +): Promise { + const config = getDecoSupabaseConfig(); + if (!config) return false; + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error("deco.cx site lookup timed out")), + timeoutMs, + ); + }); + try { + const sites = await Promise.race([ + supabaseGet<{ name: string }>( + config.supabaseUrl, + config.serviceKey, + `sites?name=eq.${encodeURIComponent(slug)}&select=name&limit=1`, + ), + timeout, + ]); + return sites.length > 0; + } finally { + clearTimeout(timer); + } +} + +const DECO_SITE_LOOKUP_TIMEOUT_MS = 3_000; + +/** + * `otherOrgNamingSlug` over the `connections` table: the id of an org other + * than `organizationId` with a project (VIRTUAL connection) whose + * `metadata.siteSlug` is `slug`. `metadata` is JSON in a text column, so a + * LIKE narrows the rows and the match is made on the parsed value. + */ +export function otherOrgNamingSlugFromDb(db: Kysely) { + return async (slug: string, organizationId: string) => { + const pattern = `%${slug.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`; + const rows = await db + .selectFrom("connections") + .select(["organization_id", "metadata"]) + .where("connection_type", "=", "VIRTUAL") + .where("organization_id", "<>", organizationId) + .where(sql`metadata::text`, "like", pattern) + .execute(); + for (const row of rows) { + let metadata: Record | null = null; + try { + metadata = + typeof row.metadata === "string" + ? (JSON.parse(row.metadata) as Record) + : (row.metadata as Record | null); + } catch { + continue; + } + if (projectSite(metadata) === slug) return row.organization_id; + } + return null; + }; +} + +/** A project that names a site, for the backfill. */ +export interface SiteClaimCandidate { + organizationId: string; + projectId: string; + slug: string; +} + +export interface SiteClaimBackfillReport { + dryRun: boolean; + linked: SiteClaimCandidate[]; + alreadyLinked: SiteClaimCandidate[]; + refused: (SiteClaimCandidate & { + reason: SiteClaimRefusal; + owner?: string; + })[]; + /** + * Nobody is linked: an unowned slug that projects of more than one org name, + * or a slug that several projects of one org name with none linked yet. + */ + ambiguous: { + slug: string; + organizationIds: string[]; + projectIds: string[]; + }[]; + notV8: SiteClaimCandidate[]; + errors: (SiteClaimCandidate & { error: string })[]; +} + +/** + * Links existing Blocks v8 projects to their sites. Safe to re-run: a link is + * idempotent and never takes a slug another org or project has (reported as + * `refused`). A slug is linked to nobody when it is ambiguous: unowned and + * named by v8 projects of two orgs, or named by several projects of one org. + */ +export async function backfillSiteClaims( + deps: SiteClaimDeps & { + candidates: SiteClaimCandidate[]; + /** Whether the project's main branch is a Blocks v8 site. */ + isV8: (candidate: SiteClaimCandidate) => Promise; + by: string; + dryRun: boolean; + }, +): Promise { + const report: SiteClaimBackfillReport = { + dryRun: deps.dryRun, + linked: [], + alreadyLinked: [], + refused: [], + ambiguous: [], + notV8: [], + errors: [], + }; + const fail = (candidate: SiteClaimCandidate, error: unknown) => + report.errors.push({ + ...candidate, + error: error instanceof Error ? error.message : String(error), + }); + + const v8: SiteClaimCandidate[] = []; + for (const candidate of deps.candidates) { + try { + if (await deps.isV8(candidate)) v8.push(candidate); + else report.notV8.push(candidate); + } catch (error) { + fail(candidate, error); + } + } + + const bySlug = new Map(); + for (const candidate of v8) { + bySlug.set(candidate.slug, [ + ...(bySlug.get(candidate.slug) ?? []), + candidate, + ]); + } + // The backfill decides between projects itself, so the per-project + // "another org names it" rule is replaced by the `ambiguous` one below. + const linkDeps: SiteClaimDeps = { + orgSites: deps.orgSites, + isDecoSite: deps.isDecoSite, + }; + const ambiguous = (named: SiteClaimCandidate[]) => + report.ambiguous.push({ + slug: named[0]!.slug, + organizationIds: [...new Set(named.map((c) => c.organizationId))].sort(), + projectIds: named.map((c) => c.projectId).sort(), + }); + for (const [slug, named] of [...bySlug].sort(([a], [b]) => + a < b ? -1 : a > b ? 1 : 0, + )) { + let row: Awaited>; + try { + row = await deps.orgSites.getBySlug(slug); + } catch (error) { + for (const candidate of named) fail(candidate, error); + continue; + } + const orgs = [...new Set(named.map((c) => c.organizationId))].sort(); + if (orgs.length > 1 && !row) { + ambiguous(named); + continue; + } + for (const organizationId of orgs) { + const projects = named.filter((c) => c.organizationId === organizationId); + // Several projects of one org name it: only an existing link decides. + if ( + projects.length > 1 && + !projects.some((c) => c.projectId === row?.projectId) + ) { + ambiguous(projects); + continue; + } + for (const candidate of projects) { + try { + const outcome = await linkProjectSite(linkDeps, { + slug, + organizationId, + projectId: candidate.projectId, + by: deps.by, + source: "backfill", + dryRun: deps.dryRun, + }); + if (outcome.status === "linked") report.linked.push(candidate); + else if (outcome.status === "already-linked") { + report.alreadyLinked.push(candidate); + } else { + report.refused.push({ + ...candidate, + reason: outcome.reason, + ...(outcome.owner ? { owner: outcome.owner } : {}), + }); + } + } catch (error) { + fail(candidate, error); + } + } + } + } + return report; +} diff --git a/apps/api/src/storage/org-sites-link.integration.test.ts b/apps/api/src/storage/org-sites-link.integration.test.ts index 2ae15cfc1b..0e9c818059 100644 --- a/apps/api/src/storage/org-sites-link.integration.test.ts +++ b/apps/api/src/storage/org-sites-link.integration.test.ts @@ -317,6 +317,26 @@ describe("site slug lifecycle", () => { await create("twice"); expect((await sites.getBySlug("twice"))?.projectId).toBe(first.item.id); }); + + // Hosted (hosted/claim-site.ts): a slug no org owns is claimed, then linked. + it("claims and links a slug nobody owns", async () => { + const { item } = await create("fresh"); + const row = await sites.getBySlug("fresh"); + expect(row?.organizationId).toBe(ORG); + expect(row?.projectId).toBe(item.id); + expect(row?.source).toBe("project-create"); + }); + + it("never reuses a deleted org's slug", async () => { + await claim("gone", OTHER_ORG); + await sql`DELETE FROM organization WHERE id = ${OTHER_ORG}`.execute( + database.db, + ); + await create("gone"); + const row = await sites.getBySlug("gone"); + expect(row?.organizationId).toBeNull(); + expect(row?.projectId).toBeNull(); + }); }); describe("COLLECTION_VIRTUAL_MCP_UPDATE", () => { diff --git a/apps/api/src/tools/virtual/create.ts b/apps/api/src/tools/virtual/create.ts index 6e3a2b0cc5..fb6bc1c334 100644 --- a/apps/api/src/tools/virtual/create.ts +++ b/apps/api/src/tools/virtual/create.ts @@ -18,11 +18,11 @@ import { requireOrgAdminForPinnedField } from "./require-org-admin-for-pin"; import { requireConnectionsInOrganization } from "./require-connections-in-org"; import { writeAgentPrompts } from "../../file-storage/agent-prompts"; import { stripServerManagedMetadata } from "../strip-server-managed-metadata"; -import { isValidSiteSlug } from "@decocms/shared/site-slug"; -import { OrgSiteLinkError } from "../../storage/org-sites"; - -const normalizeSiteSlug = (value: unknown) => - typeof value === "string" ? value.trim().toLowerCase() : ""; +import { + claimProjectSite, + decoSiteExists, + otherOrgNamingSlugFromDb, +} from "../../hosted/claim-site"; /** * Random icon+color for new agents (server-side, no React deps). * Uses the same icon:// format as the client-side agent-icon module. @@ -149,26 +149,26 @@ export const COLLECTION_VIRTUAL_MCP_CREATE = defineTool({ dataWithIcon, ); - // The site slug is set once, here (the import flow passes it). Link it to - // the org's `org_sites` row so the database knows this project's site. - // When the org doesn't own a free row — the slug is another org's, another - // project's here, or unclaimed — the project keeps the stored value - // unlinked (the same storefront may be imported into several orgs). - const siteSlug = normalizeSiteSlug(metadata?.siteSlug); - if (siteSlug && isValidSiteSlug(siteSlug) && virtualMcp.id) { - try { - await ctx.storage.orgSites.link({ - slug: siteSlug, + // The site slug is set once, here (the import flow passes it): link it to + // the project in `org_sites`. A slug no org owns is claimed for this org + // first, unless another org's project already names it or deco.cx has it; + // a reserved (deleted org's) slug, another org's, or another project's + // stays unlinked (see hosted/claim-site.ts). Best-effort: never fails the + // creation. + if (virtualMcp.id) { + await claimProjectSite( + { + orgSites: ctx.storage.orgSites, + isDecoSite: (slug) => decoSiteExists(slug), + otherOrgNamingSlug: otherOrgNamingSlugFromDb(ctx.db), + }, + { organizationId: organization.id, projectId: virtualMcp.id, + metadata, by: userId, - }); - } catch (error) { - if (!(error instanceof OrgSiteLinkError)) throw error; - console.warn( - `[virtual-mcp] project ${virtualMcp.id} keeps site "${siteSlug}" unlinked: ${error.code}`, - ); - } + }, + ); } // Seed kickstart prompts into org-fs so the agent's gateway serves them as