Skip to content

feat(sites): link org_sites to projects — site ids are immutable and never reused - #7796

Open
tlgimenes wants to merge 4 commits into
mainfrom
feat/org-sites-project-link
Open

tlgimenes wants to merge 4 commits into
mainfrom
feat/org-sites-project-link

Conversation

@tlgimenes

@tlgimenes tlgimenes commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

What

The database now records which project each site belongs to: org_sites.project_id. Once a project has a site id (slug), no UI and no API request can change it.

Why

The site slug is the site's public id. CDN paths, site tokens, telemetry and asset URLs all carry it. Site tokens can't be revoked, so the rules are:

  • Immutable: once a project has a slug, it never changes.
  • Never reused across orgs: once a project has used a slug, the slug never goes to another org, and deleting the org doesn't free it. After the project is deleted, the same org may link it to another of its projects.
  • One site per project: a partial unique index on project_id enforces it.

Before this PR, the only link between a project and its site was the project's member-editable metadata.siteSlug, or its title for older imports. Any metadata write could rewrite it.

Migration 235-org-sites-project-link

New columns on org_sites:

  • project_id text NULL REFERENCES connections(id) ON DELETE SET NULL, unique where not null. Deleting a project keeps the slug reserved for its org; the same org may then link it to another of its projects itself (normal link path). Another org never can.
  • linked_at timestamptz NULL, set when the slug is first used and never cleared. A used slug can't be released or moved to another org; a relink keeps the first linked_at. Every row that exists when the migration runs is marked used (linked_at = created_at when no project could be linked): each came from a deco.cx import or backfill, so it's a real public site that may already have tokens out.

organization_id: now nullable, with the FK changed from ON DELETE CASCADE to ON DELETE SET NULL. A row with no org is a tombstone that nobody can claim.

  • RESTRICT would break org deletion (auth.api.deleteOrganization).
  • CASCADE is what freed slugs for reuse.
  • SET NULL keeps org deletion working and keeps the slug reserved forever.

Backfill: it reads projects and never writes them. For each row, it links the single project in the same org whose metadata.siteSlug is exactly the slug. Nothing else is a match: no title fallback, no case- or whitespace-variant.

With 0 or more than 1 candidates, the row stays unlinked (but marked used) and is counted; nothing is guessed. Malformed metadata JSON is counted, never cast in SQL, so one bad row can't abort the migration. The migration logs one summary line:

[migration 235] org_sites: linked=N (planned=…, skipped_deleted_project=…) none=… ambiguous=… unlinked_marked_used=… unparseable_project_metadata=…

It also logs one line per ambiguous slug, listing the slug and its project ids.

To see unlinked rows:

SELECT slug, organization_id, source FROM org_sites
WHERE project_id IS NULL AND organization_id IS NOT NULL;

Locking. Kysely's Migrator runs pending migrations in one transaction, so locks are held until commit while old pods serve. The migration therefore:

  1. sets SET LOCAL lock_timeout = '5s' first — a blocked ALTER fails fast and the deploy retries, instead of queueing every writer behind it;
  2. plans the backfill with reads only (ACCESS SHARE) before any ALTER;
  3. runs the ALTERs (ACCESS EXCLUSIVE on org_sites, SHARE ROW EXCLUSIVE on connections and organization for the FKs; the org FK is added NOT VALID then validated);
  4. applies the plan in set-based UPDATE … FROM (VALUES …) batches of 1000, with EXISTS (SELECT 1 FROM connections …) so a project deleted since planning is skipped instead of aborting the deploy.

To size the lock window before merging (no prod DB access from here):

SELECT (SELECT count(*) FROM org_sites) AS org_sites,
       (SELECT count(*) FROM connections WHERE connection_type = 'VIRTUAL') AS projects;

