diff --git a/apps/api/migrations/235-org-sites-project-link.integration.test.ts b/apps/api/migrations/235-org-sites-project-link.integration.test.ts new file mode 100644 index 0000000000..e47d8ce1b3 --- /dev/null +++ b/apps/api/migrations/235-org-sites-project-link.integration.test.ts @@ -0,0 +1,255 @@ +/** + * Migration 235 links each org_sites slug to the one project whose + * `metadata.siteSlug` is exactly it, guesses nothing otherwise (no title + * match), and keeps slugs when an org or a + * project is deleted. + */ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { sql } from "kysely"; +import { + closeTestPgDatabase, + connectTestPgDatabase, + resetTestPgDatabase, + seedCommonTestPgFixtures, +} from "../src/database/test-db-pg"; +import type { StudioDatabase } from "../src/database"; +import { OrgSiteLinkError, OrgSiteStorage } from "../src/storage/org-sites"; +import { VirtualMCPStorage } from "../src/storage/virtual"; +import { + applyOrgSiteProjectLinks, + down as down235, + up as up235, +} from "./235-org-sites-project-link"; + +type Row = { + slug: string; + organization_id: string | null; + project_id: string | null; + linked_at: Date | null; + updated_by: string; +}; + +describe("migration 235 — org_sites project link", () => { + let database: StudioDatabase; + // biome-ignore lint/suspicious/noExplicitAny: migrations take an untyped Kysely + let db: any; + + beforeEach(async () => { + database = await connectTestPgDatabase(); + await resetTestPgDatabase(database); + await seedCommonTestPgFixtures(database); + db = database.db; + }); + + afterEach(async () => { + await closeTestPgDatabase(database); + }); + + async function project( + organizationId: string, + title: string, + metadata: Record | null, + ): Promise { + const created = await new VirtualMCPStorage(database.db).create( + organizationId, + "user_1", + { title, metadata, connections: [], status: "active", pinned: false }, + ); + return created.id!; + } + + async function claimOld(slug: string, organizationId: string) { + await sql` + INSERT INTO org_sites (slug, organization_id, source, created_by, updated_by) + VALUES (${slug}, ${organizationId}, 'deco-import', 'user_1', 'user_1') + `.execute(db); + } + + async function setRawMetadata(id: string, raw: string) { + await sql`UPDATE connections SET metadata = ${raw} WHERE id = ${id}`.execute( + db, + ); + } + + async function rows(): Promise> { + const result = await sql` + SELECT slug, organization_id, project_id, linked_at, updated_by + FROM org_sites ORDER BY slug + `.execute(db); + return Object.fromEntries(result.rows.map((r) => [r.slug, r])); + } + + it("links only an exact metadata.siteSlug match, never guesses", async () => { + const byMeta = await project("org_1", "Acme Store", { siteSlug: "acme" }); + // Pre-siteSlug import: the title is the slug of a repo-backed project. + // A title is never a match — the row stays unlinked. + const byTitle = await project("org_1", "Legacy", null); + await setRawMetadata( + byTitle, + JSON.stringify({ githubRepo: { url: "https://github.com/acme/legacy" } }), + ); + // A case/whitespace variant isn't an exact match either. + await setRawMetadata( + await project("org_1", "Variant", null), + JSON.stringify({ siteSlug: " Shouty " }), + ); + // Two projects claim the same slug in one org: ambiguous. + const dupA = await project("org_1", "Twin A", { siteSlug: "twins" }); + const dupB = await project("org_1", "Twin B", { siteSlug: "twins" }); + // Malformed JSON: counted, not linked. + const broken = await project("org_1", "Broken", null); + await setRawMetadata(broken, "{oops"); + // Same slug used by a project in another org: not this org's candidate. + await project("org_456", "Elsewhere", { siteSlug: "nobody" }); + + await down235(db); + await claimOld("acme", "org_1"); + await claimOld("legacy", "org_1"); + await claimOld("shouty", "org_1"); + await claimOld("twins", "org_1"); + await claimOld("broken", "org_1"); + await claimOld("nobody", "org_1"); + + await up235(db); + + const after = await rows(); + expect(after.acme?.project_id).toBe(byMeta); + expect(after.acme?.linked_at).not.toBeNull(); + expect(after.acme?.updated_by).toBe("migration-235"); + for (const slug of ["legacy", "shouty", "broken", "twins", "nobody"]) { + expect(after[slug]?.project_id).toBeNull(); + } + expect([dupA, dupB]).not.toContain(after.twins?.project_id); + + // Every pre-existing slug is a real site: unlinked ones are marked used + // too, so they are never released or moved to another org. + for (const slug of ["legacy", "shouty", "twins", "nobody", "broken"]) { + expect(after[slug]?.linked_at).not.toBeNull(); + expect(after[slug]?.updated_by).toBe("user_1"); + } + const storage = new OrgSiteStorage(database.db); + await expect(storage.releaseSite("nobody", "org_1")).rejects.toThrow( + OrgSiteLinkError, + ); + await expect( + storage.reassignSite({ + slug: "twins", + organizationId: "org_456", + by: "x", + }), + ).rejects.toThrow(OrgSiteLinkError); + // The org links an unlinked slug to one of its projects itself, keeping + // the first use; another org can't. + const resolved = await storage.link({ + slug: "twins", + organizationId: "org_1", + projectId: dupA, + by: "x", + }); + expect(resolved.projectId).toBe(dupA); + expect(resolved.linkedAt).toBe(after.twins!.linked_at!.toISOString()); + const foreign = await project("org_456", "Foreign", null); + await expect( + storage.link({ + slug: "legacy", + organizationId: "org_456", + projectId: foreign, + by: "x", + }), + ).rejects.toThrow(/another organization/); + + // Project rows are only read. + const stored = await sql<{ metadata: string | null }>` + SELECT metadata FROM connections WHERE id = ${byTitle} + `.execute(db); + expect(String(stored.rows[0]?.metadata)).not.toContain("siteSlug"); + }); + + it("deleting an org keeps its slugs as tombstones; deleting a project keeps the slug reserved", async () => { + const kept = await project("org_456", "Kept", { siteSlug: "kept" }); + await down235(db); + await claimOld("kept", "org_456"); + await claimOld("gone-org", "org_456"); + await up235(db); + + const first = (await rows()).kept; + expect(first?.project_id).toBe(kept); + + await sql`DELETE FROM connections WHERE id = ${kept}`.execute(db); + const afterProject = (await rows()).kept; + expect(afterProject?.organization_id).toBe("org_456"); + expect(afterProject?.project_id).toBeNull(); + // linked_at survives: the slug was used, so it is never released or moved. + expect(afterProject?.linked_at).toEqual(first!.linked_at); + + await sql`DELETE FROM organization WHERE id = 'org_456'`.execute(db); + const afterOrg = await rows(); + expect(afterOrg.kept?.organization_id).toBeNull(); + expect(afterOrg["gone-org"]?.organization_id).toBeNull(); + }); + + it("a project has at most one slug", async () => { + const id = await project("org_1", "Solo", { siteSlug: "solo" }); + await down235(db); + await claimOld("solo", "org_1"); + await claimOld("solo-2", "org_1"); + await up235(db); + + await expect( + sql`UPDATE org_sites SET project_id = ${id} WHERE slug = 'solo-2'`.execute( + db, + ), + ).rejects.toThrow(); + }); + + it("applying a plan skips a project deleted since planning", async () => { + const gone = await project("org_1", "Gone", { siteSlug: "gone" }); + await down235(db); + await claimOld("gone", "org_1"); + await up235(db); + await sql`UPDATE org_sites SET project_id = NULL WHERE slug = 'gone'`.execute( + db, + ); + await sql`DELETE FROM connections WHERE id = ${gone}`.execute(db); + expect( + await applyOrgSiteProjectLinks(db, [{ slug: "gone", projectId: gone }]), + ).toBe(0); + expect((await rows()).gone?.project_id).toBeNull(); + }); + + it("down refuses to free tombstones; restores the old shape otherwise", async () => { + await down235(db); + await claimOld("a-site", "org_1"); + await claimOld("b-site", "org_456"); + await up235(db); + await sql`DELETE FROM organization WHERE id = 'org_456'`.execute(db); + + await expect(down235(db)).rejects.toThrow(/tombstoned slugs exist/); + expect((await rows())["b-site"]?.organization_id).toBeNull(); + + // An operator who accepts freeing them deletes them explicitly. + await sql`DELETE FROM org_sites WHERE organization_id IS NULL`.execute(db); + await down235(db); + const columns = await sql<{ column_name: string; is_nullable: string }>` + SELECT column_name, is_nullable FROM information_schema.columns + WHERE table_name = 'org_sites' + `.execute(db); + const byName = Object.fromEntries( + columns.rows.map((c) => [c.column_name, c.is_nullable]), + ); + expect(byName.project_id).toBeUndefined(); + expect(byName.linked_at).toBeUndefined(); + expect(byName.organization_id).toBe("NO"); + const left = await sql<{ + slug: string; + }>`SELECT slug FROM org_sites ORDER BY slug`.execute(db); + expect(left.rows.map((r) => r.slug)).toEqual(["a-site"]); + + // CASCADE is back. + await sql`DELETE FROM organization WHERE id = 'org_1'`.execute(db); + const none = await sql`SELECT 1 FROM org_sites`.execute(db); + expect(none.rows).toHaveLength(0); + + await up235(db); + }); +}); diff --git a/apps/api/migrations/235-org-sites-project-link.ts b/apps/api/migrations/235-org-sites-project-link.ts new file mode 100644 index 0000000000..94f703590d --- /dev/null +++ b/apps/api/migrations/235-org-sites-project-link.ts @@ -0,0 +1,263 @@ +import { type Kysely, sql } from "kysely"; + +/** + * Migration 235 — link each site slug in `org_sites` to the project that uses it. + * + * The site slug is the public id of a site: CDN paths, site tokens, telemetry + * and asset URLs all carry it. Tokens can't be revoked, so a slug must never + * change once a project uses it and must never move to another tenant. Until + * now the project → site link lived only in the project's member-editable + * `metadata.siteSlug` (or, for older imports, its title). This makes the + * database the source of truth: + * + * - `project_id`: the project (a VIRTUAL row in `connections`) the slug belongs + * to. At most one slug per project (partial unique index). `ON DELETE SET + * NULL`, so deleting the project keeps the slug reserved for its org. + * - `linked_at`: set when the slug is first used and never cleared. A used + * slug is never released or moved to another org; after its project is + * deleted, only the same org may link it to another of its projects. Every + * row that exists when this runs is treated as used (`linked_at = + * created_at` when no project is found): each came from a deco.cx import or + * backfill, so it is a real public site that may already have site tokens + * out. + * - `organization_id` becomes nullable with `ON DELETE SET NULL` (was + * `CASCADE`). Deleting an org leaves its slugs behind as tombstones — rows + * with no org that no one can claim — instead of freeing them for reuse. + * + * Backfill: each slug is linked to the single project in its org whose + * `metadata.siteSlug` is exactly the slug. Nothing else is a match — not the + * project title, not a case- or whitespace-variant. If no project or more than + * one matches, the row stays unlinked and is counted; nothing is guessed. + * Project rows are only read, never written. + * + * Locking: Kysely runs pending migrations in one transaction, so every lock + * taken here is held until commit while old pods keep serving. The plan is + * read first (ACCESS SHARE only); the ALTERs then take short exclusive locks + * on `org_sites` and SHARE ROW EXCLUSIVE on `connections` / `organization` + * (the FKs), and the plan is applied in a few set-based UPDATEs. A + * `lock_timeout` makes a blocked ALTER fail fast — the deploy retries — + * instead of queueing every writer behind it. + * + * See unlinked rows: + * SELECT slug, organization_id, source FROM org_sites + * WHERE project_id IS NULL AND organization_id IS NOT NULL; + */ + +const MIGRATION_ACTOR = "migration-235"; +/** Rows per set-based UPDATE when applying the plan. */ +const APPLY_BATCH = 1000; + +export interface OrgSiteBackfillPlan { + links: { slug: string; projectId: string }[]; + none: number; + ambiguous: { slug: string; projectIds: string[] }[]; + unparseableMetadata: number; +} + +async function findOrgFkName(db: Kysely): Promise { + const result = await sql<{ conname: string }>` + SELECT conname FROM pg_constraint + WHERE conrelid = 'org_sites'::regclass + AND confrelid = 'organization'::regclass + AND contype = 'f' + `.execute(db); + return result.rows[0]?.conname ?? null; +} + +/** + * Decide, from reads only, which project each `org_sites` row links to: the + * single project in its org whose `metadata.siteSlug` is exactly the slug. Exported for the test. + */ +export async function planOrgSiteProjectLinks( + db: Kysely, +): Promise { + const plan: OrgSiteBackfillPlan = { + links: [], + none: 0, + ambiguous: [], + unparseableMetadata: 0, + }; + + const sites = await sql<{ slug: string; organization_id: string }>` + SELECT slug, organization_id FROM org_sites + WHERE organization_id IS NOT NULL + ORDER BY organization_id, slug + `.execute(db); + if (sites.rows.length === 0) return plan; + + const projects = await sql<{ + id: string; + organization_id: string; + metadata: string | null; + }>` + SELECT id, organization_id, metadata FROM connections + WHERE connection_type = 'VIRTUAL' + AND organization_id IN ( + SELECT organization_id FROM org_sites WHERE organization_id IS NOT NULL + ) + ORDER BY organization_id, id + `.execute(db); + + // `${org}\0${slug}` → ids of the projects whose `metadata.siteSlug` is it. + const byMetadata = new Map(); + for (const project of projects.rows) { + if (!project.metadata) continue; + let siteSlug: unknown; + try { + const value: unknown = JSON.parse(project.metadata); + if (value && typeof value === "object") { + siteSlug = (value as Record).siteSlug; + } + } catch { + // A malformed row can't name a site. + plan.unparseableMetadata++; + continue; + } + if (typeof siteSlug !== "string" || !siteSlug) continue; + const key = `${project.organization_id}\0${siteSlug}`; + const list = byMetadata.get(key) ?? []; + list.push(project.id); + byMetadata.set(key, list); + } + + for (const { slug, organization_id } of sites.rows) { + const candidates = byMetadata.get(`${organization_id}\0${slug}`) ?? []; + if (candidates.length === 0) { + plan.none++; + } else if (candidates.length > 1) { + plan.ambiguous.push({ slug, projectIds: candidates }); + } else { + plan.links.push({ slug, projectId: candidates[0]! }); + } + } + return plan; +} + +/** + * Apply planned links in set-based batches. A project deleted since planning + * is skipped (the `EXISTS`), not an FK error that aborts the deploy. Returns + * how many rows were linked. + */ +export async function applyOrgSiteProjectLinks( + db: Kysely, + links: OrgSiteBackfillPlan["links"], +): Promise { + let applied = 0; + for (let i = 0; i < links.length; i += APPLY_BATCH) { + const values = sql.join( + links + .slice(i, i + APPLY_BATCH) + .map((l) => sql`(${l.slug}::text, ${l.projectId}::text)`), + ); + const result = await sql` + UPDATE org_sites o + SET project_id = v.pid, + linked_at = now(), + updated_by = ${MIGRATION_ACTOR}, + updated_at = now() + FROM (VALUES ${values}) AS v(slug, pid) + WHERE o.slug = v.slug + AND o.project_id IS NULL + AND EXISTS (SELECT 1 FROM connections c WHERE c.id = v.pid) + `.execute(db); + applied += Number(result.numAffectedRows ?? 0n); + } + return applied; +} + +export async function up(db: Kysely): Promise { + // Fail fast instead of queueing every writer behind a blocked ALTER. + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + + // 1. Plan with reads only, before taking any lock. + const plan = await planOrgSiteProjectLinks(db); + + // 2. Schema. + await sql` + ALTER TABLE org_sites + ADD COLUMN project_id text NULL + REFERENCES connections(id) ON DELETE SET NULL, + ADD COLUMN linked_at timestamptz NULL + `.execute(db); + await sql` + CREATE UNIQUE INDEX org_sites_project_id_uq + ON org_sites (project_id) WHERE project_id IS NOT NULL + `.execute(db); + + // Deleting an org must not free its slugs: CASCADE → SET NULL (tombstone). + const fk = await findOrgFkName(db); + if (fk) { + await sql`ALTER TABLE org_sites DROP CONSTRAINT ${sql.id(fk)}`.execute(db); + } + await sql` + ALTER TABLE org_sites ALTER COLUMN organization_id DROP NOT NULL + `.execute(db); + await sql` + ALTER TABLE org_sites + ADD CONSTRAINT org_sites_organization_id_fkey + FOREIGN KEY (organization_id) REFERENCES organization(id) + ON DELETE SET NULL NOT VALID + `.execute(db); + await sql` + ALTER TABLE org_sites VALIDATE CONSTRAINT org_sites_organization_id_fkey + `.execute(db); + + // 3. Apply the plan. + const linked = await applyOrgSiteProjectLinks(db, plan.links); + + // 4. Every existing slug is a real public site: mark it used, so it is never + // released or moved even when no project could be linked. + const marked = await sql` + UPDATE org_sites SET linked_at = created_at + WHERE organization_id IS NOT NULL AND linked_at IS NULL + `.execute(db); + + await sql`SET LOCAL lock_timeout = DEFAULT`.execute(db); + + console.log( + `[migration 235] org_sites: linked=${linked} ` + + `(planned=${plan.links.length}, ` + + `skipped_deleted_project=${plan.links.length - linked}) ` + + `none=${plan.none} ambiguous=${plan.ambiguous.length} ` + + `unlinked_marked_used=${Number(marked.numAffectedRows ?? 0n)} ` + + `unparseable_project_metadata=${plan.unparseableMetadata}`, + ); + for (const { slug, projectIds } of plan.ambiguous) { + console.log( + `[migration 235] left unlinked, several projects use "${slug}": ${projectIds.join(", ")}`, + ); + } +} + +export async function down(db: Kysely): Promise { + // The old shape can't hold a slug without an org, and dropping a tombstone + // would make a deleted org's slug claimable again — breaking "never reused" + // silently. Refuse; an operator who accepts that deletes them explicitly. + const tombstones = await sql` + SELECT 1 FROM org_sites WHERE organization_id IS NULL LIMIT 1 + `.execute(db); + if (tombstones.rows.length > 0) { + throw new Error( + "migration 235 down: tombstoned slugs exist (org_sites.organization_id IS NULL); refusing to free them", + ); + } + + const fk = await findOrgFkName(db); + if (fk) { + await sql`ALTER TABLE org_sites DROP CONSTRAINT ${sql.id(fk)}`.execute(db); + } + await sql` + ALTER TABLE org_sites ALTER COLUMN organization_id SET NOT NULL + `.execute(db); + await sql` + ALTER TABLE org_sites + ADD CONSTRAINT org_sites_organization_id_fkey + FOREIGN KEY (organization_id) REFERENCES organization(id) + ON DELETE CASCADE + `.execute(db); + + await sql`DROP INDEX IF EXISTS org_sites_project_id_uq`.execute(db); + await sql` + ALTER TABLE org_sites DROP COLUMN project_id, DROP COLUMN linked_at + `.execute(db); +} diff --git a/apps/api/migrations/index.ts b/apps/api/migrations/index.ts index 1fb198a7d2..3131f6cd90 100644 --- a/apps/api/migrations/index.ts +++ b/apps/api/migrations/index.ts @@ -11,6 +11,7 @@ import * as migration231jirachatagent from "./231-jira-chat-agent"; import * as migration232taskboardpromptskills from "./232-task-board-prompt-skills"; import * as migration233removejirachatagent from "./233-remove-jira-chat-agent"; import * as migration234taskboardcommentaudience from "./234-task-board-comment-audience"; +import * as migration235orgsitesprojectlink from "./235-org-sites-project-link"; import * as migration223droporgmainagentid from "./223-drop-org-main-agent-id"; import * as migration214connectionssanitizedididx from "./214-connections-sanitized-id-idx"; import * as migration215commercediscoveryrepository from "./215-commerce-discovery-repository"; @@ -506,6 +507,7 @@ const migrations: Record = { "232-task-board-prompt-skills": migration232taskboardpromptskills, "233-remove-jira-chat-agent": migration233removejirachatagent, "234-task-board-comment-audience": migration234taskboardcommentaudience, + "235-org-sites-project-link": migration235orgsitesprojectlink, }; export default migrations; diff --git a/apps/api/src/api/routes/admin-project-metadata.test.ts b/apps/api/src/api/routes/admin-project-metadata.test.ts index f6880c2080..44a1d5acd2 100644 --- a/apps/api/src/api/routes/admin-project-metadata.test.ts +++ b/apps/api/src/api/routes/admin-project-metadata.test.ts @@ -92,7 +92,10 @@ describe("pickProjectMetadata", () => { }); describe("listSiteProjects", () => { - const owned = new Set(["acme", "acme-tanstack"]); + const owned = new Map([ + ["acme", null], + ["acme-tanstack", null], + ]); it("lists a project whose slug is only its title", () => { expect( @@ -132,6 +135,19 @@ describe("listSiteProjects", () => { ]); }); + it("lists only the linked project when the slug is linked", () => { + const linked = new Map([["acme", "vir_2"]]); + expect( + listSiteProjects( + [ + { id: "vir_1", title: "Copy", metadata: { siteSlug: "acme" } }, + { id: "vir_2", title: "Acme", metadata: { siteSlug: "acme" } }, + ], + linked, + ).map((p) => p.id), + ).toEqual(["vir_2"]); + }); + it("skips projects whose slug the org does not own", () => { expect( listSiteProjects( diff --git a/apps/api/src/api/routes/admin-project-metadata.ts b/apps/api/src/api/routes/admin-project-metadata.ts index dc600c4b35..5ebfae5d69 100644 --- a/apps/api/src/api/routes/admin-project-metadata.ts +++ b/apps/api/src/api/routes/admin-project-metadata.ts @@ -102,6 +102,8 @@ export function pickProjectMetadata( /** * The org's site projects: those whose resolved slug it owns, the same test * experiments use. A migrated project often carries its slug only as its title. + * A slug linked to a project (`ownedSites` maps slug → linked project id) lists + * only that project, not look-alikes naming the same slug. */ export function listSiteProjects( projects: { @@ -109,11 +111,13 @@ export function listSiteProjects( title: string; metadata?: (Record & { siteSlug?: string | null }) | null; }[], - ownedSlugs: ReadonlySet, + ownedSites: ReadonlyMap, ) { return projects.flatMap((project) => { const siteSlug = resolveAgentSiteSlug(project); - if (!siteSlug || !ownedSlugs.has(siteSlug)) return []; + if (!siteSlug || !ownedSites.has(siteSlug)) return []; + const linkedProjectId = ownedSites.get(siteSlug); + if (linkedProjectId && linkedProjectId !== project.id) return []; return [ { id: project.id, diff --git a/apps/api/src/api/routes/admin.ts b/apps/api/src/api/routes/admin.ts index aa062a8b4c..d41d8079a5 100644 --- a/apps/api/src/api/routes/admin.ts +++ b/apps/api/src/api/routes/admin.ts @@ -28,7 +28,11 @@ import { BUILTIN_ROLES, type BuiltinRole } from "@decocms/shared/auth/roles"; import { getDb } from "@/database"; import { OrganizationSettingsStorage } from "@/storage/organization-settings"; import { OrganizationNoticeStorage } from "@/storage/organization-notices"; -import { OrgSiteConflictError, OrgSiteStorage } from "@/storage/org-sites"; +import { + OrgSiteConflictError, + OrgSiteLinkError, + OrgSiteStorage, +} from "@/storage/org-sites"; import { VirtualMCPStorage } from "@/storage/virtual"; import { OrgNoticeInputSchema } from "@decocms/shared/organization/notice"; import { isOrgArchived } from "@decocms/shared/organization/org-archived"; @@ -645,30 +649,47 @@ export function createAdminRoutes(): Hono { audit("org_site_claim", {}); return c.json({ site }); } catch (error) { + if (error instanceof OrgSiteLinkError) { + return c.json({ error: error.code, message: error.message }, 409); + } if (!(error instanceof OrgSiteConflictError)) throw error; // Owned by another org: refuse unless the caller explicitly confirms the move. if (!reassign) { - const owner = await db - .selectFrom("organization") - .select(["name", "slug"]) - .where("id", "=", error.ownerOrganizationId) - .executeTakeFirst(); + const [owner, row] = await Promise.all([ + db + .selectFrom("organization") + .select(["name", "slug"]) + .where("id", "=", error.ownerOrganizationId) + .executeTakeFirst(), + storage.getBySlug(slug), + ]); return c.json( { error: "owned_by_other_org", ownerOrganizationId: error.ownerOrganizationId, ownerOrganizationName: owner?.name ?? null, ownerOrganizationSlug: owner?.slug ?? null, + // A slug a project has used is never moved (its tokens live on). + reassignable: !row?.linkedAt, }, 409, ); } - const site = await storage.reassignSite({ - slug, - organizationId: orgId, - source: "manual", - by: actorId, - }); + let site: Awaited>; + try { + site = await storage.reassignSite({ + slug, + organizationId: orgId, + source: "manual", + by: actorId, + }); + } catch (reassignError) { + if (!(reassignError instanceof OrgSiteLinkError)) throw reassignError; + return c.json( + { error: reassignError.code, message: reassignError.message }, + 409, + ); + } audit("org_site_reassign", { from_organization_id: error.ownerOrganizationId, }); @@ -696,7 +717,13 @@ export function createAdminRoutes(): Hono { return c.json({ error: "Unauthorized" }, 401); } - const released = await new OrgSiteStorage(db).releaseSite(slug, orgId); + let released: boolean; + try { + released = await new OrgSiteStorage(db).releaseSite(slug, orgId); + } catch (error) { + if (!(error instanceof OrgSiteLinkError)) throw error; + return c.json({ error: error.code, message: error.message }, 409); + } if (!released) { return c.json({ error: "Site not found for this organization" }, 404); } @@ -739,7 +766,7 @@ export function createAdminRoutes(): Hono { ]); const projects = listSiteProjects( virtualMcps, - new Set(sites.map((site) => site.slug)), + new Map(sites.map((site) => [site.slug, site.projectId])), ); return c.json({ projects }); }); diff --git a/apps/api/src/storage/org-sites-link.integration.test.ts b/apps/api/src/storage/org-sites-link.integration.test.ts new file mode 100644 index 0000000000..2ae15cfc1b --- /dev/null +++ b/apps/api/src/storage/org-sites-link.integration.test.ts @@ -0,0 +1,443 @@ +/** + * A site slug is the site's public id (CDN paths, tokens, asset URLs): set once + * by the flow that creates or imports the site, linked to one project, never + * changed and never reused. These tests pin that lifecycle at every layer that + * can write it: the org_sites port, the project storage, and the tools. + */ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "bun:test"; +import { sql } from "kysely"; +import { + closeTestPgDatabase, + connectTestPgDatabase, + resetTestPgDatabase, + seedCommonTestPgFixtures, +} from "../database/test-db-pg"; +import type { StudioDatabase } from "../database"; +import type { StudioContext } from "../core/studio-context"; +import { COLLECTION_VIRTUAL_MCP_CREATE } from "../tools/virtual/create"; +import { COLLECTION_VIRTUAL_MCP_UPDATE } from "../tools/virtual/update"; +import { COLLECTION_CONNECTIONS_UPDATE } from "../tools/connection/update"; +import { assertOwnsSite } from "../tools/experiments/ownership"; +import { + OrgSiteConflictError, + OrgSiteLinkError, + OrgSiteStorage, + SiteSlugImmutableError, +} from "./org-sites"; +import { VirtualMCPStorage } from "./virtual"; +import { ConnectionStorage } from "./connection"; +import { CredentialVault } from "../encryption/credential-vault"; + +const ORG = "org_1"; +const OTHER_ORG = "org_456"; +const USER = "user_1"; + +describe("site slug lifecycle", () => { + let database: StudioDatabase; + let sites: OrgSiteStorage; + let projects: VirtualMCPStorage; + + beforeAll(async () => { + database = await connectTestPgDatabase(); + }); + + afterAll(async () => { + await closeTestPgDatabase(database); + }); + + beforeEach(async () => { + await resetTestPgDatabase(database); + await seedCommonTestPgFixtures(database); + sites = new OrgSiteStorage(database.db); + projects = new VirtualMCPStorage(database.db); + }); + + function ctxFor(organizationId: string): StudioContext { + return { + auth: { user: { id: USER, role: "owner" } }, + organization: { id: organizationId, slug: organizationId, role: "owner" }, + access: { check: async () => {}, getRole: () => "owner" }, + db: database.db, + storage: { + virtualMcps: projects, + orgSites: sites, + connections: { + findById: async (id: string) => { + const project = await projects.findById(id); + return project + ? { + id, + organization_id: project.organization_id, + connection_type: "VIRTUAL", + connection_url: `virtual://${id}`, + } + : null; + }, + }, + }, + } as unknown as StudioContext; + } + + async function newProject( + title: string, + metadata: Record | null = null, + organizationId = ORG, + ) { + const created = await projects.create(organizationId, USER, { + title, + metadata, + connections: [], + status: "active", + pinned: false, + }); + return created.id!; + } + + const claim = (slug: string, organizationId = ORG) => + sites.claimSite({ slug, organizationId, by: USER }); + + const link = (slug: string, projectId: string, organizationId = ORG) => + sites.link({ slug, organizationId, projectId, by: USER }); + + async function linkError(promise: Promise) { + try { + await promise; + } catch (error) { + if (error instanceof OrgSiteLinkError) return error.code; + throw error; + } + throw new Error("expected an OrgSiteLinkError"); + } + + describe("OrgSiteStorage.link", () => { + it("links once, idempotently, and getByProject finds it", async () => { + const id = await newProject("Acme"); + await claim("acme"); + const linked = await link("acme", id); + expect(linked.projectId).toBe(id); + expect(linked.linkedAt).not.toBeNull(); + const again = await link("acme", id); + expect(again.linkedAt).toBe(linked.linkedAt); + expect((await sites.getByProject(id))?.slug).toBe("acme"); + }); + + it("refuses every other case", async () => { + const a = await newProject("A"); + const b = await newProject("B"); + const foreign = await newProject("F", null, OTHER_ORG); + await claim("site-a"); + await claim("site-b"); + await claim("theirs", OTHER_ORG); + await link("site-a", a); + + expect(await linkError(link("unclaimed", a))).toBe("not_found"); + expect(await linkError(link("theirs", b))).toBe("not_owned"); + expect(await linkError(link("site-a", b))).toBe("linked_elsewhere"); + expect(await linkError(link("site-b", a))).toBe("project_has_other_slug"); + expect(await linkError(link("site-b", foreign))).toBe( + "project_not_found", + ); + + await sql`DELETE FROM organization WHERE id = ${OTHER_ORG}`.execute( + database.db, + ); + expect(await linkError(link("theirs", b))).toBe("reserved"); + }); + + it("after its project is deleted, the same org relinks it itself; linked_at is kept", async () => { + const first = await newProject("First"); + await claim("relink"); + const linked = await link("relink", first); + await projects.delete(first); + const row = await sites.getBySlug("relink"); + expect(row?.projectId).toBeNull(); + expect(row?.linkedAt).toBe(linked.linkedAt); + + const second = await newProject("Second"); + const relinked = await link("relink", second); + expect(relinked.projectId).toBe(second); + expect(relinked.linkedAt).toBe(linked.linkedAt); + + // Linked again, so every other rule holds. + const third = await newProject("Third"); + expect(await linkError(link("relink", third))).toBe("linked_elsewhere"); + }); + + it("another org never relinks a used slug; a tombstone is nobody's", async () => { + const first = await newProject("First", null, OTHER_ORG); + await claim("theirs", OTHER_ORG); + await link("theirs", first, OTHER_ORG); + await projects.delete(first); + const ours = await newProject("Ours"); + expect(await linkError(link("theirs", ours))).toBe("not_owned"); + await expect(claim("theirs")).rejects.toBeInstanceOf( + OrgSiteConflictError, + ); + + await sql`DELETE FROM organization WHERE id = ${OTHER_ORG}`.execute( + database.db, + ); + expect(await linkError(link("theirs", ours))).toBe("reserved"); + expect(await linkError(claim("theirs"))).toBe("reserved"); + }); + }); + + describe("claim, reassign, release never reuse a used slug", () => { + it("a tombstone can't be claimed or reassigned", async () => { + await claim("orphan", OTHER_ORG); + await sql`DELETE FROM organization WHERE id = ${OTHER_ORG}`.execute( + database.db, + ); + expect((await sites.getBySlug("orphan"))?.organizationId).toBeNull(); + expect(await linkError(claim("orphan"))).toBe("reserved"); + expect( + await linkError( + sites.reassignSite({ slug: "orphan", organizationId: ORG, by: USER }), + ), + ).toBe("reserved"); + expect(await sites.isOwnedBy("orphan", ORG)).toBe(false); + }); + + it("a used slug can't be released or moved; an unused one can", async () => { + const id = await newProject("Used"); + await claim("used"); + await link("used", id); + expect(await linkError(sites.releaseSite("used", ORG))).toBe("in_use"); + await expect(claim("used", OTHER_ORG)).rejects.toBeInstanceOf( + OrgSiteConflictError, + ); + expect( + await linkError( + sites.reassignSite({ + slug: "used", + organizationId: OTHER_ORG, + by: USER, + }), + ), + ).toBe("in_use"); + // Even after its project is gone. + await projects.delete(id); + expect(await linkError(sites.releaseSite("used", ORG))).toBe("in_use"); + + await claim("unused"); + const moved = await sites.reassignSite({ + slug: "unused", + organizationId: OTHER_ORG, + by: USER, + }); + expect(moved.organizationId).toBe(OTHER_ORG); + expect(await sites.releaseSite("unused", OTHER_ORG)).toBe(true); + expect(await sites.releaseSite("unused", OTHER_ORG)).toBe(false); + }); + }); + + describe("VirtualMCPStorage", () => { + it("reads a project's site from its link", async () => { + // A project linked through the normal claim/link path. + const id = await newProject("legacy-site"); + await claim("legacy-site"); + await link("legacy-site", id); + expect((await projects.findById(id))?.metadata?.siteSlug).toBe( + "legacy-site", + ); + const listed = (await projects.list(ORG)).find((p) => p.id === id); + expect(listed?.metadata?.siteSlug).toBe("legacy-site"); + }); + + it("keeps the slug through metadata rewrites and refuses a different one", async () => { + const id = await newProject("Acme", { siteSlug: "acme" }); + // Rebuilt from scratch without the key (sandbox start, reports setup…). + await projects.update(id, USER, { metadata: { instructions: "hi" } }); + expect((await projects.findById(id))?.metadata?.siteSlug).toBe("acme"); + await projects.update(id, USER, { metadata: null }); + expect((await projects.findById(id))?.metadata?.siteSlug).toBe("acme"); + await expect( + projects.update(id, USER, { metadata: { siteSlug: "other" } }), + ).rejects.toBeInstanceOf(SiteSlugImmutableError); + }); + + it("patchMetadata never writes the site slug", async () => { + const id = await newProject("Acme", { siteSlug: "acme" }); + const patch = (set: Record, unset: string[] = []) => + projects.patchMetadata({ + id, + organizationId: ORG, + set, + unset, + by: USER, + }); + await expect(patch({ siteSlug: "other" })).rejects.toBeInstanceOf( + SiteSlugImmutableError, + ); + await expect(patch({}, ["siteSlug"])).rejects.toBeInstanceOf( + SiteSlugImmutableError, + ); + expect(await patch({ analyticsSiteSlug: "acme" })).toBe(true); + expect((await projects.findById(id))?.metadata?.siteSlug).toBe("acme"); + }); + }); + + describe("COLLECTION_VIRTUAL_MCP_CREATE", () => { + const create = (siteSlug: string, organizationId = ORG) => + COLLECTION_VIRTUAL_MCP_CREATE.handler( + { + data: { + title: siteSlug, + metadata: { siteSlug }, + connections: [], + status: "active", + pinned: false, + }, + }, + ctxFor(organizationId), + ); + + it("links the slug when the org owns a free row", async () => { + await claim("imported"); + const { item } = await create("imported"); + expect((await sites.getBySlug("imported"))?.projectId).toBe(item.id); + }); + + it("keeps the slug unlinked when the org can't link it", async () => { + await claim("theirs", OTHER_ORG); + const { item } = await create("theirs"); + expect(item.metadata?.siteSlug).toBe("theirs"); + expect((await sites.getBySlug("theirs"))?.projectId).toBeNull(); + + // A second import of the same site in one org: the first keeps the link. + await claim("twice"); + const first = await create("twice"); + await create("twice"); + expect((await sites.getBySlug("twice"))?.projectId).toBe(first.item.id); + }); + }); + + describe("COLLECTION_VIRTUAL_MCP_UPDATE", () => { + const update = (id: string, data: Record) => + COLLECTION_VIRTUAL_MCP_UPDATE.handler({ id, data }, ctxFor(ORG)); + + it("refuses changing or clearing a linked slug", async () => { + const id = await newProject("Acme", { siteSlug: "acme" }); + await claim("acme"); + await link("acme", id); + await expect( + update(id, { metadata: { siteSlug: "acme-2" } }), + ).rejects.toThrow(/site id can't change/); + await expect( + update(id, { metadata: { siteSlug: null } }), + ).rejects.toThrow(SiteSlugImmutableError); + expect((await sites.getBySlug("acme"))?.projectId).toBe(id); + }); + + it("refuses changing an unlinked slug", async () => { + const id = await newProject("Shop", { siteSlug: "shop" }); + await expect( + update(id, { metadata: { siteSlug: "shop-2" } }), + ).rejects.toBeInstanceOf(SiteSlugImmutableError); + }); + + it("refuses setting a slug on a project that has none", async () => { + const id = await newProject("Plain agent"); + await expect( + update(id, { metadata: { siteSlug: "grab" } }), + ).rejects.toBeInstanceOf(SiteSlugImmutableError); + }); + + it("accepts the same slug, other keys, metadata: null and renames", async () => { + const id = await newProject("Acme", { siteSlug: "acme" }); + await update(id, { + metadata: { siteSlug: "acme", instructions: "Be nice" }, + }); + await update(id, { metadata: { instructions: "Be nicer" } }); + await update(id, { metadata: null }); + await update(id, { title: "Acme Renamed" }); + const after = await projects.findById(id); + expect(after?.metadata?.siteSlug).toBe("acme"); + expect(after?.title).toBe("Acme Renamed"); + }); + + it("a rename of a title-slug project pins the slug instead of moving it", async () => { + const id = await newProject("legacy"); + await update(id, { title: "Brand new name" }); + expect((await projects.findById(id))?.metadata?.siteSlug).toBe("legacy"); + await expect( + update(id, { metadata: { siteSlug: "brand-new-name" } }), + ).rejects.toBeInstanceOf(SiteSlugImmutableError); + }); + }); + + describe("COLLECTION_CONNECTIONS_UPDATE on a project row", () => { + it("refuses rewriting the project's site slug", async () => { + const id = await newProject("Acme", { siteSlug: "acme" }); + await expect( + COLLECTION_CONNECTIONS_UPDATE.handler( + { id, data: { metadata: { siteSlug: "hijack" } } }, + ctxFor(ORG), + ), + ).rejects.toBeInstanceOf(SiteSlugImmutableError); + }); + + it("a rename of a title-slug project pins the slug instead of moving it", async () => { + const id = await newProject("legacy"); + const vault = new CredentialVault(CredentialVault.generateKey()); + const base = ctxFor(ORG); + const ctx = { + ...base, + vault, + storage: { + ...base.storage, + connections: new ConnectionStorage(database.db, vault), + }, + } as unknown as StudioContext; + await COLLECTION_CONNECTIONS_UPDATE.handler( + { id, data: { title: "Brand new name" } }, + ctx, + ); + const after = await projects.findById(id); + expect(after?.title).toBe("Brand new name"); + expect(after?.metadata?.siteSlug).toBe("legacy"); + }); + }); + + describe("experiments ownership", () => { + it("follows the link, not a look-alike project", async () => { + const linked = await newProject("Real", { siteSlug: "real" }); + await claim("real"); + await link("real", linked); + // Same org, a second project claiming the slug: the link decides. + await newProject("Copy", { siteSlug: "real" }); + await expect(assertOwnsSite(ctxFor(ORG), ORG, "real")).resolves.toBe( + undefined, + ); + + // Another org's project naming a slug linked here owns nothing. + await newProject("Thief", { siteSlug: "real" }, OTHER_ORG); + await expect( + assertOwnsSite(ctxFor(OTHER_ORG), OTHER_ORG, "real"), + ).rejects.toThrow(/Site not found/); + + // A tombstone (its org was deleted) is nobody's, look-alikes included. + await claim("orphan", OTHER_ORG); + await sql`DELETE FROM organization WHERE id = ${OTHER_ORG}`.execute( + database.db, + ); + await newProject("orphan"); + await expect(assertOwnsSite(ctxFor(ORG), ORG, "orphan")).rejects.toThrow( + /Site not found/, + ); + + // Unlinked projects keep the legacy match. + await newProject("v7-site"); + await expect(assertOwnsSite(ctxFor(ORG), ORG, "v7-site")).resolves.toBe( + undefined, + ); + }); + }); +}); diff --git a/apps/api/src/storage/org-sites.ts b/apps/api/src/storage/org-sites.ts index 4ead6a4863..e52803861c 100644 --- a/apps/api/src/storage/org-sites.ts +++ b/apps/api/src/storage/org-sites.ts @@ -1,4 +1,4 @@ -import type { Kysely, Selectable } from "kysely"; +import { type Kysely, type Selectable, sql } from "kysely"; import { isValidSiteSlug } from "@decocms/shared/site-slug"; import type { Database, OrgSite, OrgSiteTable } from "./types"; import type { OrgSiteStoragePort } from "./ports"; @@ -9,6 +9,66 @@ import type { OrgSiteStoragePort } from "./ports"; * prefix-scoped STS credentials for a slug only if its org owns that slug here. */ +/** + * The site id (slug) is public — CDN paths, site tokens, telemetry and asset + * URLs carry it — and tokens can't be revoked. So once a slug is used + * (`linked_at` set: a project linked it, or it predates migration 235) it is + * tied to that org for good: it can't be released or moved to another org. + * Once its project is deleted, the same org may link it to another of its + * projects; another org never can (operators handle that by hand in the DB). + * A row whose org was deleted (`organization_id` NULL) is a tombstone nobody + * can claim or link. + */ +const SITE_ID_IMMUTABLE_MESSAGE = + "The site id can't change: CDN paths, tokens and asset URLs use it."; + +/** A write that would change (or clear) a project's site slug once it has one. */ +export class SiteSlugImmutableError extends Error { + readonly code = "SITE_SLUG_IMMUTABLE"; + constructor() { + super(SITE_ID_IMMUTABLE_MESSAGE); + this.name = "SiteSlugImmutableError"; + } +} + +export type OrgSiteLinkErrorCode = + /** No `org_sites` row for the slug. */ + | "not_found" + /** Tombstone: its org was deleted; the slug is reserved forever. */ + | "reserved" + /** Another org owns the slug. */ + | "not_owned" + /** The slug is the site of another project. */ + | "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 slug has been used by a project, so it can't be released or moved. */ + | "in_use"; + +const LINK_ERROR_MESSAGES: Record = { + not_found: "This site id isn't registered for the organization.", + reserved: + "This site id belonged to a deleted organization and stays reserved.", + not_owned: "This site id belongs to another organization.", + linked_elsewhere: "This site id is already the site of another project.", + project_has_other_slug: `This project already has a site id. ${SITE_ID_IMMUTABLE_MESSAGE}`, + project_not_found: "Project not found in this organization.", + in_use: `This site id has been used by a project, so it can't be released or moved. ${SITE_ID_IMMUTABLE_MESSAGE}`, +}; + +/** A link, claim, release or move the slug's lifecycle rules refuse. */ +export class OrgSiteLinkError extends Error { + constructor( + public readonly code: OrgSiteLinkErrorCode, + public readonly slug: string, + ) { + super(LINK_ERROR_MESSAGES[code]); + this.name = "OrgSiteLinkError"; + } +} + /** Thrown when a slug is already claimed by a different organization. */ export class OrgSiteConflictError extends Error { constructor( @@ -29,6 +89,8 @@ function toEntity(row: Selectable): OrgSite { slug: row.slug, organizationId: row.organization_id, source: row.source, + projectId: row.project_id, + linkedAt: row.linked_at ? toIso(row.linked_at) : null, createdBy: row.created_by, createdAt: toIso(row.created_at), updatedBy: row.updated_by, @@ -76,6 +138,9 @@ export class OrgSiteStorage implements OrgSiteStoragePort { // Lost a race then the row vanished; surface as conflict rather than loop. throw new OrgSiteConflictError(params.slug, "unknown"); } + if (existing.organizationId === null) { + throw new OrgSiteLinkError("reserved", params.slug); + } if (existing.organizationId !== params.organizationId) { throw new OrgSiteConflictError(params.slug, existing.organizationId); } @@ -107,7 +172,8 @@ export class OrgSiteStorage implements OrgSiteStoragePort { ); } const now = new Date(); - // Unconditional override (deployment-admin only): take the slug from whatever org owns it. + // Deployment-admin override: take the slug from whatever org owns it — + // unless it is a tombstone or a project has used it (never reused). const row = await this.db .insertInto("org_sites") .values({ @@ -120,16 +186,26 @@ export class OrgSiteStorage implements OrgSiteStoragePort { updated_at: now, }) .onConflict((oc) => - oc.column("slug").doUpdateSet({ - organization_id: params.organizationId, - source: params.source ?? "manual", - updated_by: params.by, - updated_at: now, - }), + oc + .column("slug") + .doUpdateSet({ + organization_id: params.organizationId, + source: params.source ?? "manual", + updated_by: params.by, + updated_at: now, + }) + .where("org_sites.organization_id", "is not", null) + .where("org_sites.linked_at", "is", null), ) .returningAll() - .executeTakeFirstOrThrow(); - return toEntity(row); + .executeTakeFirst(); + if (row) return toEntity(row); + + const existing = await this.getBySlug(params.slug); + if (existing?.organizationId === null) { + throw new OrgSiteLinkError("reserved", params.slug); + } + throw new OrgSiteLinkError("in_use", params.slug); } async releaseSite(slug: string, organizationId: string): Promise { @@ -137,8 +213,97 @@ export class OrgSiteStorage implements OrgSiteStoragePort { .deleteFrom("org_sites") .where("slug", "=", slug) .where("organization_id", "=", organizationId) + .where("linked_at", "is", null) + .executeTakeFirst(); + if (Number(res.numDeletedRows ?? 0n) > 0) return true; + if (await this.isOwnedBy(slug, organizationId)) { + throw new OrgSiteLinkError("in_use", slug); + } + return false; + } + + async getByProject(projectId: string): Promise { + const row = await this.db + .selectFrom("org_sites") + .selectAll() + .where("project_id", "=", projectId) + .executeTakeFirst(); + return row ? toEntity(row) : null; + } + + async link(params: { + slug: string; + organizationId: string; + projectId: string; + by: string; + }): Promise { + const { slug, organizationId, projectId, by } = params; + const existing = await this.getBySlug(slug); + if (!existing) throw new OrgSiteLinkError("not_found", slug); + if (existing.organizationId === null) { + throw new OrgSiteLinkError("reserved", slug); + } + if (existing.organizationId !== organizationId) { + throw new OrgSiteLinkError("not_owned", slug); + } + if (existing.projectId === projectId) return existing; + if (existing.projectId !== null) { + throw new OrgSiteLinkError("linked_elsewhere", slug); + } + // Unlinked: never used, or used and its project was deleted (or it + // predates the link). Either way it is this org's to link — `linked_at` + // keeps the first use. + + const project = await this.db + .selectFrom("connections") + .select("id") + .where("id", "=", projectId) + .where("organization_id", "=", organizationId) + .where("connection_type", "=", "VIRTUAL") .executeTakeFirst(); - return Number(res.numDeletedRows ?? 0n) > 0; + if (!project) throw new OrgSiteLinkError("project_not_found", slug); + + const current = await this.getByProject(projectId); + if (current && current.slug !== slug) { + throw new OrgSiteLinkError("project_has_other_slug", slug); + } + + const now = new Date(); + let row: Selectable | undefined; + try { + row = await this.db + .updateTable("org_sites") + .set({ + project_id: projectId, + linked_at: sql`coalesce(linked_at, now())`, + updated_by: by, + updated_at: now, + }) + .where("slug", "=", slug) + .where("organization_id", "=", organizationId) + .where("project_id", "is", null) + .returningAll() + .executeTakeFirst(); + } catch (error) { + // org_sites_project_id_uq: a concurrent link gave the project a site. + if ((error as { code?: string }).code === "23505") { + throw new OrgSiteLinkError("project_has_other_slug", slug); + } + throw error; + } + if (row) return toEntity(row); + + // Lost a race: re-read for the precise reason. + const after = await this.getBySlug(slug); + if (after?.projectId === projectId) return after; + if (!after) throw new OrgSiteLinkError("not_found", slug); + if (after.organizationId === null) { + throw new OrgSiteLinkError("reserved", slug); + } + if (after.organizationId !== organizationId) { + throw new OrgSiteLinkError("not_owned", slug); + } + throw new OrgSiteLinkError("linked_elsewhere", slug); } async getBySlug(slug: string): Promise { diff --git a/apps/api/src/storage/ports.ts b/apps/api/src/storage/ports.ts index 1d54fb9dda..735fa8cf32 100644 --- a/apps/api/src/storage/ports.ts +++ b/apps/api/src/storage/ports.ts @@ -744,7 +744,8 @@ export interface OrganizationJoinRequestStoragePort { export interface OrgSiteStoragePort { /** * Claim a globally-unique site slug for an organization. Idempotent for the - * same org; throws OrgSiteConflictError if a different org already owns it. + * same org; throws OrgSiteConflictError if a different org already owns it + * and OrgSiteLinkError("reserved") for a tombstone. */ claimSite(params: { slug: string; @@ -753,8 +754,9 @@ export interface OrgSiteStoragePort { by: string; }): Promise; /** - * Move a slug to `organizationId` regardless of its current owner - * (deployment-admin override); insert it when unclaimed. + * Move a slug to `organizationId` from its current owner (deployment-admin + * override); insert it when unclaimed. Throws OrgSiteLinkError for a + * tombstone ("reserved") or a slug a project has used ("in_use"). */ reassignSite(params: { slug: string; @@ -762,9 +764,28 @@ export interface OrgSiteStoragePort { source?: string; by: string; }): Promise; - /** Release a slug owned by this org; false when it wasn't owned by it. */ + /** + * Release a slug owned by this org; false when it wasn't owned by it. + * Throws OrgSiteLinkError("in_use") once a project has used it (never reused). + */ releaseSite(slug: string, organizationId: string): Promise; getBySlug(slug: string): Promise; + /** The slug linked to this project, if any. */ + getByProject(projectId: string): Promise; + /** + * Make `slug` the site of `projectId`, once. Idempotent for the same pair; + * throws OrgSiteLinkError when the slug isn't this org's (not_found, + * reserved, not_owned), is another project's (linked_elsewhere), or the + * project already has a different site (project_has_other_slug). A slug + * whose project was deleted stays the org's: the same org may link it to + * another of its projects; another org never can. + */ + link(params: { + slug: string; + organizationId: string; + projectId: string; + by: string; + }): Promise; /** Every slug this org owns, slug-ascending. */ listByOrg(organizationId: string): Promise; /** Authorization primitive: does this org own this slug? */ diff --git a/apps/api/src/storage/types.ts b/apps/api/src/storage/types.ts index aade37f778..18e82346fe 100644 --- a/apps/api/src/storage/types.ts +++ b/apps/api/src/storage/types.ts @@ -1558,9 +1558,22 @@ export interface OrgSiteTable { // Globally-unique site slug = object-key prefix namespace in the shared // tenant bucket. Primary key enforces global uniqueness across orgs. slug: string; - organization_id: string; + // NULL = tombstone: the org was deleted and the slug stays reserved forever. + organization_id: ColumnType; // Provenance of the claim: 'deco-import' (migrated) or 'manual'. source: ColumnType; + // The project (VIRTUAL connection) this slug is the site of; one per project. + project_id: ColumnType< + string | null, + string | null | undefined, + string | null + >; + // First time the slug was linked to a project; never cleared. + linked_at: ColumnType< + Date | null, + Date | string | null | undefined, + Date | string | null + >; created_by: string; created_at: ColumnType; updated_by: string; @@ -1569,8 +1582,13 @@ export interface OrgSiteTable { export interface OrgSite { slug: string; - organizationId: string; + /** Null for a tombstone (its org was deleted): reserved, nobody can claim it. */ + organizationId: string | null; source: string; + /** The project this slug is the site of, if linked. */ + projectId: string | null; + /** When the slug was first linked to a project; set once, never cleared. */ + linkedAt: string | null; createdBy: string; createdAt: string; updatedBy: string; diff --git a/apps/api/src/storage/virtual.ts b/apps/api/src/storage/virtual.ts index 6418eeedd8..635e0303c6 100644 --- a/apps/api/src/storage/virtual.ts +++ b/apps/api/src/storage/virtual.ts @@ -29,6 +29,7 @@ import type { } from "./ports"; import type { Database, DependencyMode } from "./types"; import { pruneOrphanedUiRefs } from "./prune-orphaned-ui-refs"; +import { SiteSlugImmutableError } from "./org-sites"; /** Raw database row type for connections (VIRTUAL type) */ type RawConnectionRow = { @@ -98,6 +99,25 @@ function boundRepositoryId( return typeof bound === "string" && bound.length > 0 ? bound : null; } +/** + * `metadata` with `siteSlug` pinned to the project's current slug. A different + * non-empty value throws; an absent or empty one is restored. + */ +function withUnchangedSiteSlug( + metadata: Record | null, + siteSlug: string, +): Record { + const incoming = metadata?.siteSlug; + if ( + typeof incoming === "string" && + incoming.trim() && + incoming.trim().toLowerCase() !== siteSlug + ) { + throw new SiteSlugImmutableError(); + } + return { ...metadata, siteSlug }; +} + export class VirtualMCPStorage implements VirtualMCPStoragePort { constructor(private db: Kysely) {} @@ -230,10 +250,65 @@ export class VirtualMCPStorage implements VirtualMCPStoragePort { .where("dependency_mode", "=", "direct") .execute(); - return this.deserializeVirtualMCPEntity( - row as unknown as RawConnectionRow, - aggregationRows as RawAggregationRow[], - ); + const [entity] = await this.withLinkedSiteSlugs(db, [ + this.deserializeVirtualMCPEntity( + row as unknown as RawConnectionRow, + aggregationRows as RawAggregationRow[], + ), + ]); + return entity ?? null; + } + + /** + * Overlay each project's site slug from its `org_sites` link onto + * `metadata.siteSlug`. The link is the source of truth; the stored key is a + * mirror kept for the many readers of `metadata.siteSlug` (and for projects + * not linked yet, whose stored value still applies). + */ + private async withLinkedSiteSlugs( + db: Kysely, + entities: VirtualMCPEntity[], + ): Promise { + const ids = entities.flatMap((e) => (e.id ? [e.id] : [])); + if (ids.length === 0) return entities; + const links = await db + .selectFrom("org_sites") + .select(["slug", "project_id"]) + .where("project_id", "in", ids) + .execute(); + if (links.length === 0) return entities; + const slugByProject = new Map(links.map((l) => [l.project_id, l.slug])); + return entities.map((entity) => { + const slug = entity.id ? slugByProject.get(entity.id) : undefined; + return slug + ? { ...entity, metadata: { ...entity.metadata, siteSlug: slug } } + : entity; + }); + } + + /** The project's current site slug: its link, else its stored value. */ + private async currentSiteSlug( + db: Kysely, + id: string, + ): Promise { + const link = await db + .selectFrom("org_sites") + .select("slug") + .where("project_id", "=", id) + .executeTakeFirst(); + if (link) return link.slug; + const row = await db + .selectFrom("connections") + .select("metadata") + .where("id", "=", id) + .where("connection_type", "=", "VIRTUAL") + .executeTakeFirst(); + const stored = this.parseJson<{ siteSlug?: unknown }>( + row?.metadata ?? null, + )?.siteSlug; + return typeof stored === "string" && stored.trim() + ? stored.trim().toLowerCase() + : null; } /** @@ -357,10 +432,13 @@ export class VirtualMCPStorage implements VirtualMCPStoragePort { aggregationsByParent.set(agg.parent_connection_id, existing); } - return rows.map((row) => - this.deserializeVirtualMCPEntity( - row as unknown as RawConnectionRow, - aggregationsByParent.get(row.id) ?? [], + return this.withLinkedSiteSlugs( + this.db, + rows.map((row) => + this.deserializeVirtualMCPEntity( + row as unknown as RawConnectionRow, + aggregationsByParent.get(row.id) ?? [], + ), ), ); } @@ -397,10 +475,13 @@ export class VirtualMCPStorage implements VirtualMCPStoragePort { aggregationsByParent.set(agg.parent_connection_id, existing); } - return rows.map((row) => - this.deserializeVirtualMCPEntity( - row as unknown as RawConnectionRow, - aggregationsByParent.get(row.id) ?? [], + return this.withLinkedSiteSlugs( + this.db, + rows.map((row) => + this.deserializeVirtualMCPEntity( + row as unknown as RawConnectionRow, + aggregationsByParent.get(row.id) ?? [], + ), ), ); } @@ -453,10 +534,13 @@ export class VirtualMCPStorage implements VirtualMCPStoragePort { aggregationsByParent.set(agg.parent_connection_id, existing); } - return rows.map((row) => - this.deserializeVirtualMCPEntity( - row as RawConnectionRow, - aggregationsByParent.get(row.id) ?? [], + return this.withLinkedSiteSlugs( + this.db, + rows.map((row) => + this.deserializeVirtualMCPEntity( + row as RawConnectionRow, + aggregationsByParent.get(row.id) ?? [], + ), ), ); } @@ -490,10 +574,17 @@ export class VirtualMCPStorage implements VirtualMCPStoragePort { updateData.pinned = data.pinned; } if (data.metadata !== undefined) { + // The site slug is immutable once set: keep it through any metadata + // write (callers rebuild metadata from scratch, or clear it) and refuse + // one that would change it. Tools check first, with a clearer error. + const siteSlug = await this.currentSiteSlug(this.db, id); + const metadata = siteSlug + ? withUnchangedSiteSlug(data.metadata, siteSlug) + : data.metadata; // Dual-write, in step with the JSON — see `boundRepositoryId`. - updateData.repository_id = boundRepositoryId(data.metadata); - updateData.metadata = data.metadata - ? JSON.stringify(writeRepositoryMetadata(data.metadata)) + updateData.repository_id = boundRepositoryId(metadata); + updateData.metadata = metadata + ? JSON.stringify(writeRepositoryMetadata(metadata)) : null; } @@ -587,6 +678,14 @@ export class VirtualMCPStorage implements VirtualMCPStoragePort { unset: string[]; by: string; }): Promise { + // The site slug is set once, by the create/import flow; a single-key + // patch never touches it (defence in depth behind the admin allow-list). + if ( + Object.hasOwn(params.set, "siteSlug") || + params.unset.includes("siteSlug") + ) { + throw new SiteSlugImmutableError(); + } // Compatibility: remove both stored spellings before replacing or clearing // a binding, including rows written with canonical keys during migration. const removedKeys = [ diff --git a/apps/api/src/tools/connection/update.ts b/apps/api/src/tools/connection/update.ts index a3a09f395a..aed9685445 100644 --- a/apps/api/src/tools/connection/update.ts +++ b/apps/api/src/tools/connection/update.ts @@ -24,6 +24,8 @@ import { validateConfiguration, } from "./credential-grants"; import { fetchToolsFromMCP } from "./fetch-tools"; +import { assertSiteSlugUnchanged } from "../virtual/site-slug-guard"; +import { pinnedSiteSlugOnRename } from "../virtual/pin-site-slug"; import { buildVirtualUrl, type ConnectionEntity, @@ -140,6 +142,34 @@ export const COLLECTION_CONNECTIONS_UPDATE = defineTool({ throw new Error("Connection not found in organization"); } + // A VIRTUAL row is a project: its metadata carries the immutable site slug, + // which this generic write must neither change nor drop — nor move by a + // rename, for a legacy project whose slug is still its title. + if ( + existing.connection_type === "VIRTUAL" && + (data.metadata !== undefined || data.title !== undefined) + ) { + const project = await ctx.storage.virtualMcps.findById(existing.id); + if (project) { + if (data.metadata !== undefined) { + assertSiteSlugUnchanged(project, data.metadata); + const siteSlug = project.metadata?.siteSlug; + if (siteSlug) data.metadata = { ...data.metadata, siteSlug }; + } + const pinned = pinnedSiteSlugOnRename({ + nextTitle: data.title, + currentTitle: project.title, + currentSiteSlug: project.metadata?.siteSlug, + }); + if (pinned) { + data.metadata = { + ...(data.metadata ?? project.metadata ?? {}), + siteSlug: pinned, + }; + } + } + } + // Validate VIRTUAL connections if connection_type or connection_url is being updated const finalConnectionType = data.connection_type ?? existing.connection_type; diff --git a/apps/api/src/tools/experiments/ownership.ts b/apps/api/src/tools/experiments/ownership.ts index f09d3109ab..8c49fa394d 100644 --- a/apps/api/src/tools/experiments/ownership.ts +++ b/apps/api/src/tools/experiments/ownership.ts @@ -4,13 +4,33 @@ import { resolveAnalyticsSiteSlug, } from "@decocms/shared/site-slug"; +/** + * The org's project whose site is `site`. A slug linked in `org_sites` names + * its project directly; a slug linked to another org's project — or a + * tombstone (its org was deleted) — is never this org's. Projects not linked + * yet (several imports of one storefront in an org, slugs never claimed) still + * match by resolved slug. + * + * Known gap, kept for v7 compatibility: an unlinked slug owned by another org + * still matches this org's look-alike project. + */ async function findOwnedProject( ctx: StudioContext, organizationId: string, site: string, ) { - const vms = await ctx.storage.virtualMcps.list(organizationId); - const project = vms.find((vm) => resolveAgentSiteSlug(vm) === site); + const [vms, row] = await Promise.all([ + ctx.storage.virtualMcps.list(organizationId), + ctx.storage.orgSites.getBySlug(site), + ]); + if (row && row.organizationId === null) { + throw new Error("Site not found in organization"); + } + const project = row?.projectId + ? row.organizationId === organizationId + ? vms.find((vm) => vm.id === row.projectId) + : undefined + : vms.find((vm) => resolveAgentSiteSlug(vm) === site); if (!project) { throw new Error("Site not found in organization"); } @@ -18,8 +38,8 @@ async function findOwnedProject( } /** - * Fail unless the org owns a project (VIRTUAL connection) whose resolved site - * slug is `site` — a Studio site lives in `connections`, not `org_sites`. + * Fail unless the org owns a project (VIRTUAL connection) whose site is + * `site` — its `org_sites` link, else its resolved site slug. */ export async function assertOwnsSite( ctx: StudioContext, diff --git a/apps/api/src/tools/virtual/create.ts b/apps/api/src/tools/virtual/create.ts index b1717fe604..6e3a2b0cc5 100644 --- a/apps/api/src/tools/virtual/create.ts +++ b/apps/api/src/tools/virtual/create.ts @@ -18,6 +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() : ""; /** * Random icon+color for new agents (server-side, no React deps). * Uses the same icon:// format as the client-side agent-icon module. @@ -144,6 +149,28 @@ 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, + organizationId: organization.id, + projectId: virtualMcp.id, + 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 // native MCP prompts (icebreakers). Best-effort: never fail agent creation // over a prompt write. diff --git a/apps/api/src/tools/virtual/site-slug-guard.test.ts b/apps/api/src/tools/virtual/site-slug-guard.test.ts new file mode 100644 index 0000000000..57682cfc0b --- /dev/null +++ b/apps/api/src/tools/virtual/site-slug-guard.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "bun:test"; +import { SiteSlugImmutableError } from "../../storage/org-sites"; +import { assertSiteSlugUnchanged } from "./site-slug-guard"; + +const site = { title: "Acme Store", metadata: { siteSlug: "acme" } }; +const plain = { title: "Marketing helper", metadata: {} }; +const legacy = { title: "legacy-shop", metadata: null }; + +const refused = ( + project: Parameters[0], + metadata: Record | null | undefined, +) => { + try { + assertSiteSlugUnchanged(project, metadata); + return false; + } catch (error) { + if (error instanceof SiteSlugImmutableError) return true; + throw error; + } +}; + +describe("assertSiteSlugUnchanged", () => { + it("lets writes that don't touch the slug through", () => { + expect(refused(site, undefined)).toBe(false); + expect(refused(site, null)).toBe(false); + expect(refused(site, { instructions: "x" })).toBe(false); + }); + + it("lets the same slug through, case and spaces aside", () => { + expect(refused(site, { siteSlug: "acme" })).toBe(false); + expect(refused(site, { siteSlug: " ACME " })).toBe(false); + }); + + it("refuses changing or clearing a slug", () => { + expect(refused(site, { siteSlug: "acme-2" })).toBe(true); + expect(refused(site, { siteSlug: null })).toBe(true); + expect(refused(site, { siteSlug: "" })).toBe(true); + }); + + it("refuses setting a slug on a project without one", () => { + expect(refused(plain, { siteSlug: "grab" })).toBe(true); + expect(refused(plain, { siteSlug: null })).toBe(false); + }); + + it("treats a legacy title slug as the current slug", () => { + expect(refused(legacy, { siteSlug: "legacy-shop" })).toBe(false); + expect(refused(legacy, { siteSlug: "other" })).toBe(true); + }); + + it("explains why", () => { + expect(() => assertSiteSlugUnchanged(site, { siteSlug: "x" })).toThrow( + "The site id can't change: CDN paths, tokens and asset URLs use it.", + ); + }); +}); diff --git a/apps/api/src/tools/virtual/site-slug-guard.ts b/apps/api/src/tools/virtual/site-slug-guard.ts new file mode 100644 index 0000000000..b228d7e103 --- /dev/null +++ b/apps/api/src/tools/virtual/site-slug-guard.ts @@ -0,0 +1,37 @@ +import { isValidSiteSlug } from "@decocms/shared/site-slug"; +import { SiteSlugImmutableError } from "../../storage/org-sites"; + +const normalize = (value: unknown) => + typeof value === "string" ? value.trim().toLowerCase() : ""; + +/** + * Refuse a metadata write that would change a project's site slug. + * + * The slug is the site's public id (CDN paths, site tokens, asset URLs), so it + * is set once — by the flow that creates or imports the site — and never + * changes. `project` is the stored project, with `metadata.siteSlug` already + * overlaid from its `org_sites` link. + * + * Allowed: no `siteSlug` key (it is kept), the same value (forms send the whole + * metadata back), and an empty value on a project that has none. For a legacy + * project whose slug is still its title, writing that same slug is no change. + */ +export function assertSiteSlugUnchanged( + project: { + title?: string | null; + metadata?: { siteSlug?: string | null } | null; + }, + metadata: Record | null | undefined, +): void { + if (!metadata || !("siteSlug" in metadata)) return; + const incoming = normalize(metadata.siteSlug); + const current = normalize(project.metadata?.siteSlug); + if (current) { + if (incoming !== current) throw new SiteSlugImmutableError(); + return; + } + if (!incoming) return; + const fromTitle = normalize(project.title); + if (isValidSiteSlug(fromTitle) && incoming === fromTitle) return; + throw new SiteSlugImmutableError(); +} diff --git a/apps/api/src/tools/virtual/update.ts b/apps/api/src/tools/virtual/update.ts index c81490f70a..f23ef3776d 100644 --- a/apps/api/src/tools/virtual/update.ts +++ b/apps/api/src/tools/virtual/update.ts @@ -18,6 +18,7 @@ import { VirtualMCPEntitySchema, VirtualMCPUpdateDataSchema } from "./schema"; import { requireOrgAdminForPinnedField } from "./require-org-admin-for-pin"; import { requireConnectionsInOrganization } from "./require-connections-in-org"; import { pinnedSiteSlugOnRename } from "./pin-site-slug"; +import { assertSiteSlugUnchanged } from "./site-slug-guard"; import { stripServerManagedMetadata } from "../strip-server-managed-metadata"; /** @@ -82,11 +83,23 @@ export const COLLECTION_VIRTUAL_MCP_UPDATE = defineTool({ // `prompts` lives in org-fs, not on the agent row — pull it out before the // row update and re-seed separately below. const { prompts, metadata: rawMetadata, ...updateData } = input.data; + // The site slug is set once, by the create/import flow; never changed here. + assertSiteSlugUnchanged(existing, rawMetadata); let metadata = stripServerManagedMetadata(rawMetadata); if (metadata && existing.metadata) { metadata = { ...existing.metadata, ...metadata }; - } else if (metadata === null && existing.metadata?.sandboxMap) { - metadata = { sandboxMap: existing.metadata.sandboxMap }; + } else if ( + metadata === null && + (existing.metadata?.sandboxMap || existing.metadata?.siteSlug) + ) { + metadata = { + ...(existing.metadata.sandboxMap + ? { sandboxMap: existing.metadata.sandboxMap } + : {}), + ...(existing.metadata.siteSlug + ? { siteSlug: existing.metadata.siteSlug } + : {}), + }; } const data = { ...updateData, diff --git a/apps/web/src/i18n/en/admin.ts b/apps/web/src/i18n/en/admin.ts index 81b0a1fbbe..e94d9290d4 100644 --- a/apps/web/src/i18n/en/admin.ts +++ b/apps/web/src/i18n/en/admin.ts @@ -105,6 +105,11 @@ export const admin = { "admin.orgs.siteReassigned": "Moved {slug} to {org}", "admin.orgs.siteReassignWarning": '"{slug}" belongs to {owner} and will be moved to this organization.', + "admin.orgs.siteInUse": "In use", + "admin.orgs.siteInUseHint": + "A project uses this site id, so it can't be removed or moved: CDN paths, tokens and asset URLs use it.", + "admin.orgs.siteNotReassignable": + '"{slug}" belongs to {owner} and a project uses it, so it can\'t be moved: CDN paths, tokens and asset URLs use it.', "admin.orgs.siteRemoved": "Removed {slug} from {org}", "admin.orgs.siteSlugPlaceholder": "my-site", "admin.orgs.sites": "Sites", diff --git a/apps/web/src/i18n/en/virtual-mcp.ts b/apps/web/src/i18n/en/virtual-mcp.ts index 1db062fece..4fe7e0ef80 100644 --- a/apps/web/src/i18n/en/virtual-mcp.ts +++ b/apps/web/src/i18n/en/virtual-mcp.ts @@ -124,6 +124,9 @@ export const virtualMcp = { "virtualMcp.settings.identity.iconDescription": "How your project appears in the sidebar and across Studio.", "virtualMcp.settings.identity.name": "Project name", + "virtualMcp.settings.identity.siteId": "Site id", + "virtualMcp.settings.identity.siteIdDescription": + "The site id can't change: CDN paths, tokens and asset URLs use it.", "virtualMcp.settings.identity.description": "Description", "virtualMcp.settings.general.title": "General", "virtualMcp.settings.site.title": "CMS", diff --git a/apps/web/src/i18n/pt-br/admin.ts b/apps/web/src/i18n/pt-br/admin.ts index 282427e98a..fe1d00df7b 100644 --- a/apps/web/src/i18n/pt-br/admin.ts +++ b/apps/web/src/i18n/pt-br/admin.ts @@ -109,6 +109,11 @@ export const admin = { "admin.orgs.siteReassigned": "{slug} movido para {org}", "admin.orgs.siteReassignWarning": '"{slug}" pertence a {owner} e será movido para esta organização.', + "admin.orgs.siteInUse": "Em uso", + "admin.orgs.siteInUseHint": + "Um projeto usa este id de site, então ele não pode ser removido nem movido: caminhos de CDN, tokens e URLs de assets usam ele.", + "admin.orgs.siteNotReassignable": + '"{slug}" pertence a {owner} e um projeto usa ele, então não pode ser movido: caminhos de CDN, tokens e URLs de assets usam ele.', "admin.orgs.siteRemoved": "{slug} removido de {org}", "admin.orgs.siteSlugPlaceholder": "meu-site", "admin.orgs.sites": "Sites", diff --git a/apps/web/src/i18n/pt-br/virtual-mcp.ts b/apps/web/src/i18n/pt-br/virtual-mcp.ts index 3e0dfdaf97..c2fe9665ef 100644 --- a/apps/web/src/i18n/pt-br/virtual-mcp.ts +++ b/apps/web/src/i18n/pt-br/virtual-mcp.ts @@ -127,6 +127,9 @@ export const virtualMcp = { "virtualMcp.settings.identity.iconDescription": "Como seu projeto aparece na barra lateral e no Studio.", "virtualMcp.settings.identity.name": "Nome do projeto", + "virtualMcp.settings.identity.siteId": "Id do site", + "virtualMcp.settings.identity.siteIdDescription": + "O id do site não pode mudar: caminhos de CDN, tokens e URLs de assets usam ele.", "virtualMcp.settings.identity.description": "Descrição", "virtualMcp.settings.general.title": "Geral", "virtualMcp.settings.site.title": "CMS", diff --git a/apps/web/src/lib/admin-fetch.ts b/apps/web/src/lib/admin-fetch.ts index 87c239ad13..f39c62c7f3 100644 --- a/apps/web/src/lib/admin-fetch.ts +++ b/apps/web/src/lib/admin-fetch.ts @@ -9,8 +9,13 @@ export async function adminFetch( ): Promise { const res = await fetch(path, { credentials: "include", ...init }); if (!res.ok) { - const body = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(body.error || `Request failed (HTTP ${res.status})`); + const body = (await res.json().catch(() => ({}))) as { + error?: string; + message?: string; + }; + throw new Error( + body.message || body.error || `Request failed (HTTP ${res.status})`, + ); } return (await res.json()) as T; } diff --git a/apps/web/src/routes/admin/orgs.tsx b/apps/web/src/routes/admin/orgs.tsx index a007b0543c..64296b07eb 100644 --- a/apps/web/src/routes/admin/orgs.tsx +++ b/apps/web/src/routes/admin/orgs.tsx @@ -425,6 +425,10 @@ function FlagsDialog({ org }: { org: DeploymentAdminOrg }) { interface AdminOrgSite { slug: string; source: string; + /** The project this slug is the site of, when linked. */ + projectId: string | null; + /** Set once a project has used the slug: it can never be removed or moved. */ + linkedAt: string | null; } /** Thrown from the add mutation on 409, carrying the current owner for the warning. */ @@ -433,6 +437,7 @@ class SiteConflictError extends Error { readonly slug: string, readonly ownerName: string | null, readonly ownerSlug: string | null, + readonly reassignable: boolean, ) { super("owned_by_other_org"); this.name = "SiteConflictError"; @@ -592,6 +597,7 @@ function SitesDialog({ org }: { org: DeploymentAdminOrg }) { slug: string; ownerName: string | null; ownerSlug: string | null; + reassignable: boolean; } | null>(null); const queryClient = useQueryClient(); @@ -619,18 +625,23 @@ function SitesDialog({ org }: { org: DeploymentAdminOrg }) { }); const body = (await res.json().catch(() => ({}))) as { error?: string; + message?: string; ownerOrganizationName?: string | null; ownerOrganizationSlug?: string | null; + reassignable?: boolean; }; if (res.status === 409 && body.error === "owned_by_other_org") { throw new SiteConflictError( vars.slug, body.ownerOrganizationName ?? null, body.ownerOrganizationSlug ?? null, + body.reassignable !== false, ); } if (!res.ok) { - throw new Error(body.error || `Request failed (HTTP ${res.status})`); + throw new Error( + body.message || body.error || `Request failed (HTTP ${res.status})`, + ); } return body; }, @@ -651,6 +662,7 @@ function SitesDialog({ org }: { org: DeploymentAdminOrg }) { slug: error.slug, ownerName: error.ownerName, ownerSlug: error.ownerSlug, + reassignable: error.reassignable, }); return; } @@ -750,15 +762,27 @@ function SitesDialog({ org }: { org: DeploymentAdminOrg }) { {site.source} + {site.linkedAt ? ( + + {t("admin.orgs.siteInUse")} + + ) : null} - + {site.linkedAt ? ( + // A used site id is permanent: tokens and URLs carry it. + + {t("admin.orgs.siteInUseHint")} + + ) : ( + + )} )) )} @@ -794,13 +818,18 @@ function SitesDialog({ org }: { org: DeploymentAdminOrg }) { {conflict ? (

- {t("admin.orgs.siteReassignWarning", { - slug: conflict.slug, - owner: - conflict.ownerName || - conflict.ownerSlug || - t("admin.orgs.anotherOrg"), - })} + {t( + conflict.reassignable + ? "admin.orgs.siteReassignWarning" + : "admin.orgs.siteNotReassignable", + { + slug: conflict.slug, + owner: + conflict.ownerName || + conflict.ownerSlug || + t("admin.orgs.anotherOrg"), + }, + )}

- + {conflict.reassignable ? ( + + ) : null}
) : null} diff --git a/apps/web/src/views/virtual-mcp/index.tsx b/apps/web/src/views/virtual-mcp/index.tsx index 1cff8204b0..201f6a9a55 100644 --- a/apps/web/src/views/virtual-mcp/index.tsx +++ b/apps/web/src/views/virtual-mcp/index.tsx @@ -71,6 +71,7 @@ import { ConnectionItem, ConnectionItemSkeleton } from "./connection-item"; import { ProjectViewsSection } from "./settings/views-section"; import { useProjectViews } from "./settings/use-project-views"; import { ProjectIdentity } from "./settings/project-identity"; +import { projectSiteId } from "./settings/project-site-id"; import { ProjectSettingsTabs } from "./settings/settings-tabs"; import { SettingsCard, @@ -972,7 +973,11 @@ function VirtualMcpDetailViewWithData({ {section === "general" && } {section === "general" && (
- +
Promise; + /** The project's site id, shown read-only when it has one. */ + siteSlug?: string | null; }) { const t = useT(); const id = useId(); @@ -82,6 +86,9 @@ export function ProjectIdentity({ )} /> + {siteSlug ? ( + + ) : null}