Repository navigation
Conversation
…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
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
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
This was referenced Oct 8, 2026
This was referenced Oct 8, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
project_idenforces 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-linkNew 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 firstlinked_at. Every row that exists when the migration runs is marked used (linked_at = created_atwhen 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 fromON DELETE CASCADEtoON DELETE SET NULL. A row with no org is a tombstone that nobody can claim.RESTRICTwould break org deletion (auth.api.deleteOrganization).CASCADEis what freed slugs for reuse.SET NULLkeeps 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.siteSlugis 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:
It also logs one line per ambiguous slug, listing the slug and its project ids.
To see unlinked rows:
Locking. Kysely's
Migratorruns pending migrations in one transaction, so locks are held until commit while old pods serve. The migration therefore:SET LOCAL lock_timeout = '5s'first — a blocked ALTER fails fast and the deploy retries, instead of queueing every writer behind it;org_sites, SHARE ROW EXCLUSIVE onconnectionsandorganizationfor the FKs; the org FK is addedNOT VALIDthen validated);UPDATE … FROM (VALUES …)batches of 1000, withEXISTS (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):
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 typedOrgSiteLinkErrorin these cases:not_foundreservednot_ownedlinked_elsewhereproject_has_other_slugproject_not_foundA 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_atkeeps the first use. Another org getsnot_owned, a tombstonereserved. 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:
VirtualMCPStoragefindByIdand thelist*methods overlaymetadata.siteSlugfrom the link. The ~180 readers ofmetadata.siteSlug(tabs, section editors, editor-resolve, admin lists) are unchanged, and they get the linked slug.metadata.siteSlugstays as a mirror. It's a read-time overlay; projects aren't rewritten. Removing it would have touched every reader.Authorization: experiments ownership (
assertOwnsSite/resolveOwnedAnalyticsSite) now follows the link.siteSlugowns nothing.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/prepareclaims 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_sitesownership and are unchanged."Users can't change site slugs via the UI"
Where the UI could set or change a slug
project-identity.tsx)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.import-from-deco-dialog.tsx)siteSlugfrom the picked site at create timeroutes/admin/orgs.tsx)analyticsSiteSlug;siteSlugis rejected (existing test)There is no free-text
siteSluginput anywhere else inapps/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):metadata: nullkeepssiteSlug, the same way it keepssandboxMap.COLLECTION_CONNECTIONS_UPDATEon 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 (asCOLLECTION_VIRTUAL_MCP_UPDATEdoes).VirtualMCPStorage.patchMetadata(admin single-key writes): refusessiteSluginsetorunset, 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.POST/DELETE /api/_admin/orgs/:orgId/sites: returns 409 withreserved/in_useand a message. Theowned_by_other_org409 now carriesreassignable.Rollout
The migration is safe on production data.
org_sitestable, under a 5slock_timeout(see Locking).v7 is unaffected:
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)
linked_at = created_atwhen no project could be linked): never released or moved to another org.not_owned), a tombstone (org deleted) is refused (reserved); operators handle those by hand in the DB. The deployment-adminPOST /api/_admin/orgs/:orgId/sites/:slug/linkendpoint and therelink_requires_admincode are removed.metadata.siteSlugmatch within the org; everything else stays unlinked (and marked used).org_sites.Tests
apps/api/migrations/235-org-sites-project-link.integration.test.tsruns on a seeded DB and covers:metadata.siteSlugmatch; a title or a case/whitespace variant is never a matchlinked_atapps/api/src/storage/org-sites-link.integration.test.tscovers:linked_at) and another org / a tombstone refusedpatchMetadatarefusessiteSlugapps/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