Down: restores the previous shape: NOT NULL with CASCADE, and the new columns and index dropped. It refuses while tombstones exist (dropping them would silently make a deleted org's slugs claimable again); an operator who accepts that deletes them explicitly first.

Storage port (OrgSiteStoragePort)

  • getByProject(projectId): new; returns the slug linked to a project.

  • link({ slug, organizationId, projectId, by }): new. It links once and is idempotent for the same pair. It refuses with a typed OrgSiteLinkError in these cases:

    Code When
    not_found No row for the slug
    reserved Tombstone
    not_owned Another org owns the slug
    linked_elsewhere Another project has the slug
    project_has_other_slug The project already has a different slug
    project_not_found Not a project of this org

    A used slug that is unlinked now (its project was deleted, or a legacy row) links like any other: the owning org may link it to one of its projects, linked_at keeps the first use. Another org gets not_owned, a tombstone reserved. Cross-org moves of a used slug aren't supported in code; operators do them by hand in the DB.

  • claimSite: refuses a tombstone (reserved).

  • reassignSite (deployment-admin "move site here"): refuses tombstones and used slugs (in_use).

  • releaseSite: refuses used slugs (in_use).

v7 behaviour on main

Reading a project's site: VirtualMCPStorage findById and the list* methods overlay metadata.siteSlug from the link. The ~180 readers of metadata.siteSlug (tabs, section editors, editor-resolve, admin lists) are unchanged, and they get the linked slug.

  • Choice: metadata.siteSlug stays as a mirror. It's a read-time overlay; projects aren't rewritten. Removing it would have touched every reader.
  • For projects that aren't linked yet, the stored value still applies.

Authorization: experiments ownership (assertOwnsSite / resolveOwnedAnalyticsSite) now follows the link.

  • A linked slug names its project.
  • Another org's project that sets the same siteSlug owns nothing.
  • A tombstoned slug is nobody's.
  • Unlinked projects keep the legacy match, so v7 isn't affected. Known gap, kept for v7 compatibility: an unlinked slug owned by another org still matches this org's look-alike project (see "Decided (2026-10-08)", item 4).

The deployment-admin project list (listSiteProjects) shows only the linked project for a linked slug.

Create (COLLECTION_VIRTUAL_MCP_CREATE, which the deco.cx import uses after /deco-sites/prepare claims the slug): it links the slug when the org owns a free row. Otherwise the slug is stored unlinked, which keeps importing the same storefront into several orgs working, and it still can't change after that.

Org-level gates (hosting, monitor, file configs, infra billing, has-site, notices) already check org_sites ownership and are unchanged.

"Users can't change site slugs via the UI"

Where the UI could set or change a slug

Where How Now
Project settings → name (project-identity.tsx) Renaming a project whose slug was its title The rename pins the old title as the slug (existing pin-site-slug). With the link, the slug no longer depends on the title. A new read-only Site id row shows it with the note "The site id can't change: CDN paths, tokens and asset URLs use it." — also for a repo-backed legacy project whose slug is still its title.
deco.cx import (import-from-deco-dialog.tsx) Sets siteSlug from the picked site at create time This is the one flow that sets it, once, and create links it
Deployment admin → Sites (routes/admin/orgs.tsx) Remove / move a slug between orgs Used slugs show In use, with no Remove button and an explanation. "Move site here" is hidden for a used slug, and the server refuses it too
Admin project metadata editor — Already allowlists only analyticsSiteSlug; siteSlug is rejected (existing test)

There is no free-text siteSlug input anywhere else in apps/web. Other metadata writers (dev-agent setup, project profile, new-project dialog) never send it, and the API guard covers them anyway.

API enforcement: every write path

Any change refuses with SITE_SLUG_IMMUTABLE ("The site id can't change: CDN paths, tokens and asset URLs use it.").

  • COLLECTION_VIRTUAL_MCP_UPDATE (UI, Decopilot, MCP):
    • Refuses changing or clearing a slug, and refuses setting one on a project that has none.
    • Sending the same value is allowed, because forms send the whole metadata back.
    • metadata: null keeps siteSlug, the same way it keeps sandboxMap.
  • COLLECTION_CONNECTIONS_UPDATE on a VIRTUAL (project) row: the same check, it puts the slug back if the metadata write left it out, and a title change on a legacy title-slug project pins the slug (as COLLECTION_VIRTUAL_MCP_UPDATE does).
  • VirtualMCPStorage.patchMetadata (admin single-key writes): refuses siteSlug in set or unset, behind the admin allow-list.
  • VirtualMCPStorage.update: defence in depth for internal writers (sandbox start, reports setup, set-repository, pinned views). It keeps the current slug through any metadata rewrite and throws on a different one.
  • Admin POST/DELETE /api/_admin/orgs/:orgId/sites: returns 409 with reserved / in_use and a message. The owned_by_other_org 409 now carries reassignable.

Rollout

The migration is safe on production data.

  • Schema changes: two nullable columns and an FK swap on the small org_sites table, under a 5s lock_timeout (see Locking).
  • Backfill: planned from reads before any lock, applied in set-based batches; reads only projects in orgs that have sites, and never writes them.
  • Previous release: keeps working on the new schema, which the rollback-compat workflow checks. It always inserts an org, ignores the new columns, and its release DELETE still works on unused rows.

v7 is unaffected:

  • Unlinked projects behave as before.
  • The only behaviour changes are refusals: of slug changes, of releasing or moving any pre-existing or used slug to another org, and of experiments on a tombstoned slug.

Stack

This is the base of the v8 Studio stack, and it can be reviewed and merged on its own against main. On top of it are three parallel tracks, then e2e:

The top of the stack (#7843) has the same tree as feat/blocks-v8-hosted. It replaces #7728, #7770 and #7766.

Decided (2026-10-08)

  1. Every pre-existing slug is permanently used (linked_at = created_at when no project could be linked): never released or moved to another org.
  2. After a project is deleted, the same org relinks its own used slug to another of its projects by itself, through the normal claim/link path (no admin). Cross-org relinking isn't supported in code: another org's slug is refused (not_owned), a tombstone (org deleted) is refused (reserved); operators handle those by hand in the DB. The deployment-admin POST /api/_admin/orgs/:orgId/sites/:slug/link endpoint and the relink_requires_admin code are removed.
  3. The backfill's title-based match is dropped: it links only an exact metadata.siteSlug match within the org; everything else stays unlinked (and marked used).
  4. Known gap, kept: experiments on an unlinked slug another org owns still match this org's look-alike project (v7 behaviour). Refusing it would be a v7 behaviour change for orgs that imported a storefront they don't own in org_sites.

Tests

  • apps/api/migrations/235-org-sites-project-link.integration.test.ts runs on a seeded DB and covers:
    • links only an exact metadata.siteSlug match; a title or a case/whitespace variant is never a match
    • unlinked legacy rows are marked used: release and reassign refused; the owning org links one itself, another org can't
    • a planned link to a project deleted since planning is skipped
    • ambiguous and none rows stay unlinked
    • malformed JSON
    • other-org look-alikes
    • org delete leaves a tombstone
    • project delete keeps the slug and linked_at
    • one slug per project
    • down refuses with tombstones; down/up round trip
  • apps/api/src/storage/org-sites-link.integration.test.ts covers:
    • every port rule, including the same org relinking after a project is deleted (keeping linked_at) and another org / a tombstone refused
    • tombstone, release and reassign refusals
    • storage overlay and pinning
    • create links or stays unlinked
    • update tool refusals and allowed writes
    • connections-update refusal, and rename through it pins a legacy slug
    • patchMetadata refuses siteSlug
    • experiments ownership, including tombstones
  • apps/api/src/tools/virtual/site-slug-guard.test.ts: unit tests for the guard.
  • apps/web/src/views/virtual-mcp/settings/project-site-id.test.tsx: checks the read-only site id and its explanation, and which projects show one.
  • apps/api/src/api/routes/admin-project-metadata.test.ts: the admin project list prefers the linked project.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig

…ever reused

Migration 235 adds org_sites.project_id (unique, ON DELETE SET NULL) and
linked_at, backfills the single project per slug (metadata.siteSlug, then
title; ambiguous/none left unlinked and counted), and turns the org FK into
ON DELETE SET NULL so deleting an org leaves an unclaimable tombstone.

The port gains getByProject/link with typed refusals; claim/reassign/release
refuse tombstones and slugs a project has used. Project reads overlay the
linked slug onto metadata.siteSlug; every write path (virtual MCP update,
connections update on a project row, VirtualMCPStorage.update) refuses
changing a site slug. Create links the imported slug once. Project settings
show the site id read-only; deployment admin can't remove or move a used slug.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
@github-actions github-actions Bot added the claude PR authored by a coding agent label Oct 8, 2026
tlgimenes added a commit that referenced this pull request Oct 8, 2026
Brings in the org_sites project link (#7796) and main. Conflict in
sandbox-proxy.ts: main renamed the suggest-commit body cap to
JUDGE_REVIEW_MAX_BODY_BYTES; kept that plus this branch's content caps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
tlgimenes added a commit that referenced this pull request Oct 8, 2026
…ship follows the org_sites link

Brings in #7796 (org_sites.project_id link, immutable site slugs) and main.

Hosted switch:
- ownedProjectSite(orgSites, projectId, orgId) reads the project's org_sites
  link (getByProject) instead of metadata.siteSlug. hosted routes,
  decofile scope and the sandbox-less draft status use it.
- claim-site.ts: a project's site is linked once (linkProjectSite): a slug no
  org owns is claimed first (still refused for deco.cx sites and slugs another
  org's project names), then linked. Tombstoned (reserved) slugs, another
  org's, another project's, or a second slug for a linked project are refused.
- Admin backfill links v8 projects (report: linked / alreadyLinked / refused /
  ambiguous); several projects of one org naming one slug are ambiguous.
- e2e: a slug change is refused and hosted keys stay on the linked site.

Conflicts:
- publish UI: main (#7792) replaced cms-publish-popover.tsx with
  publish-dialog.tsx; the hosted changes (no review mode, no Request approval,
  hosted publish action) moved into publish-dialog.tsx.
- main removed the project "git" tab; kept hosted's "releases" tab only.
- sandbox-proxy.ts: main dropped /git/suggest-commit; judge-review keeps the
  hosted-aware fastPreviewStatus/fastPreviewDiff backfill.
- create.ts: project creation goes through claimProjectSite (claim + link).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
tlgimenes added a commit that referenced this pull request Oct 8, 2026
Makes the Studio stack one line: #7796 -> #7728 -> #7770 -> #7766.
Conflict in content-protocol-api.ts (applyProtocolPatch doc): kept both the
hosted CDN-draft and the sandbox working-tree notes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
…only relink

Review follow-ups for the org_sites ↔ project link:

- Migration 235 plans the backfill with reads only, then takes the ALTER
  locks under a 5s lock_timeout and applies the plan in batched set-based
  UPDATEs (a project deleted since planning is skipped, not an FK error).
  The org FK is added NOT VALID then validated.
- Every pre-existing slug is marked used (linked_at = created_at when no
  project was linked): each is a real public site, so it is never released
  or moved to another org.
- The title fallback only links repo-backed projects (frozen
  hasClonableSource), never a chat-only agent named like the site.
- down() refuses while tombstones exist instead of silently freeing them.
- A used slug whose project is gone (or that predates the link) is linked
  to another project only by a deployment admin
  (POST /api/admin/orgs/:orgId/sites/:slug/link).
- COLLECTION_CONNECTIONS_UPDATE pins a legacy title slug on rename;
  patchMetadata refuses siteSlug; experiments refuse tombstoned slugs;
  the admin project list prefers the linked project; project settings show
  a repo-backed legacy project's title slug read-only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
tlgimenes added a commit that referenced this pull request Oct 8, 2026
…m refusals

A row that vanished between claim and link is "not-found", not "other-org".
A used slug whose project is gone (or that predates the link) is refused as
"relink-requires-admin", matching the org_sites rule from #7796, in dry runs
too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
…backfill

Per the PO decisions on #7796:
- After a project is deleted, the same org links its used slug to another
  of its projects through the normal link path (linked_at keeps the first
  use). Another org, or a tombstone, is refused (not_owned / reserved);
  cross-org moves are done by operators in the DB.
- Remove the deployment-admin POST /api/_admin/orgs/:orgId/sites/:slug/link
  endpoint, the link adminOverride and the relink_requires_admin code.
- Migration 235 backfill links only an exact metadata.siteSlug match within
  the org; the title-based match is dropped, everything else stays unlinked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
tlgimenes added a commit that referenced this pull request Oct 8, 2026
…s-admin

Follows #7796's decision: after a project is deleted, the same org links
its used slug to another of its projects through the normal claim/link
path. Another org's slug stays refused (other-org), a tombstone stays
reserved. The relink-requires-admin refusal is gone with the admin path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
This was referenced Oct 8, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude PR authored by a coding agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant