From 4eb716684e3ccdf671af8796fa789cef8d21e1ca Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:23:20 -0500 Subject: [PATCH 1/6] feat(web): open Cap Infra to every account --- apps/web/lib/infra-access.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/web/lib/infra-access.ts b/apps/web/lib/infra-access.ts index 10b5d1d6c0e..2097f7148a8 100644 --- a/apps/web/lib/infra-access.ts +++ b/apps/web/lib/infra-access.ts @@ -1,9 +1,3 @@ -import { buildEnv } from "@cap/env"; - -const INFRA_PREVIEW_EMAILS = new Set(["richie@cap.so"]); - -export function canUseInfra(email: string | null | undefined) { - if (buildEnv.NEXT_PUBLIC_IS_CAP !== "true") return true; - if (!email) return false; - return INFRA_PREVIEW_EMAILS.has(email.toLowerCase()); +export function canUseInfra(_email?: string | null) { + return true; } From c662eaeb4f203ef727d06b4eadc9923c0263b0e8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:23:20 -0500 Subject: [PATCH 2/6] test(web): expect Cap Infra to be open to every account --- .../developer-actions-preview-gate.test.ts | 16 ++------- .../unit/developer-credits-checkout.test.ts | 16 ++------- apps/web/__tests__/unit/infra-access.test.ts | 36 +++++-------------- .../__tests__/unit/infra-auto-top-up.test.ts | 23 ++++-------- apps/web/__tests__/unit/infra-landing.test.ts | 27 +++++++------- 5 files changed, 36 insertions(+), 82 deletions(-) diff --git a/apps/web/__tests__/unit/developer-actions-preview-gate.test.ts b/apps/web/__tests__/unit/developer-actions-preview-gate.test.ts index ded21e31beb..084e1743bf4 100644 --- a/apps/web/__tests__/unit/developer-actions-preview-gate.test.ts +++ b/apps/web/__tests__/unit/developer-actions-preview-gate.test.ts @@ -103,21 +103,11 @@ const actions: [string, () => Promise][] = [ ], ]; -describe("Cap Infra console actions and the preview list", () => { +describe("Cap Infra console actions and sign-in", () => { beforeEach(() => { mocks.touched = []; }); - it.each(actions)( - "does not let an account outside the preview %s", - async (_name, run) => { - mocks.user = { id: "user-1", email: "someone@example.com" }; - - await expect(run()).rejects.toThrow("Unauthorized"); - expect(mocks.touched).toEqual([]); - }, - ); - it.each(actions)( "does not let a signed-out visitor %s", async (_name, run) => { @@ -129,9 +119,9 @@ describe("Cap Infra console actions and the preview list", () => { ); it.each(actions)( - "lets an account on the preview list through to %s", + "lets any signed-in account through to %s", async (_name, run) => { - mocks.user = { id: "user-1", email: "richie@cap.so" }; + mocks.user = { id: "user-1", email: "someone@example.com" }; const outcome = await run().then( () => "returned", diff --git a/apps/web/__tests__/unit/developer-credits-checkout.test.ts b/apps/web/__tests__/unit/developer-credits-checkout.test.ts index c3e6d6edfa5..08aa5734870 100644 --- a/apps/web/__tests__/unit/developer-credits-checkout.test.ts +++ b/apps/web/__tests__/unit/developer-credits-checkout.test.ts @@ -168,27 +168,17 @@ describe("POST /api/developer/credits/checkout", () => { expect(res.status).toBe(400); }); - it("refuses a purchase from an account outside the preview list", async () => { + it("lets any signed-in account reach the purchase on Cap's own deployment", async () => { const { buildEnv } = await import("@cap/env"); const env = buildEnv as { NEXT_PUBLIC_IS_CAP?: string }; env.NEXT_PUBLIC_IS_CAP = "true"; try { mockGetCurrentUser.mockResolvedValue(mockUser); - const res = await POST( - makeRequest({ appId: "app-456", amountCents: 1000 }), - ); - expect(res.status).toBe(403); - expect(mockStripe.checkout.sessions.create).not.toHaveBeenCalled(); - - mockGetCurrentUser.mockResolvedValue({ - ...mockUser, - email: "richie@cap.so", - }); mockDb.limit.mockResolvedValueOnce([]); - const allowed = await POST( + const res = await POST( makeRequest({ appId: "app-456", amountCents: 1000 }), ); - expect(allowed.status).toBe(404); + expect(res.status).toBe(404); } finally { env.NEXT_PUBLIC_IS_CAP = ""; } diff --git a/apps/web/__tests__/unit/infra-access.test.ts b/apps/web/__tests__/unit/infra-access.test.ts index 2c07bbc57dd..3e0604a331e 100644 --- a/apps/web/__tests__/unit/infra-access.test.ts +++ b/apps/web/__tests__/unit/infra-access.test.ts @@ -1,35 +1,17 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const env = vi.hoisted(() => ({ - NEXT_PUBLIC_IS_CAP: "true" as string | undefined, -})); - -vi.mock("@cap/env", () => ({ buildEnv: env })); +import { describe, expect, it } from "vitest"; import { canUseInfra } from "@/lib/infra-access"; -describe("Cap Infra preview access", () => { - beforeEach(() => { - env.NEXT_PUBLIC_IS_CAP = "true"; - }); - - it("lets a preview account in, whatever the letter case", () => { +describe("Cap Infra access", () => { + it("is open to every signed-in account", () => { expect(canUseInfra("richie@cap.so")).toBe(true); - expect(canUseInfra("Richie@Cap.so")).toBe(true); - }); - - it("keeps everyone else out on Cap's own deployment", () => { - expect(canUseInfra("someone@example.com")).toBe(false); - expect(canUseInfra("richie@cap.so.example.com")).toBe(false); - expect(canUseInfra("")).toBe(false); - expect(canUseInfra(null)).toBe(false); - expect(canUseInfra(undefined)).toBe(false); + expect(canUseInfra("someone@example.com")).toBe(true); + expect(canUseInfra("Someone@Example.com")).toBe(true); }); - it("stays open on self-hosted deployments", () => { - for (const value of ["false", "", undefined]) { - env.NEXT_PUBLIC_IS_CAP = value; - expect(canUseInfra("someone@example.com")).toBe(true); - } + it("reports open access when asked without an account", () => { + expect(canUseInfra(null)).toBe(true); + expect(canUseInfra(undefined)).toBe(true); + expect(canUseInfra("")).toBe(true); }); }); diff --git a/apps/web/__tests__/unit/infra-auto-top-up.test.ts b/apps/web/__tests__/unit/infra-auto-top-up.test.ts index c4f416818db..054f0db4ec8 100644 --- a/apps/web/__tests__/unit/infra-auto-top-up.test.ts +++ b/apps/web/__tests__/unit/infra-auto-top-up.test.ts @@ -197,18 +197,6 @@ describe("auto top-up settings", () => { }); describe("auto top-up charge", () => { - it("never charges an account outside the preview list", async () => { - ledger.account.email = "someone@example.com"; - - expect(await runAutoTopUp("account-1", now())).toEqual({ - state: "skipped", - reason: "not_allowed", - }); - expect(mocks.createPaymentIntent).not.toHaveBeenCalled(); - expect(ledger.rows).toHaveLength(0); - expect(ledger.account.autoTopUpEnabled).toBe(true); - }); - it("does nothing when switched off, without a card, misconfigured or above the threshold", async () => { ledger.account.autoTopUpEnabled = false; expect(await runAutoTopUp("account-1", now())).toMatchObject({ @@ -791,12 +779,15 @@ describe("billing emails", () => { expect(mocks.sendEmail).toHaveBeenCalledTimes(1); }); - it("never emails an account outside the preview list", async () => { + it("emails any account owner when the switch is on", async () => { mocks.emailsEnabled = true; ledger.account.email = "someone@example.com"; - expect(await outOfCredit()).toBe(false); - expect(await topUpFailed()).toBe(false); - expect(mocks.sendEmail).not.toHaveBeenCalled(); + expect(await outOfCredit()).toBe(true); + expect(await topUpFailed()).toBe(true); + expect(mocks.sendEmail).toHaveBeenCalledTimes(2); + expect(mocks.sendEmail.mock.calls[0]?.[0]).toMatchObject({ + email: "someone@example.com", + }); }); }); diff --git a/apps/web/__tests__/unit/infra-landing.test.ts b/apps/web/__tests__/unit/infra-landing.test.ts index d39f2893795..c8f0d919343 100644 --- a/apps/web/__tests__/unit/infra-landing.test.ts +++ b/apps/web/__tests__/unit/infra-landing.test.ts @@ -146,18 +146,18 @@ describe("the Cap Infra landing page", () => { expect(text).not.toContain(String.fromCodePoint(0x2014)); }); - it("asks for access while Cap Infra is in preview", async () => { + it("lets anyone start building on Cap's own deployment", async () => { const content = await load("true"); - expect(content.OPEN_ACCESS).toBe(false); - expect(content.START.label).toBe("Request access"); - expect(content.START.href).toBe( - "mailto:hello@cap.so?subject=Cap%20Infra%20access", - ); - expect(content.PREVIEW_NOTE).toBe( - "Cap Infra is in preview. Email hello@cap.so for access.", - ); - expect(content.infraFaqs[0]?.answer).toBe(content.PREVIEW_NOTE); + expect(content.OPEN_ACCESS).toBe(true); + expect(content.START).toEqual({ + label: "Start building", + href: "/dashboard/infra", + }); + expect(content.PREVIEW_NOTE).toBeNull(); + expect( + content.infraFaqs.some((faq) => faq.answer.includes("preview")), + ).toBe(false); }); it("opens the console where anyone can use it", async () => { @@ -227,8 +227,9 @@ describe("the rendered page", () => { const embed = content.PLAYER.find((entry) => entry.id === "embed")?.code; expect(embed).toContain('loading="lazy"'); expect(text).toContain(embed); - expect(text).toContain("Request access"); - expect(text).not.toContain("Start building"); + expect(text).toContain("Start building"); + expect(text).not.toContain("Request access"); + expect(text).not.toContain("in preview"); for (const image of page.querySelectorAll("img")) { expect(image.hasAttribute("alt")).toBe(true); const eager = @@ -354,7 +355,7 @@ describe("the agent section", () => { expect(text).toContain(rest.join("\n").trim()); expect(text).toContain(CAP_INFRA_POINTER_PROMPT); expect(text).toContain("Copy prompt"); - expect(text).toContain("Cap Infra is in preview."); + expect(text).not.toContain("Cap Infra is in preview."); expect(text).not.toMatch(CLI); const hrefs = [...page.querySelectorAll("a")].map((link) => link.getAttribute("href"), From 89fc069869d57c9924de3d02ff04cf76acb5b3b4 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 05:58:10 -0500 Subject: [PATCH 3/6] feat(web): link Cap Infra from the site footer --- apps/web/app/(site)/Footer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/app/(site)/Footer.tsx b/apps/web/app/(site)/Footer.tsx index 7c60647a61a..6a311c83fc3 100644 --- a/apps/web/app/(site)/Footer.tsx +++ b/apps/web/app/(site)/Footer.tsx @@ -23,6 +23,7 @@ const footerLinks = { { label: "Changelog", href: "/changelog" }, { label: "Docs", href: "/docs" }, { label: "Cap for Agents", href: "/agents" }, + { label: "Cap Infra", href: "/infra" }, { label: "Pricing", href: "/pricing" }, { label: "Download", href: "/download" }, { From eaca368a3c542d62026ef5ab43e768b3b846542d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 06:19:57 -0500 Subject: [PATCH 4/6] perf(web): do not prefetch the Infra console from the dashboard sidebar --- apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx index 32c62d4b815..326bc58779e 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx @@ -42,6 +42,7 @@ import { SignedImageUrl } from "@/components/SignedImageUrl"; import { Tooltip } from "@/components/Tooltip"; import { UsageButton } from "@/components/UsageButton"; import { canUseInfra } from "@/lib/infra-access"; +import { INFRA_BASE_PATH } from "@/lib/infra-routes"; import { loomImportDestinationFromPathname, loomImportPageHref, @@ -555,7 +556,7 @@ const NavItem = ({ onMouseLeave={() => { iconRef.current?.stopAnimation(); }} - prefetch={true} + prefetch={href !== INFRA_BASE_PATH} passHref className={classNames( "relative border border-transparent transition z-3", From 1e23eb76229512d13f5c3a5baa1d1747d1cb8ec0 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 06:21:34 -0500 Subject: [PATCH 5/6] perf(web): do not prefetch the Infra console from the mobile tab bar --- apps/web/app/(org)/dashboard/_components/MobileTab.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx b/apps/web/app/(org)/dashboard/_components/MobileTab.tsx index 6a860da7e56..f51c5cc921c 100644 --- a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx +++ b/apps/web/app/(org)/dashboard/_components/MobileTab.tsx @@ -78,7 +78,12 @@ const MobileTab = () => { {Tabs.filter((i) => !i.adminOnly || canViewSettings) .filter((i) => !i.infraOnly || showInfra) .map((tab) => ( - + {tab.icon} ))} From dd6d2c79a5bf41c5e4fc289d0e47e2afb809203e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:06:12 -0500 Subject: [PATCH 6/6] test(web): expect Infra billing actions and the mobile tab to be open to every account --- .../unit/dashboard-mobile-tab-infra.test.ts | 4 ++-- .../unit/infra-billing-gates.test.ts | 24 ++++++++++++------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/apps/web/__tests__/unit/dashboard-mobile-tab-infra.test.ts b/apps/web/__tests__/unit/dashboard-mobile-tab-infra.test.ts index 0c6f12e7bb6..47e835291a9 100644 --- a/apps/web/__tests__/unit/dashboard-mobile-tab-infra.test.ts +++ b/apps/web/__tests__/unit/dashboard-mobile-tab-infra.test.ts @@ -69,14 +69,14 @@ describe("dashboard mobile tabs", () => { ).toBe("Infra"); }); - it("does not show Infra to someone who cannot use Infra", () => { + it("shows Infra to any org owner, not only a preview list", () => { context.user = { id: "owner", email: "someone@example.com" }; - expect(hrefs()).not.toContain("/dashboard/infra"); expect(hrefs()).toEqual([ "/dashboard/spaces/browse", "/dashboard/caps", "/dashboard/settings/organization", + "/dashboard/infra", ]); }); diff --git a/apps/web/__tests__/unit/infra-billing-gates.test.ts b/apps/web/__tests__/unit/infra-billing-gates.test.ts index af5bf2cf81d..1c11d356f13 100644 --- a/apps/web/__tests__/unit/infra-billing-gates.test.ts +++ b/apps/web/__tests__/unit/infra-billing-gates.test.ts @@ -227,7 +227,9 @@ describe("creating an upload", () => { }); describe("console upload route", () => { - it("is refused for an account outside the preview list without reading the app", async () => { + it("checks ownership for any signed-in account, not a preview list", async () => { + mocks.selects = [[]]; + expect(await ownsInfraApp(outsider as never, "app-1")).toBe(false); expect(mocks.selects).toHaveLength(0); }); @@ -254,10 +256,11 @@ describe("console upload route", () => { }); describe("saving a card", () => { - it("refuses an account outside the preview list before reaching the payment provider", async () => { + it("treats any signed-in account by ownership before reaching the payment provider", async () => { mocks.user = outsider; + mocks.selects = [[]]; - expect(await cardCheckout()).toEqual({ status: "forbidden" }); + expect(await cardCheckout()).toEqual({ status: "not_found" }); expect(mocks.ensureCustomer).not.toHaveBeenCalled(); expect(mocks.createSession).not.toHaveBeenCalled(); }); @@ -337,11 +340,12 @@ describe("auto top-up settings action", () => { extra: Partial[0]> = {}, ) => updateDeveloperAutoTopUp({ appId: "app-1", enabled: true, ...extra }); - it("refuses a signed-out user and an account outside the preview list", async () => { + it("refuses a signed-out user, and any account for an app it does not own", async () => { mocks.user = null; await expect(save()).rejects.toThrow("Unauthorized"); mocks.user = outsider; - await expect(save()).rejects.toThrow("Unauthorized"); + mocks.selects = [[]]; + await expect(save()).rejects.toThrow("App not found"); expect(mocks.sets).toHaveLength(0); }); @@ -422,12 +426,16 @@ describe("auto top-up settings action", () => { }); describe("removing a saved card", () => { - it("refuses an account outside the preview list", async () => { - mocks.user = outsider; - + it("refuses a signed-out user, and any account for an app it does not own", async () => { + mocks.user = null; await expect(removeDeveloperSavedCard("app-1")).rejects.toThrow( "Unauthorized", ); + mocks.user = outsider; + mocks.selects = [[]]; + await expect(removeDeveloperSavedCard("app-1")).rejects.toThrow( + "App not found", + ); expect(mocks.detach).not.toHaveBeenCalled(); });