diff --git a/src/features/Org2Cloud/org2CloudCapabilities.test.ts b/src/features/Org2Cloud/org2CloudCapabilities.test.ts index b19f0c09b..f94e74b3f 100644 --- a/src/features/Org2Cloud/org2CloudCapabilities.test.ts +++ b/src/features/Org2Cloud/org2CloudCapabilities.test.ts @@ -26,10 +26,12 @@ describe("getCloudCapabilities", () => { expect(await getCloudCapabilities("jwt-1")).toEqual({ broadcastSignals: true, storageSegments: false, + homeEndpoints: false, }); expect(await getCloudCapabilities("jwt-1")).toEqual({ broadcastSignals: true, storageSegments: false, + homeEndpoints: false, }); expect(rawMock).toHaveBeenCalledTimes(1); }); @@ -42,6 +44,20 @@ describe("getCloudCapabilities", () => { expect(await getCloudCapabilities("jwt-1")).toEqual({ broadcastSignals: true, storageSegments: true, + homeEndpoints: false, + }); + }); + + it("parses the 0007 homeEndpoints flag", async () => { + rawMock.mockResolvedValueOnce({ + broadcastSignals: true, + storageSegments: true, + homeEndpoints: true, + }); + expect(await getCloudCapabilities("jwt-1")).toEqual({ + broadcastSignals: true, + storageSegments: true, + homeEndpoints: true, }); }); @@ -50,11 +66,13 @@ describe("getCloudCapabilities", () => { expect(await getCloudCapabilities("jwt-1")).toEqual({ broadcastSignals: false, storageSegments: false, + homeEndpoints: false, }); rawMock.mockResolvedValueOnce({ broadcastSignals: true }); expect(await getCloudCapabilities("jwt-1")).toEqual({ broadcastSignals: true, storageSegments: false, + homeEndpoints: false, }); expect(rawMock).toHaveBeenCalledTimes(2); }); @@ -63,14 +81,17 @@ describe("getCloudCapabilities", () => { rawMock.mockResolvedValueOnce({ broadcastSignals: "yes", storageSegments: "yes", + homeEndpoints: "yes", }); expect(await getCloudCapabilities("jwt-1")).toEqual({ broadcastSignals: false, storageSegments: false, + homeEndpoints: false, }); expect(await getCloudCapabilities("jwt-1")).toEqual({ broadcastSignals: false, storageSegments: false, + homeEndpoints: false, }); expect(rawMock).toHaveBeenCalledTimes(1); }); @@ -88,10 +109,12 @@ describe("getCloudCapabilities", () => { expect(await first).toEqual({ broadcastSignals: true, storageSegments: true, + homeEndpoints: false, }); expect(await second).toEqual({ broadcastSignals: true, storageSegments: true, + homeEndpoints: false, }); expect(rawMock).toHaveBeenCalledTimes(1); }); diff --git a/src/features/Org2Cloud/org2CloudCapabilities.ts b/src/features/Org2Cloud/org2CloudCapabilities.ts index 8ddf351d8..b59d7b2e8 100644 --- a/src/features/Org2Cloud/org2CloudCapabilities.ts +++ b/src/features/Org2Cloud/org2CloudCapabilities.ts @@ -13,16 +13,19 @@ import { getCloudCapabilitiesRaw } from "./org2CloudClient"; const CloudCapabilitiesWireSchema = z.object({ broadcastSignals: z.boolean().nullish().catch(undefined), storageSegments: z.boolean().nullish().catch(undefined), + homeEndpoints: z.boolean().nullish().catch(undefined), }); export interface CloudCapabilities { broadcastSignals: boolean; storageSegments: boolean; + homeEndpoints: boolean; } const LEGACY_CAPABILITIES: CloudCapabilities = { broadcastSignals: false, storageSegments: false, + homeEndpoints: false, }; const capabilitiesByEndpoint = new Map(); @@ -48,6 +51,7 @@ export async function getCloudCapabilities( const capabilities: CloudCapabilities = { broadcastSignals: parsed.data.broadcastSignals ?? false, storageSegments: parsed.data.storageSegments ?? false, + homeEndpoints: parsed.data.homeEndpoints ?? false, }; capabilitiesByEndpoint.set(endpointKey, capabilities); return capabilities; diff --git a/src/features/Org2Cloud/org2CloudClient.test.ts b/src/features/Org2Cloud/org2CloudClient.test.ts index cec273684..c68812c53 100644 --- a/src/features/Org2Cloud/org2CloudClient.test.ts +++ b/src/features/Org2Cloud/org2CloudClient.test.ts @@ -326,3 +326,50 @@ describe("listMyOrgs batched entitlements (0004)", () => { ]); }); }); + +describe("listMyOrgs homeEndpoint (0007)", () => { + it("carries a roster row's homeEndpoint through", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse([ + { + orgId: "org-1", + name: "Acme", + role: "member", + homeEndpoint: "https://shard-2.supabase.co", + }, + ]) + ); + await expect(listMyOrgs("at-1")).resolves.toEqual([ + { + orgId: "org-1", + name: "Acme", + role: "member", + homeEndpoint: "https://shard-2.supabase.co", + }, + ]); + }); + + it("omits homeEndpoint for pre-0007 rows without the key and for null", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse([ + { orgId: "org-1", name: "Acme", role: "owner" }, + { orgId: "org-2", name: "Beta", role: "member", homeEndpoint: null }, + ]) + ); + await expect(listMyOrgs("at-1")).resolves.toEqual([ + { orgId: "org-1", name: "Acme", role: "owner" }, + { orgId: "org-2", name: "Beta", role: "member" }, + ]); + }); + + it("keeps the org and drops only the homeEndpoint when the value is malformed", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse([ + { orgId: "org-1", name: "Acme", role: "owner", homeEndpoint: 42 }, + ]) + ); + await expect(listMyOrgs("at-1")).resolves.toEqual([ + { orgId: "org-1", name: "Acme", role: "owner" }, + ]); + }); +}); diff --git a/src/features/Org2Cloud/org2CloudClient.ts b/src/features/Org2Cloud/org2CloudClient.ts index 806e3b11d..d76bd9b9e 100644 --- a/src/features/Org2Cloud/org2CloudClient.ts +++ b/src/features/Org2Cloud/org2CloudClient.ts @@ -219,6 +219,11 @@ const CloudOrgWireSchema = z.object({ // to the per-org RPC for exactly that org. `.catch(undefined)` keeps a // malformed entitlement from failing the whole roster parse. entitlement: EntitlementStateWireSchema.nullish().catch(undefined), + // 0007 org-sharding directory hook (design §7 step 3): the Supabase origin + // hosting this org's data plane. null/absent (pre-0007 backends, or an org + // living on the active project) ⇒ the active endpoint. `.catch(undefined)` + // keeps a malformed value from failing the whole roster parse. + homeEndpoint: z.string().nullish().catch(undefined), }); export interface CloudOrg { @@ -226,6 +231,8 @@ export interface CloudOrg { name: string; role: CloudOrgRole; entitlement?: CloudEntitlementState; + /** 0007 directory hook; absent ⇒ the org lives on the active endpoint. */ + homeEndpoint?: string; } const CloudOrgMemberWireSchema = z.object({ @@ -276,14 +283,17 @@ export async function listMyOrgs( } return null; } - return parsed.data.map(({ orgId, name, role, entitlement }) => ({ - orgId, - name, - role, - ...(entitlement - ? { entitlement: normalizeEntitlementWire(entitlement) } - : {}), - })); + return parsed.data.map( + ({ orgId, name, role, entitlement, homeEndpoint }) => ({ + orgId, + name, + role, + ...(entitlement + ? { entitlement: normalizeEntitlementWire(entitlement) } + : {}), + ...(homeEndpoint ? { homeEndpoint } : {}), + }) + ); } /** Members of a cloud org (`list_org_members`). `[]` on any failure. */ diff --git a/src/features/Org2Cloud/org2CloudEndpointDirectory.test.ts b/src/features/Org2Cloud/org2CloudEndpointDirectory.test.ts new file mode 100644 index 000000000..ca431934b --- /dev/null +++ b/src/features/Org2Cloud/org2CloudEndpointDirectory.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; + +import type { CloudEndpoint } from "./config"; +import { + orgsGroupedByEndpoint, + resolveOrgEndpoint, +} from "./org2CloudEndpointDirectory"; + +const OFFICIAL: CloudEndpoint = { + webOrigin: "https://org2-cloud-infra.vercel.app", + supabaseUrl: "https://official.supabase.co", + anonKey: "anon-key", + isOfficial: true, +}; + +describe("resolveOrgEndpoint", () => { + it("returns the official endpoint when homeEndpoint is absent", () => { + expect(resolveOrgEndpoint({}, OFFICIAL)).toBe(OFFICIAL); + }); + + it("returns the official endpoint when homeEndpoint matches it", () => { + expect( + resolveOrgEndpoint( + { homeEndpoint: "https://official.supabase.co" }, + OFFICIAL + ) + ).toBe(OFFICIAL); + }); + + it("swaps only the supabaseUrl for a differing https origin", () => { + expect( + resolveOrgEndpoint( + { homeEndpoint: "https://shard-2.supabase.co" }, + OFFICIAL + ) + ).toEqual({ + webOrigin: "https://org2-cloud-infra.vercel.app", + supabaseUrl: "https://shard-2.supabase.co", + anonKey: "anon-key", + isOfficial: true, + }); + }); + + it.each([ + "", + "not-a-url", + "http://shard-2.supabase.co", + "https://shard-2.supabase.co/rest/v1", + "https://shard-2.supabase.co/", + "ftp://shard-2.supabase.co", + ])("returns the official endpoint for garbage origin %j", (homeEndpoint) => { + expect(resolveOrgEndpoint({ homeEndpoint }, OFFICIAL)).toBe(OFFICIAL); + }); +}); + +interface RosterOrg { + orgId: string; + name: string; + homeEndpoint?: string; +} + +describe("orgsGroupedByEndpoint", () => { + it("partitions a roster by resolved home project", () => { + const home: RosterOrg = { orgId: "org-1", name: "Home" }; + const homeExplicit = { + orgId: "org-2", + name: "Home explicit", + homeEndpoint: "https://official.supabase.co", + }; + const sharded = { + orgId: "org-3", + name: "Sharded", + homeEndpoint: "https://shard-2.supabase.co", + }; + const shardedSibling = { + orgId: "org-4", + name: "Sharded sibling", + homeEndpoint: "https://shard-2.supabase.co", + }; + const garbage = { + orgId: "org-5", + name: "Garbage", + homeEndpoint: "not-a-url", + }; + + const groups = orgsGroupedByEndpoint( + [home, homeExplicit, sharded, shardedSibling, garbage], + OFFICIAL + ); + + expect([...groups.keys()]).toEqual([ + "https://official.supabase.co", + "https://shard-2.supabase.co", + ]); + expect(groups.get("https://official.supabase.co")).toEqual({ + endpoint: OFFICIAL, + orgs: [home, homeExplicit, garbage], + }); + expect(groups.get("https://shard-2.supabase.co")).toEqual({ + endpoint: { ...OFFICIAL, supabaseUrl: "https://shard-2.supabase.co" }, + orgs: [sharded, shardedSibling], + }); + }); + + it("returns an empty map for an empty roster", () => { + expect(orgsGroupedByEndpoint([], OFFICIAL).size).toBe(0); + }); +}); diff --git a/src/features/Org2Cloud/org2CloudEndpointDirectory.ts b/src/features/Org2Cloud/org2CloudEndpointDirectory.ts new file mode 100644 index 000000000..35c91b3ab --- /dev/null +++ b/src/features/Org2Cloud/org2CloudEndpointDirectory.ts @@ -0,0 +1,52 @@ +/** + * Org → endpoint directory resolvers (design §7 step 3, the client half of + * the org-sharding lever). A 0007+ `list_my_orgs` row may carry a + * `homeEndpoint`: the Supabase origin hosting that org's data plane. These + * pure helpers resolve it against the active endpoint and group a roster by + * home project. NOT wired into any call site yet — the hook's contract is + * that a future cutover flips the data and a later PR wires the routing. + */ +import type { CloudEndpoint } from "./config"; + +function asHttpsOrigin(value: string | undefined): string | null { + if (!value) return null; + try { + const url = new URL(value); + if (url.protocol !== "https:" || url.origin !== value) return null; + return url.origin; + } catch { + return null; + } +} + +export function resolveOrgEndpoint( + org: { homeEndpoint?: string }, + official: CloudEndpoint +): CloudEndpoint { + const home = asHttpsOrigin(org.homeEndpoint); + if (!home || home === official.supabaseUrl) return official; + return { ...official, supabaseUrl: home }; +} + +export interface OrgEndpointGroup { + endpoint: CloudEndpoint; + orgs: T[]; +} + +/** Roster partitioned by resolved home project, keyed by `supabaseUrl`. */ +export function orgsGroupedByEndpoint( + orgs: readonly T[], + official: CloudEndpoint +): Map> { + const groups = new Map>(); + for (const org of orgs) { + const endpoint = resolveOrgEndpoint(org, official); + const group = groups.get(endpoint.supabaseUrl); + if (group) { + group.orgs.push(org); + } else { + groups.set(endpoint.supabaseUrl, { endpoint, orgs: [org] }); + } + } + return groups; +} diff --git a/src/features/Org2Cloud/org2CloudOrgsAtom.ts b/src/features/Org2Cloud/org2CloudOrgsAtom.ts index 6d2ba17fc..c6ad9450d 100644 --- a/src/features/Org2Cloud/org2CloudOrgsAtom.ts +++ b/src/features/Org2Cloud/org2CloudOrgsAtom.ts @@ -59,6 +59,8 @@ export interface Org2CloudOrg { role: string; /** Batched entitlement from a 0004 roster listing; absent ⇒ per-org RPC. */ entitlement?: CloudEntitlementState; + /** 0007 directory hook; absent ⇒ the org lives on the active endpoint. */ + homeEndpoint?: string; } export interface RefetchOrg2CloudOrgsOptions { diff --git a/src/features/Org2Cloud/org2CloudSyncClient.test.ts b/src/features/Org2Cloud/org2CloudSyncClient.test.ts index a03f61052..76197e6f2 100644 --- a/src/features/Org2Cloud/org2CloudSyncClient.test.ts +++ b/src/features/Org2Cloud/org2CloudSyncClient.test.ts @@ -61,6 +61,7 @@ beforeEach(() => { capabilitiesMock.mockResolvedValue({ broadcastSignals: false, storageSegments: false, + homeEndpoints: false, }); }); @@ -258,6 +259,7 @@ describe("storage segment offload (0006)", () => { capabilitiesMock.mockResolvedValue({ broadcastSignals: false, storageSegments: true, + homeEndpoints: false, }); }); @@ -337,6 +339,7 @@ describe("storage segment offload (0006)", () => { capabilitiesMock.mockResolvedValue({ broadcastSignals: false, storageSegments: false, + homeEndpoints: false, }); await appendSessionEvents("jwt-1", appendInput([makeEvent("f1")], null)); expect(fetchMock).toHaveBeenCalledTimes(1);