Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/features/Org2Cloud/org2CloudCapabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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,
});
});

Expand All @@ -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);
});
Expand All @@ -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);
});
Expand All @@ -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);
});
Expand Down
4 changes: 4 additions & 0 deletions src/features/Org2Cloud/org2CloudCapabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CloudCapabilities>();
Expand All @@ -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;
Expand Down
47 changes: 47 additions & 0 deletions src/features/Org2Cloud/org2CloudClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]);
});
});
26 changes: 18 additions & 8 deletions src/features/Org2Cloud/org2CloudClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,13 +219,20 @@ 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 {
orgId: string;
name: string;
role: CloudOrgRole;
entitlement?: CloudEntitlementState;
/** 0007 directory hook; absent ⇒ the org lives on the active endpoint. */
homeEndpoint?: string;
}

const CloudOrgMemberWireSchema = z.object({
Expand Down Expand Up @@ -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. */
Expand Down
108 changes: 108 additions & 0 deletions src/features/Org2Cloud/org2CloudEndpointDirectory.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
52 changes: 52 additions & 0 deletions src/features/Org2Cloud/org2CloudEndpointDirectory.ts
Original file line number Diff line number Diff line change
@@ -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<T extends { homeEndpoint?: string }> {
endpoint: CloudEndpoint;
orgs: T[];
}

/** Roster partitioned by resolved home project, keyed by `supabaseUrl`. */
export function orgsGroupedByEndpoint<T extends { homeEndpoint?: string }>(
orgs: readonly T[],
official: CloudEndpoint
): Map<string, OrgEndpointGroup<T>> {
const groups = new Map<string, OrgEndpointGroup<T>>();
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;
}
2 changes: 2 additions & 0 deletions src/features/Org2Cloud/org2CloudOrgsAtom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions src/features/Org2Cloud/org2CloudSyncClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ beforeEach(() => {
capabilitiesMock.mockResolvedValue({
broadcastSignals: false,
storageSegments: false,
homeEndpoints: false,
});
});

Expand Down Expand Up @@ -258,6 +259,7 @@ describe("storage segment offload (0006)", () => {
capabilitiesMock.mockResolvedValue({
broadcastSignals: false,
storageSegments: true,
homeEndpoints: false,
});
});

Expand Down Expand Up @@ -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);
Expand Down
Loading