From 06f0822e9ce7692015e902f6e30b1d6395740f2b Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Mon, 4 May 2026 08:54:56 -0500 Subject: [PATCH 1/6] refactor(sandbox): callers use open-agents abstraction (Phase 2.2) (#509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(sandbox): callers use open-agents abstraction (Phase 2.2) Replaces direct @vercel/sandbox SDK calls with the open-agents sandbox abstraction layer (inlined in Phase 2.1) for sandbox lifecycle (create + reconnect). HTTP response shapes preserved exactly. Per the agreed Option B (hybrid): only the lifecycle creator helpers get refactored. installClaudeCode / runClaudeCode / getSandboxStatus stay on the SDK directly because the abstraction does not cover their needs (sudo, stdout/stderr streaming, simple status reads). Those two install/run files are also dead orphans (defined but never called) and will be removed entirely after the full migration. Production refactor: createSandbox.ts Sandbox.create(...) -> VercelSandbox.create(...) Input: VercelSandboxConfig (was SDK params) Snapshot trigger: restoreSnapshotId field (was source: { type: "snapshot", ... }) Returns VercelSandbox (was SDK Sandbox) createSandboxWithFallback.ts cascade — passes restoreSnapshotId to createSandbox createSandboxFromSnapshot.ts type cascade only (Sandbox -> VercelSandbox) getActiveSandbox.ts Sandbox.get({name}) -> VercelSandbox.connect(name, {}) Status check: sandbox.status -> sandbox.sdkStatus getOrCreateSandbox.ts no code change — type cascades automatically processCreateSandbox.ts reads sandbox.sdkStatus instead of sandbox.status defensive nullish on createdAt Abstraction extension: vercel/sandbox/VercelSandbox.ts adds two readonly getters following the existing host/environmentDetails/expiresAt pattern: get sdkStatus(): string — raw SDK session status (running/pending/ stopped/failed/aborted/snapshotting), distinct from the abstraction's normalized status getter get createdAt(): Date | undefined — SDK session.createdAt These give api callers what they need to construct the existing HTTP response shape without breaking the abstraction's interface. Tests updated: createSandbox.test.ts mocks VercelSandbox.create instead of Sandbox.create; mock object uses sdkStatus instead of status createSandboxWithFallback.test.ts asserts restoreSnapshotId pass-through getActiveSandbox.test.ts mocks VercelSandbox.connect; sdkStatus on mock objects processCreateSandbox.test.ts mockSandbox uses sdkStatus Verification: - pnpm lint:check: clean - pnpm test: 2391/2391 pass - HTTP response shape unchanged: same fields, same enum values for sandboxStatus (sourced from the SDK now via sdkStatus, was directly via SDK Sandbox.status before — identical strings either way) Co-Authored-By: Claude Opus 4.7 (1M context) * fix: address PR #509 review feedback Three real issues from CodeRabbit + cubic: 1. createdAt staleness (CodeRabbit minor) The new `createdAt` getter on VercelSandbox skipped the `refreshStateFromCurrentSession()` step that `sdkStatus` uses, so readers right after a reconnect could see stale session metadata. Add the refresh. 2. Fabricated createdAt (cubic P2) Both createSandbox.ts and processCreateSandbox.ts had a `?? new Date().toISOString()` fallback that fabricated creation metadata when sandbox.createdAt was missing. The SDK guarantees createdAt is populated for any reachable instance, so the fallback was both wrong (fabricates data) and unnecessary. Tighten the getter to return `Date` (not `Date | undefined`) and throw with an explicit "SDK contract violation" message if the field is missing — fail-fast surfaces a real contract bug instead of silently lying. Drop the `?? new Date()` fallbacks at both call sites. 3. Misleading snapshot-restore branching (CodeRabbit major) createSandbox.ts had two paths — a "snapshot" branch that omitted DEFAULT_VCPUS/DEFAULT_RUNTIME (intent: let snapshot dictate), and a "fresh" branch that applied defaults. But VercelSandbox.create internally defaults vcpus=4 and runtime="node22" regardless, so the omission was a no-op — the abstraction always forwarded those to the SDK. Drop the misleading branching. Document the actual behavior at the top of createSandbox: "VercelSandbox.create applies its own defaults regardless of source — those apply to the runtime resources of the new sandbox even when restoring from a snapshot." Updated the snapshot-restore test to assert the actual call shape (vcpus + runtime + timeout + restoreSnapshotId) instead of just the original SDK-style truncated args. Verification: - pnpm lint:check: clean - pnpm test: 2391/2391 pass Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- lib/sandbox/__tests__/createSandbox.test.ts | 40 +++++++------ .../createSandboxWithFallback.test.ts | 2 +- .../__tests__/getActiveSandbox.test.ts | 25 ++++---- .../__tests__/processCreateSandbox.test.ts | 6 +- lib/sandbox/createSandbox.ts | 60 +++++++++---------- lib/sandbox/createSandboxFromSnapshot.ts | 4 +- lib/sandbox/createSandboxWithFallback.ts | 2 +- lib/sandbox/getActiveSandbox.ts | 11 ++-- lib/sandbox/processCreateSandbox.ts | 2 +- lib/sandbox/vercel/sandbox/VercelSandbox.ts | 25 ++++++++ 10 files changed, 101 insertions(+), 76 deletions(-) diff --git a/lib/sandbox/__tests__/createSandbox.test.ts b/lib/sandbox/__tests__/createSandbox.test.ts index b80dbc44b..83feae2b7 100644 --- a/lib/sandbox/__tests__/createSandbox.test.ts +++ b/lib/sandbox/__tests__/createSandbox.test.ts @@ -1,17 +1,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createSandbox } from "../createSandbox"; -import { Sandbox } from "@vercel/sandbox"; +import { VercelSandbox } from "../vercel"; const mockSandbox = { name: "sbx_test123", - status: "running", + sdkStatus: "running", timeout: 1800000, createdAt: new Date("2024-01-01T00:00:00Z"), }; -vi.mock("@vercel/sandbox", () => ({ - Sandbox: { +vi.mock("../vercel", () => ({ + VercelSandbox: { create: vi.fn(() => Promise.resolve(mockSandbox)), }, })); @@ -28,40 +28,42 @@ describe("createSandbox", () => { vi.clearAllMocks(); }); - it("creates sandbox with default configuration when no params provided", async () => { + it("creates sandbox with default configuration when no config provided", async () => { await createSandbox(); - expect(Sandbox.create).toHaveBeenCalledWith({ - resources: { vcpus: 4 }, + expect(VercelSandbox.create).toHaveBeenCalledWith({ + vcpus: 4, timeout: 1800000, runtime: "node22", }); }); - it("creates sandbox from snapshot when source is provided", async () => { - await createSandbox({ source: { type: "snapshot", snapshotId: "snap_abc123" } }); + it("restores from snapshot when restoreSnapshotId is provided", async () => { + await createSandbox({ restoreSnapshotId: "snap_abc123" }); - expect(Sandbox.create).toHaveBeenCalledWith({ - source: { type: "snapshot", snapshotId: "snap_abc123" }, + expect(VercelSandbox.create).toHaveBeenCalledWith({ + vcpus: 4, + runtime: "node22", timeout: 1800000, + restoreSnapshotId: "snap_abc123", }); }); it("allows overriding default timeout", async () => { await createSandbox({ timeout: 300000 }); - expect(Sandbox.create).toHaveBeenCalledWith({ - resources: { vcpus: 4 }, + expect(VercelSandbox.create).toHaveBeenCalledWith({ + vcpus: 4, timeout: 300000, runtime: "node22", }); }); - it("allows overriding default resources", async () => { - await createSandbox({ resources: { vcpus: 2 } }); + it("allows overriding default vcpus", async () => { + await createSandbox({ vcpus: 2 }); - expect(Sandbox.create).toHaveBeenCalledWith({ - resources: { vcpus: 2 }, + expect(VercelSandbox.create).toHaveBeenCalledWith({ + vcpus: 2, timeout: 1800000, runtime: "node22", }); @@ -84,7 +86,9 @@ describe("createSandbox", () => { ...mockSandbox, stop: vi.fn(), }; - vi.mocked(Sandbox.create).mockResolvedValue(mockSandboxWithStop as unknown as Sandbox); + vi.mocked(VercelSandbox.create).mockResolvedValue( + mockSandboxWithStop as unknown as VercelSandbox, + ); await createSandbox(); diff --git a/lib/sandbox/__tests__/createSandboxWithFallback.test.ts b/lib/sandbox/__tests__/createSandboxWithFallback.test.ts index 3af6d1fed..badc56b51 100644 --- a/lib/sandbox/__tests__/createSandboxWithFallback.test.ts +++ b/lib/sandbox/__tests__/createSandboxWithFallback.test.ts @@ -27,7 +27,7 @@ describe("createSandboxWithFallback", () => { const result = await createSandboxWithFallback("snap_abc"); expect(mockCreateSandbox).toHaveBeenCalledWith({ - source: { type: "snapshot", snapshotId: "snap_abc" }, + restoreSnapshotId: "snap_abc", }); expect(result).toEqual({ ...mockCreateResult, fromSnapshot: true }); }); diff --git a/lib/sandbox/__tests__/getActiveSandbox.test.ts b/lib/sandbox/__tests__/getActiveSandbox.test.ts index 0f6ca2e6f..db36ca821 100644 --- a/lib/sandbox/__tests__/getActiveSandbox.test.ts +++ b/lib/sandbox/__tests__/getActiveSandbox.test.ts @@ -1,13 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { Sandbox } from "@vercel/sandbox"; +import { VercelSandbox } from "@/lib/sandbox/vercel"; import { getActiveSandbox } from "../getActiveSandbox"; const mockSelectAccountSandboxes = vi.fn(); -vi.mock("@vercel/sandbox", () => ({ - Sandbox: { - get: vi.fn(), +vi.mock("@/lib/sandbox/vercel", () => ({ + VercelSandbox: { + connect: vi.fn(), }, })); @@ -25,17 +25,16 @@ describe("getActiveSandbox", () => { const mockSandbox = { name: "sbx_123", - status: "running", - runCommand: vi.fn(), + sdkStatus: "running", }; - vi.mocked(Sandbox.get).mockResolvedValue(mockSandbox as unknown as Sandbox); + vi.mocked(VercelSandbox.connect).mockResolvedValue(mockSandbox as unknown as VercelSandbox); const result = await getActiveSandbox("acc_1"); expect(mockSelectAccountSandboxes).toHaveBeenCalledWith({ accountIds: ["acc_1"], }); - expect(Sandbox.get).toHaveBeenCalledWith({ name: "sbx_123" }); + expect(VercelSandbox.connect).toHaveBeenCalledWith("sbx_123", {}); expect(result).toBe(mockSandbox); }); @@ -45,7 +44,7 @@ describe("getActiveSandbox", () => { const result = await getActiveSandbox("acc_1"); expect(result).toBeNull(); - expect(Sandbox.get).not.toHaveBeenCalled(); + expect(VercelSandbox.connect).not.toHaveBeenCalled(); }); it("returns null when sandbox is not running", async () => { @@ -55,21 +54,21 @@ describe("getActiveSandbox", () => { const mockSandbox = { name: "sbx_stopped", - status: "stopped", + sdkStatus: "stopped", }; - vi.mocked(Sandbox.get).mockResolvedValue(mockSandbox as unknown as Sandbox); + vi.mocked(VercelSandbox.connect).mockResolvedValue(mockSandbox as unknown as VercelSandbox); const result = await getActiveSandbox("acc_1"); expect(result).toBeNull(); }); - it("returns null when Sandbox.get throws", async () => { + it("returns null when VercelSandbox.connect throws", async () => { mockSelectAccountSandboxes.mockResolvedValue([ { sandbox_id: "sbx_expired", account_id: "acc_1" }, ]); - vi.mocked(Sandbox.get).mockRejectedValue(new Error("Sandbox not found")); + vi.mocked(VercelSandbox.connect).mockRejectedValue(new Error("Sandbox not found")); const result = await getActiveSandbox("acc_1"); diff --git a/lib/sandbox/__tests__/processCreateSandbox.test.ts b/lib/sandbox/__tests__/processCreateSandbox.test.ts index 160cf287c..789b0a998 100644 --- a/lib/sandbox/__tests__/processCreateSandbox.test.ts +++ b/lib/sandbox/__tests__/processCreateSandbox.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { Sandbox } from "@vercel/sandbox"; +import type { VercelSandbox } from "@/lib/sandbox/vercel"; import { processCreateSandbox } from "../processCreateSandbox"; import { createSandboxFromSnapshot } from "@/lib/sandbox/createSandboxFromSnapshot"; @@ -15,10 +15,10 @@ vi.mock("@/lib/trigger/triggerPromptSandbox", () => ({ const mockSandbox = { name: "sbx_123", - status: "running", + sdkStatus: "running", timeout: 600000, createdAt: new Date("2024-01-01T00:00:00.000Z"), -} as unknown as Sandbox; +} as unknown as VercelSandbox; describe("processCreateSandbox", () => { beforeEach(() => { diff --git a/lib/sandbox/createSandbox.ts b/lib/sandbox/createSandbox.ts index d6c732128..cc813cf98 100644 --- a/lib/sandbox/createSandbox.ts +++ b/lib/sandbox/createSandbox.ts @@ -1,61 +1,57 @@ import ms from "ms"; -import { Sandbox } from "@vercel/sandbox"; +import { VercelSandbox, type VercelSandboxConfig } from "@/lib/sandbox/vercel"; export interface SandboxCreatedResponse { - sandboxId: Sandbox["name"]; - sandboxStatus: Sandbox["status"]; - timeout: Sandbox["timeout"]; + sandboxId: VercelSandbox["name"]; + sandboxStatus: string; + timeout: VercelSandbox["timeout"]; createdAt: string; } export interface SandboxCreateResult { - sandbox: Sandbox; + sandbox: VercelSandbox; response: SandboxCreatedResponse; } -/** Extract CreateSandboxParams from Sandbox.create method signature */ -export type CreateSandboxParams = NonNullable[0]>; +/** Parameters for the api-side createSandbox helper. Wraps the abstraction's + * VercelSandboxConfig so callers do not need to import it directly. */ +export type CreateSandboxParams = VercelSandboxConfig; const DEFAULT_TIMEOUT = ms("30m"); const DEFAULT_VCPUS = 4; -const DEFAULT_RUNTIME = "node22"; +const DEFAULT_RUNTIME = "node22" as const; /** - * Creates a Vercel Sandbox and returns its info. + * Creates a Vercel Sandbox via the open-agents abstraction and returns + * its info. The sandbox is left running so subsequent prompts can run + * against it. * - * The sandbox is left running so that prompts can be executed via the prompt_sandbox tool. - * Accepts the same parameters as Sandbox.create from @vercel/sandbox. + * Note: VercelSandbox.create applies its own defaults for vcpus and + * runtime (vcpus=4, runtime="node22") regardless of source — those + * apply to the runtime resources of the new sandbox even when restoring + * from a snapshot. We pass our preferred defaults explicitly so api's + * intent is documented at the call site. * - * @param params - Sandbox creation parameters (source, timeout, resources, runtime, ports) - * @returns The sandbox creation response + * @param config - VercelSandboxConfig (timeout, vcpus, runtime, + * restoreSnapshotId, source, ports, env, etc.) + * @returns The sandbox creation result (instance + response shape) * @throws Error if sandbox creation fails */ export async function createSandbox( - params: CreateSandboxParams = {}, + config: CreateSandboxParams = {}, ): Promise { - const hasSnapshotSource = - params.source && "type" in params.source && params.source.type === "snapshot"; - - // Pass params directly to SDK - it handles all the type variants - const sandbox = await Sandbox.create( - hasSnapshotSource - ? { - ...params, - timeout: params.timeout ?? DEFAULT_TIMEOUT, - } - : { - resources: { vcpus: DEFAULT_VCPUS }, - timeout: params.timeout ?? DEFAULT_TIMEOUT, - runtime: DEFAULT_RUNTIME, - ...params, - }, - ); + const sandbox = await VercelSandbox.create({ + vcpus: DEFAULT_VCPUS, + runtime: DEFAULT_RUNTIME, + timeout: DEFAULT_TIMEOUT, + ...config, + }); return { sandbox, response: { sandboxId: sandbox.name, - sandboxStatus: sandbox.status, + sandboxStatus: sandbox.sdkStatus, timeout: sandbox.timeout, createdAt: sandbox.createdAt.toISOString(), }, diff --git a/lib/sandbox/createSandboxFromSnapshot.ts b/lib/sandbox/createSandboxFromSnapshot.ts index 2dcfb7ef4..b1132a4b6 100644 --- a/lib/sandbox/createSandboxFromSnapshot.ts +++ b/lib/sandbox/createSandboxFromSnapshot.ts @@ -1,10 +1,10 @@ -import type { Sandbox } from "@vercel/sandbox"; +import type { VercelSandbox } from "@/lib/sandbox/vercel"; import { createSandboxWithFallback } from "@/lib/sandbox/createSandboxWithFallback"; import { getValidSnapshotId } from "@/lib/sandbox/getValidSnapshotId"; import { insertAccountSandbox } from "@/lib/supabase/account_sandboxes/insertAccountSandbox"; export interface CreateSandboxFromSnapshotResult { - sandbox: Sandbox; + sandbox: VercelSandbox; fromSnapshot: boolean; } diff --git a/lib/sandbox/createSandboxWithFallback.ts b/lib/sandbox/createSandboxWithFallback.ts index 93014ac43..d728485c5 100644 --- a/lib/sandbox/createSandboxWithFallback.ts +++ b/lib/sandbox/createSandboxWithFallback.ts @@ -14,7 +14,7 @@ export async function createSandboxWithFallback( ): Promise { if (snapshotId) { try { - const result = await createSandbox({ source: { type: "snapshot", snapshotId } }); + const result = await createSandbox({ restoreSnapshotId: snapshotId }); return { ...result, fromSnapshot: true }; } catch (error) { console.error("Snapshot sandbox creation failed, falling back to fresh sandbox:", error); diff --git a/lib/sandbox/getActiveSandbox.ts b/lib/sandbox/getActiveSandbox.ts index f47dec4f6..c0ba2796f 100644 --- a/lib/sandbox/getActiveSandbox.ts +++ b/lib/sandbox/getActiveSandbox.ts @@ -1,13 +1,14 @@ -import { Sandbox } from "@vercel/sandbox"; +import { VercelSandbox } from "@/lib/sandbox/vercel"; import { selectAccountSandboxes } from "@/lib/supabase/account_sandboxes/selectAccountSandboxes"; /** * Finds the most recent sandbox for an account and returns it if still running. + * Reconnects via the open-agents sandbox abstraction. * * @param accountId - The account ID to find an active sandbox for - * @returns The running Sandbox instance, or null if none found + * @returns The running VercelSandbox instance, or null if none found */ -export async function getActiveSandbox(accountId: string): Promise { +export async function getActiveSandbox(accountId: string): Promise { const sandboxes = await selectAccountSandboxes({ accountIds: [accountId], }); @@ -19,9 +20,9 @@ export async function getActiveSandbox(accountId: string): Promise Date: Mon, 4 May 2026 11:35:58 -0500 Subject: [PATCH 2/6] chore(sandbox): delete dead Claude Code helpers (Phase 2.3) (#512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(sandbox): delete dead Claude Code helpers (Phase 2.3) installClaudeCode and runClaudeCode were defined but never imported anywhere in api production code — confirmed by grep on main: $ grep -rn "installClaudeCode\b\|runClaudeCode\b" lib/ app/ lib/sandbox/installClaudeCode.ts:9: export async function installClaudeCode(...) lib/sandbox/runClaudeCode.ts:10: export async function runClaudeCode(...) Both files were skipped during the Phase 2.2 abstraction refactor (per the agreed Option B — they used SDK features the abstraction doesn't expose: sudo, stdout/stderr streaming, batched writes). With the broader migration moving to Vercel Workflow + open-agents' agent package for sandbox bootstrap, these orphans have no path to being called again. Removed: lib/sandbox/installClaudeCode.ts (32 lines) lib/sandbox/runClaudeCode.ts (29 lines) lib/sandbox/__tests__/installClaudeCode.test.ts (4 tests) lib/sandbox/__tests__/runClaudeCode.test.ts (6 tests) Verification: - pnpm lint:check: clean - pnpm test: 2381/2381 pass (was 2391 — net -10 tests from the two deleted test files) Note: getOrCreateSandbox.ts also has zero importers per the audit and is similarly dead, but is intentionally NOT deleted in this PR since it was not explicitly flagged as orphan in the migration plan. Worth a separate follow-up decision. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(sandbox): also delete getOrCreateSandbox + getActiveSandbox (YAGNI) Cascade audit found two more truly-dead helpers per YAGNI: getOrCreateSandbox.ts 0 importers (self-only references) getActiveSandbox.ts only called by getOrCreateSandbox — orphan once that goes Removed: lib/sandbox/getOrCreateSandbox.ts (39 lines) lib/sandbox/getActiveSandbox.ts (33 lines) lib/sandbox/__tests__/getOrCreateSandbox.test.ts (3 tests) lib/sandbox/__tests__/getActiveSandbox.test.ts (4 tests) Live consumers of related helpers preserved: - createSandboxFromSnapshot still used by processCreateSandbox - selectAccountSandboxes still used by aggregateAccountSandboxStats, buildGetSandboxesParams, getSandboxesHandler, validateGetSandboxesRequest Verification: - pnpm lint:check: clean - pnpm test: 2374/2374 pass (was 2381 — net -7 from the two deleted test files; -3 from getOrCreateSandbox.test.ts + -4 from getActiveSandbox.test.ts) Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../__tests__/getActiveSandbox.test.ts | 77 ---------------- .../__tests__/getOrCreateSandbox.test.ts | 85 ----------------- .../__tests__/installClaudeCode.test.ts | 53 ----------- lib/sandbox/__tests__/runClaudeCode.test.ts | 92 ------------------- lib/sandbox/getActiveSandbox.ts | 33 ------- lib/sandbox/getOrCreateSandbox.ts | 38 -------- lib/sandbox/installClaudeCode.ts | 32 ------- lib/sandbox/runClaudeCode.ts | 29 ------ 8 files changed, 439 deletions(-) delete mode 100644 lib/sandbox/__tests__/getActiveSandbox.test.ts delete mode 100644 lib/sandbox/__tests__/getOrCreateSandbox.test.ts delete mode 100644 lib/sandbox/__tests__/installClaudeCode.test.ts delete mode 100644 lib/sandbox/__tests__/runClaudeCode.test.ts delete mode 100644 lib/sandbox/getActiveSandbox.ts delete mode 100644 lib/sandbox/getOrCreateSandbox.ts delete mode 100644 lib/sandbox/installClaudeCode.ts delete mode 100644 lib/sandbox/runClaudeCode.ts diff --git a/lib/sandbox/__tests__/getActiveSandbox.test.ts b/lib/sandbox/__tests__/getActiveSandbox.test.ts deleted file mode 100644 index db36ca821..000000000 --- a/lib/sandbox/__tests__/getActiveSandbox.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { VercelSandbox } from "@/lib/sandbox/vercel"; - -import { getActiveSandbox } from "../getActiveSandbox"; - -const mockSelectAccountSandboxes = vi.fn(); - -vi.mock("@/lib/sandbox/vercel", () => ({ - VercelSandbox: { - connect: vi.fn(), - }, -})); - -vi.mock("@/lib/supabase/account_sandboxes/selectAccountSandboxes", () => ({ - selectAccountSandboxes: (...args: unknown[]) => mockSelectAccountSandboxes(...args), -})); - -describe("getActiveSandbox", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns sandbox when most recent is running", async () => { - mockSelectAccountSandboxes.mockResolvedValue([{ sandbox_id: "sbx_123", account_id: "acc_1" }]); - - const mockSandbox = { - name: "sbx_123", - sdkStatus: "running", - }; - vi.mocked(VercelSandbox.connect).mockResolvedValue(mockSandbox as unknown as VercelSandbox); - - const result = await getActiveSandbox("acc_1"); - - expect(mockSelectAccountSandboxes).toHaveBeenCalledWith({ - accountIds: ["acc_1"], - }); - expect(VercelSandbox.connect).toHaveBeenCalledWith("sbx_123", {}); - expect(result).toBe(mockSandbox); - }); - - it("returns null when no sandboxes exist", async () => { - mockSelectAccountSandboxes.mockResolvedValue([]); - - const result = await getActiveSandbox("acc_1"); - - expect(result).toBeNull(); - expect(VercelSandbox.connect).not.toHaveBeenCalled(); - }); - - it("returns null when sandbox is not running", async () => { - mockSelectAccountSandboxes.mockResolvedValue([ - { sandbox_id: "sbx_stopped", account_id: "acc_1" }, - ]); - - const mockSandbox = { - name: "sbx_stopped", - sdkStatus: "stopped", - }; - vi.mocked(VercelSandbox.connect).mockResolvedValue(mockSandbox as unknown as VercelSandbox); - - const result = await getActiveSandbox("acc_1"); - - expect(result).toBeNull(); - }); - - it("returns null when VercelSandbox.connect throws", async () => { - mockSelectAccountSandboxes.mockResolvedValue([ - { sandbox_id: "sbx_expired", account_id: "acc_1" }, - ]); - - vi.mocked(VercelSandbox.connect).mockRejectedValue(new Error("Sandbox not found")); - - const result = await getActiveSandbox("acc_1"); - - expect(result).toBeNull(); - }); -}); diff --git a/lib/sandbox/__tests__/getOrCreateSandbox.test.ts b/lib/sandbox/__tests__/getOrCreateSandbox.test.ts deleted file mode 100644 index 063b87927..000000000 --- a/lib/sandbox/__tests__/getOrCreateSandbox.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { Sandbox } from "@vercel/sandbox"; - -import { getOrCreateSandbox } from "../getOrCreateSandbox"; - -const mockGetActiveSandbox = vi.fn(); -const mockCreateSandboxFromSnapshot = vi.fn(); - -vi.mock("../getActiveSandbox", () => ({ - getActiveSandbox: (...args: unknown[]) => mockGetActiveSandbox(...args), -})); - -vi.mock("../createSandboxFromSnapshot", () => ({ - createSandboxFromSnapshot: (...args: unknown[]) => mockCreateSandboxFromSnapshot(...args), -})); - -describe("getOrCreateSandbox", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns existing sandbox with created=false and fromSnapshot=true", async () => { - const mockSandbox = { - name: "sbx_existing", - status: "running", - } as unknown as Sandbox; - - mockGetActiveSandbox.mockResolvedValue(mockSandbox); - - const result = await getOrCreateSandbox("acc_1"); - - expect(result).toEqual({ - sandbox: mockSandbox, - sandboxId: "sbx_existing", - created: false, - fromSnapshot: true, - }); - expect(mockCreateSandboxFromSnapshot).not.toHaveBeenCalled(); - }); - - it("creates new sandbox from snapshot with created=true, fromSnapshot=true", async () => { - const mockSandbox = { - name: "sbx_new", - status: "running", - } as unknown as Sandbox; - - mockGetActiveSandbox.mockResolvedValue(null); - mockCreateSandboxFromSnapshot.mockResolvedValue({ - sandbox: mockSandbox, - fromSnapshot: true, - }); - - const result = await getOrCreateSandbox("acc_1"); - - expect(result).toEqual({ - sandbox: mockSandbox, - sandboxId: "sbx_new", - created: true, - fromSnapshot: true, - }); - expect(mockCreateSandboxFromSnapshot).toHaveBeenCalledWith("acc_1"); - }); - - it("creates fresh sandbox with created=true, fromSnapshot=false", async () => { - const mockSandbox = { - name: "sbx_fresh", - status: "running", - } as unknown as Sandbox; - - mockGetActiveSandbox.mockResolvedValue(null); - mockCreateSandboxFromSnapshot.mockResolvedValue({ - sandbox: mockSandbox, - fromSnapshot: false, - }); - - const result = await getOrCreateSandbox("acc_1"); - - expect(result).toEqual({ - sandbox: mockSandbox, - sandboxId: "sbx_fresh", - created: true, - fromSnapshot: false, - }); - }); -}); diff --git a/lib/sandbox/__tests__/installClaudeCode.test.ts b/lib/sandbox/__tests__/installClaudeCode.test.ts deleted file mode 100644 index 1979b1fe2..000000000 --- a/lib/sandbox/__tests__/installClaudeCode.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { Sandbox } from "@vercel/sandbox"; -import { installClaudeCode } from "../installClaudeCode"; - -describe("installClaudeCode", () => { - const mockSandbox = { - runCommand: vi.fn(), - } as unknown as Sandbox; - - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(mockSandbox.runCommand).mockResolvedValue({ exitCode: 0 } as never); - }); - - it("installs Claude Code CLI globally with sudo", async () => { - await installClaudeCode(mockSandbox); - - expect(mockSandbox.runCommand).toHaveBeenCalledWith({ - cmd: "npm", - args: ["install", "-g", "@anthropic-ai/claude-code"], - stderr: process.stderr, - stdout: process.stdout, - sudo: true, - }); - }); - - it("installs Anthropic SDK", async () => { - await installClaudeCode(mockSandbox); - - expect(mockSandbox.runCommand).toHaveBeenCalledWith({ - cmd: "npm", - args: ["install", "@anthropic-ai/sdk"], - stderr: process.stderr, - stdout: process.stdout, - }); - }); - - it("throws error if CLI installation fails", async () => { - vi.mocked(mockSandbox.runCommand).mockResolvedValue({ exitCode: 1 } as never); - - await expect(installClaudeCode(mockSandbox)).rejects.toThrow( - "Failed to install Claude Code CLI", - ); - }); - - it("throws error if SDK installation fails", async () => { - vi.mocked(mockSandbox.runCommand) - .mockResolvedValueOnce({ exitCode: 0 } as never) - .mockResolvedValueOnce({ exitCode: 1 } as never); - - await expect(installClaudeCode(mockSandbox)).rejects.toThrow("Failed to install Anthropic SDK"); - }); -}); diff --git a/lib/sandbox/__tests__/runClaudeCode.test.ts b/lib/sandbox/__tests__/runClaudeCode.test.ts deleted file mode 100644 index e695a8ce0..000000000 --- a/lib/sandbox/__tests__/runClaudeCode.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { Sandbox } from "@vercel/sandbox"; -import { runClaudeCode } from "../runClaudeCode"; - -describe("runClaudeCode", () => { - const mockSandbox = { - writeFiles: vi.fn(), - runCommand: vi.fn(), - } as unknown as Sandbox; - - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(mockSandbox.writeFiles).mockResolvedValue(undefined); - vi.mocked(mockSandbox.runCommand).mockResolvedValue({ exitCode: 0 } as never); - }); - - it("writes script file with claude command and prompt", async () => { - const prompt = "create a hello world app"; - await runClaudeCode(mockSandbox, prompt); - - expect(mockSandbox.writeFiles).toHaveBeenCalledWith([ - { - path: "/vercel/sandbox/ralph-once.sh", - content: Buffer.from(`claude --permission-mode acceptEdits --model opus '${prompt}'`), - }, - ]); - }); - - it("executes script with sh command", async () => { - await runClaudeCode(mockSandbox, "test prompt"); - - expect(mockSandbox.runCommand).toHaveBeenCalledWith({ - cmd: "sh", - args: ["ralph-once.sh"], - stdout: process.stdout, - stderr: process.stderr, - env: { - ANTHROPIC_API_KEY: expect.any(String), - }, - }); - }); - - it("uses ANTHROPIC_API_KEY from environment", async () => { - process.env.ANTHROPIC_API_KEY = "test-api-key-123"; - await runClaudeCode(mockSandbox, "test prompt"); - - expect(mockSandbox.runCommand).toHaveBeenCalledWith( - expect.objectContaining({ - env: { - ANTHROPIC_API_KEY: "test-api-key-123", - }, - }), - ); - }); - - it("uses empty string if ANTHROPIC_API_KEY is not set", async () => { - delete process.env.ANTHROPIC_API_KEY; - await runClaudeCode(mockSandbox, "test prompt"); - - expect(mockSandbox.runCommand).toHaveBeenCalledWith( - expect.objectContaining({ - env: { - ANTHROPIC_API_KEY: "", - }, - }), - ); - }); - - it("escapes single quotes in prompt", async () => { - const prompt = "create a 'hello' world app"; - await runClaudeCode(mockSandbox, prompt); - - expect(mockSandbox.writeFiles).toHaveBeenCalledWith([ - { - path: "/vercel/sandbox/ralph-once.sh", - content: Buffer.from(`claude --permission-mode acceptEdits --model opus '${prompt}'`), - }, - ]); - }); - - it("handles multi-line prompts", async () => { - const prompt = "line 1\nline 2\nline 3"; - await runClaudeCode(mockSandbox, prompt); - - expect(mockSandbox.writeFiles).toHaveBeenCalledWith([ - { - path: "/vercel/sandbox/ralph-once.sh", - content: Buffer.from(`claude --permission-mode acceptEdits --model opus '${prompt}'`), - }, - ]); - }); -}); diff --git a/lib/sandbox/getActiveSandbox.ts b/lib/sandbox/getActiveSandbox.ts deleted file mode 100644 index c0ba2796f..000000000 --- a/lib/sandbox/getActiveSandbox.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { VercelSandbox } from "@/lib/sandbox/vercel"; -import { selectAccountSandboxes } from "@/lib/supabase/account_sandboxes/selectAccountSandboxes"; - -/** - * Finds the most recent sandbox for an account and returns it if still running. - * Reconnects via the open-agents sandbox abstraction. - * - * @param accountId - The account ID to find an active sandbox for - * @returns The running VercelSandbox instance, or null if none found - */ -export async function getActiveSandbox(accountId: string): Promise { - const sandboxes = await selectAccountSandboxes({ - accountIds: [accountId], - }); - - if (sandboxes.length === 0) { - return null; - } - - const mostRecent = sandboxes[0]; - - try { - const sandbox = await VercelSandbox.connect(mostRecent.sandbox_id, {}); - - if (sandbox.sdkStatus === "running") { - return sandbox; - } - - return null; - } catch { - return null; - } -} diff --git a/lib/sandbox/getOrCreateSandbox.ts b/lib/sandbox/getOrCreateSandbox.ts deleted file mode 100644 index e84838bd9..000000000 --- a/lib/sandbox/getOrCreateSandbox.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { getActiveSandbox } from "./getActiveSandbox"; -import { - createSandboxFromSnapshot, - type CreateSandboxFromSnapshotResult, -} from "./createSandboxFromSnapshot"; - -export interface GetOrCreateSandboxResult extends CreateSandboxFromSnapshotResult { - sandboxId: string; - created: boolean; -} - -/** - * Returns an active sandbox for the account, creating one if none exists. - * - * @param accountId - The account ID to get or create a sandbox for - * @returns The sandbox instance, its ID, whether it was newly created, and whether it was from a snapshot - */ -export async function getOrCreateSandbox(accountId: string): Promise { - const existing = await getActiveSandbox(accountId); - - if (existing) { - return { - sandbox: existing, - sandboxId: existing.name, - created: false, - fromSnapshot: true, - }; - } - - const { sandbox, fromSnapshot } = await createSandboxFromSnapshot(accountId); - - return { - sandbox, - sandboxId: sandbox.name, - created: true, - fromSnapshot, - }; -} diff --git a/lib/sandbox/installClaudeCode.ts b/lib/sandbox/installClaudeCode.ts deleted file mode 100644 index 953d91db3..000000000 --- a/lib/sandbox/installClaudeCode.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Sandbox } from "@vercel/sandbox"; - -/** - * Installs Claude Code CLI and Anthropic SDK in the sandbox. - * - * @param sandbox - The Vercel Sandbox instance - * @throws Error if installation fails - */ -export async function installClaudeCode(sandbox: Sandbox): Promise { - const installCLI = await sandbox.runCommand({ - cmd: "npm", - args: ["install", "-g", "@anthropic-ai/claude-code"], - stderr: process.stderr, - stdout: process.stdout, - sudo: true, - }); - - if (installCLI.exitCode !== 0) { - throw new Error("Failed to install Claude Code CLI"); - } - - const installSDK = await sandbox.runCommand({ - cmd: "npm", - args: ["install", "@anthropic-ai/sdk"], - stderr: process.stderr, - stdout: process.stdout, - }); - - if (installSDK.exitCode !== 0) { - throw new Error("Failed to install Anthropic SDK"); - } -} diff --git a/lib/sandbox/runClaudeCode.ts b/lib/sandbox/runClaudeCode.ts deleted file mode 100644 index 196fed228..000000000 --- a/lib/sandbox/runClaudeCode.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Sandbox } from "@vercel/sandbox"; - -/** - * Executes a Claude Code prompt in the sandbox. - * - * @param sandbox - The Vercel Sandbox instance - * @param prompt - The prompt to send to Claude - * @throws Error if script execution fails - */ -export async function runClaudeCode(sandbox: Sandbox, prompt: string): Promise { - const script = `claude --permission-mode acceptEdits --model opus '${prompt}'`; - - await sandbox.writeFiles([ - { - path: "/vercel/sandbox/ralph-once.sh", - content: Buffer.from(script), - }, - ]); - - await sandbox.runCommand({ - cmd: "sh", - args: ["ralph-once.sh"], - stdout: process.stdout, - stderr: process.stderr, - env: { - ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || "", - }, - }); -} From 4c17ee76ac0c4a27aae8003821857f40d17bc622 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Mon, 4 May 2026 18:25:44 -0500 Subject: [PATCH 3/6] feat(sessions): port POST /api/sessions from open-agents (#515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sessions): port GET /api/sessions/[sessionId] from open-agents (Phase 2.4 — first route) First route in the route-by-route cutover plan. Strategy: open-agents frontend stays unchanged in shape; api ports each route it calls in priority order (simplest first), and the open-agents frontend gets cut over to api one route at a time. Why this route first: - Pure DB read (single-row select by id) — no agent runner, no Vercel Workflow, no sandbox runtime - Hits sessions table already migrated in database PR #20 - Frontend usage: agents-frontend hits /api/sessions/{id} on session detail page navigation - Smallest possible blast radius for proving the cutover pattern Files added: lib/supabase/sessions/selectSession.ts Single-row helper + SessionRow type (hand-typed; database.types.ts regen pending — flagged in code comment) app/api/sessions/[sessionId]/route.ts GET handler matching open-agents response shape exactly (camelCase fields, "userId" preserved on the wire even though stored as account_id internally) app/api/sessions/[sessionId]/__tests__/route.test.ts (5 tests) Auth: validateAuthContext (Privy Bearer or x-api-key). Response codes match open-agents: 200 happy path, 401 no auth, 403 not owner, 404 not found. Wire-format translation: snake_case Supabase row -> camelCase response, with account_id surfaced as userId so the existing open-agents frontend fetches with zero code changes. Translation lives at the route boundary (toSessionResponse) where it is easy to remove once chat absorbs this UI and we can switch to schema-natural naming. Verification: - pnpm lint:check: clean - pnpm test: 2379/2379 pass (5 new for this route) Up next: - Cutover step (separate PR in open-agents): point the frontend at api's URL for this single route. Validate end-to-end before porting the next route. - Next routes in priority order (still pure DB, no agent/workflow): GET /api/sessions (list with unread — needs Postgres RPC for the multi-table aggregation), GET /api/sessions/[id]/chats, GET /api/sessions/[id]/chats/[chatId]. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: address PR review — SRP splits + use Tables<\"sessions\"> from regen'd types Three review comments on PR #514: 1. SRP: extract toSessionResponse to its own file was: defined inline in app/api/sessions/[sessionId]/route.ts now: lib/sessions/toSessionResponse.ts (one exported fn per file) 2. SRP: add a handler function (mirroring api convention) was: GET handler logic inline in route.ts now: lib/sessions/getSessionByIdHandler.ts contains all the auth + ownership + DB lookup + response logic; route.ts is a thin shell that awaits options.params and delegates. Matches the pattern used by every other api route (e.g. socials/[id]/scrape, artists/[id]/...). 3. DRY: use existing db schema type was: hand-typed SessionRow interface in selectSession.ts now: Tables<\"sessions\"> from types/database.types.ts (regenerated via npx supabase gen types typescript --project-id ... --schema public) The types regen also resolved the preview-build failure (\"Type instantiation is excessively deep and possibly infinite\") on the .from(\"sessions\") call — Supabase's type inference was choking because the table was unknown to the generic. Files added: lib/sessions/toSessionResponse.ts lib/sessions/getSessionByIdHandler.ts Files modified: app/api/sessions/[sessionId]/route.ts thin shell now app/api/sessions/[sessionId]/__tests__/ route.test.ts type alias updated lib/supabase/sessions/selectSession.ts Tables<\"sessions\"> types/database.types.ts Supabase regen Verification: - pnpm lint:check: clean - pnpm test: 2379/2379 pass (no test changes; same 5 route tests) - tsc compile clean (the local pnpm build progresses past compile into page-data collection where it fails on missing local env vars — Vercel preview will have those set, so the preview rebuild should now succeed) Co-Authored-By: Claude Opus 4.7 (1M context) * fix(sessions): make 404/403 errors emit status:"error" for shape consistency The 401 returned by validateAuthContext shaped like {status:"error", error:"..."} but 404/403 from this handler returned {error:"..."} only. Same endpoint, two error shapes — inconsistent for clients. Align all error responses on the validateAuthContext shape. Tests now assert the full error body, not just the status code. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(sessions): port POST /api/sessions from open-agents Implements the POST /api/sessions contract documented in recoupable/docs PR #186 + #187. Creates a session row and an initial chat row; rolls back the session if chat insert fails so callers never observe an orphaned session. Auth: validateAuthContext (Privy Bearer or x-api-key). Validation: Zod schema + GitHub repo segment regex. Body is optional — empty body creates a session with sensible defaults (status=running, lifecycle_state=provisioning, sandbox_state.type= vercel, title="New session"). Out of scope (will follow once database catches up): auto_commit_push_override, auto_create_pr_override, pr_number, pr_status — these columns don't yet exist on api's sessions table, so the docs spec was trimmed accordingly in docs PR #187. TDD: 9 handler tests cover 401, 400 (sandboxType / repoOwner / repoName), 200 happy path, branch generation, title pass-through, 500 (insertSession failure), and 500-with-rollback (insertChat failure). Plus 1 thin test on the route shell. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(sessions): add OPTIONS handler + cache directives to POST route Match the convention from app/api/sessions/[sessionId]/route.ts: - OPTIONS handler returning 200 + CORS headers (preflight) - dynamic="force-dynamic", fetchCache="force-no-store", revalidate=0 POST routes that mutate DB shouldn't be cached, and browsers issuing preflight checks (POST with JSON body + custom auth headers) need OPTIONS to respond. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(sessions): address PR review feedback - SRP: extract insert-row construction to lib/sessions/buildSessionInsertRow.ts - YAGNI: drop generateSessionBranchName + isNewBranch handling (sessions commit to whatever branch the client provides; auto-generation was speculative) - Tighten isValidGitHubRepoOwner: GitHub's actual rules are alphanumeric + hyphen only (no `_` or `.`), 1-39 chars, no leading/trailing or consecutive hyphens - Tighten isValidGitHubRepoName: reject reserved `.` and `..`, reject `.git` suffix, cap at 100 chars - Add unit tests for both validators (15 cases) and for the new buildSessionInsertRow (4 cases) - Split createSessionHandler tests into auth/validation + persistence files; share fixtures via createSessionHandlerFixtures.ts. All test files now under 100 lines. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(sessions): address second round of PR review - 500 message: "Failed to create session" → "Internal server error" (per cubic.dev standardized 500 envelope feedback) - SRP: extract failedToCreateSession to lib/sessions/failedToCreateSession.ts - YAGNI: drop repoOwner from request body and remove isValidGitHubRepoOwner helper entirely (recoupable is the only owner; no need to validate) - YAGNI: drop repoName from request body and remove isValidGitHubRepoName helper (repo identity is derived server-side from the authenticated account, not accepted from user input) - Single-export per file: split createSessionHandlerFixtures.ts into makeCreateSessionReq.ts, baseSessionRow.ts, baseChatRow.ts. okAuth constant inlined where used. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(sessions): port random-city title fallback from open-agents Generated session titles now match the open-agents UX — names like "Anchorage", "Vienna", "Philadelphia" — instead of every untitled session being called "New session". Closes a wire-shape gap with open-agents production identified by the head-to-head test on PR. Pieces: - lib/sessions/cityNames.ts: ~200-city curated list (verbatim port) - lib/sessions/getRandomCityName.ts: pick a city not in `usedNames`, numeric-suffix fallback when the curated list is exhausted - lib/supabase/sessions/selectSessionTitlesByAccountId.ts: Supabase helper for collision avoidance - lib/sessions/resolveSessionTitle.ts: orchestrates provided title (trimmed) > random city fallback. Async. Kept separate from the insert-row builder so that stays synchronous + pure. - buildSessionInsertRow now takes `title` as a parameter - createSessionHandler awaits resolveSessionTitle before building the row TDD: 4 tests for getRandomCityName, 4 for resolveSessionTitle. Handler tests updated to mock resolveSessionTitle. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: remove GET-only files (scope this PR to POST) The GET endpoint + handler + tests live in PR #514 and were inadvertently brought in when this branch was rebased after #514's work. This PR is scoped to POST only; GET ships in #514. Shared infrastructure stays (types/database.types.ts regen + lib/sessions/toSessionResponse.ts) — both are required by the POST handler too. When either #514 or this PR merges to test first, the other will see those files already present and resolve cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(sessions): consolidate request validation + DRY supabase select Two reviewer asks rolled into one commit: SRP — validateCreateSessionBody now owns the full validation flow. The handler used to call safeParseJson, validateAuthContext, and the Zod body schema separately; that was three places to short-circuit and three places to duplicate the error envelope. Folded them into validateCreateSessionBody so the handler does one call → success or NextResponse error. Returns { body, auth } on success. DRY — replaced lib/supabase/sessions/selectSession.ts and selectSessionTitlesByAccountId.ts with a single selectSessions({ id?, accountId? }) that supports both call sites. resolveSessionTitle now derives titles from the general fetch. Tests: - New validateCreateSessionBody.test.ts covers auth-failure / 400 / success / malformed-JSON tolerance (4 cases) - Handler tests now mock validateCreateSessionBody (single mock surface instead of three) - resolveSessionTitle tests mock selectSessions Co-Authored-By: Claude Opus 4.7 (1M context) * fix(sessions): address automated review feedback Four small fixes from the latest round: 1. Zod v4 migration: { message } → { error } on the sandboxType literal. v4 unified the error customization API; { message } is deprecated. 2. Orphan rollback observability: when insertChat fails AND the session-rollback delete also fails, log the session id so ops can detect orphaned rows. New persistence test asserts the log. 3. Defensive try/catch in selectSessions so a thrown exception (network-level rejection, not a Supabase {error} return) doesn't bubble up and 500 the entire session-creation flow. 4. Deterministic test for getRandomCityName suffix-increment: pin Math.random instead of looping until the random pick lands on baseCity. Previous test could pass without ever asserting if the loop cap was hit. Skipped: cubic-dev-ai's note about logging raw sessionId in selectSession.ts — that file was deleted earlier in this PR. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: prettier format fix on persistence test The new orphan-session test had a line that exceeded prettier's wrap width. Auto-format fixed it; format-check now clean. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- app/api/sessions/__tests__/route.test.ts | 29 ++ app/api/sessions/route.ts | 29 ++ lib/sessions/__tests__/baseChatRow.ts | 19 ++ lib/sessions/__tests__/baseSessionRow.ts | 39 +++ .../__tests__/buildSessionInsertRow.test.ts | 39 +++ .../createSessionHandler.persistence.test.ts | 110 +++++++ .../__tests__/createSessionHandler.test.ts | 43 +++ .../__tests__/getRandomCityName.test.ts | 40 +++ .../__tests__/makeCreateSessionReq.ts | 19 ++ .../__tests__/resolveSessionTitle.test.ts | 51 +++ .../validateCreateSessionBody.test.ts | 65 ++++ lib/sessions/buildSessionInsertRow.ts | 37 +++ lib/sessions/cityNames.ts | 198 +++++++++++ lib/sessions/createSessionHandler.ts | 69 ++++ lib/sessions/failedToCreateSession.ts | 15 + lib/sessions/getRandomCityName.ts | 26 ++ lib/sessions/resolveSessionTitle.ts | 30 ++ lib/sessions/toChatResponse.ts | 22 ++ lib/sessions/toSessionResponse.ts | 43 +++ lib/sessions/validateCreateSessionBody.ts | 60 ++++ lib/supabase/chats/insertChat.ts | 19 ++ lib/supabase/sessions/deleteSessionById.ts | 20 ++ lib/supabase/sessions/insertSession.ts | 21 ++ lib/supabase/sessions/selectSessions.ts | 41 +++ types/database.types.ts | 307 +++++++++++++++++- 25 files changed, 1385 insertions(+), 6 deletions(-) create mode 100644 app/api/sessions/__tests__/route.test.ts create mode 100644 app/api/sessions/route.ts create mode 100644 lib/sessions/__tests__/baseChatRow.ts create mode 100644 lib/sessions/__tests__/baseSessionRow.ts create mode 100644 lib/sessions/__tests__/buildSessionInsertRow.test.ts create mode 100644 lib/sessions/__tests__/createSessionHandler.persistence.test.ts create mode 100644 lib/sessions/__tests__/createSessionHandler.test.ts create mode 100644 lib/sessions/__tests__/getRandomCityName.test.ts create mode 100644 lib/sessions/__tests__/makeCreateSessionReq.ts create mode 100644 lib/sessions/__tests__/resolveSessionTitle.test.ts create mode 100644 lib/sessions/__tests__/validateCreateSessionBody.test.ts create mode 100644 lib/sessions/buildSessionInsertRow.ts create mode 100644 lib/sessions/cityNames.ts create mode 100644 lib/sessions/createSessionHandler.ts create mode 100644 lib/sessions/failedToCreateSession.ts create mode 100644 lib/sessions/getRandomCityName.ts create mode 100644 lib/sessions/resolveSessionTitle.ts create mode 100644 lib/sessions/toChatResponse.ts create mode 100644 lib/sessions/toSessionResponse.ts create mode 100644 lib/sessions/validateCreateSessionBody.ts create mode 100644 lib/supabase/chats/insertChat.ts create mode 100644 lib/supabase/sessions/deleteSessionById.ts create mode 100644 lib/supabase/sessions/insertSession.ts create mode 100644 lib/supabase/sessions/selectSessions.ts diff --git a/app/api/sessions/__tests__/route.test.ts b/app/api/sessions/__tests__/route.test.ts new file mode 100644 index 000000000..09170af70 --- /dev/null +++ b/app/api/sessions/__tests__/route.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { POST, OPTIONS } from "@/app/api/sessions/route"; +import { createSessionHandler } from "@/lib/sessions/createSessionHandler"; + +vi.mock("@/lib/sessions/createSessionHandler", () => ({ + createSessionHandler: vi.fn(), +})); + +describe("POST /api/sessions", () => { + it("delegates to createSessionHandler", async () => { + const expected = NextResponse.json({ ok: true }, { status: 200 }); + vi.mocked(createSessionHandler).mockResolvedValue(expected); + + const req = new NextRequest("http://localhost/api/sessions", { method: "POST" }); + const res = await POST(req); + + expect(createSessionHandler).toHaveBeenCalledWith(req); + expect(res).toBe(expected); + }); +}); + +describe("OPTIONS /api/sessions", () => { + it("returns 200 for CORS preflight", async () => { + const res = await OPTIONS(); + expect(res.status).toBe(200); + }); +}); diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts new file mode 100644 index 000000000..d4a458782 --- /dev/null +++ b/app/api/sessions/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createSessionHandler } from "@/lib/sessions/createSessionHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * `POST /api/sessions` — create a session and an initial chat. + * + * @param request - The incoming request. + * @returns A NextResponse with `{ session, chat }` on 200, or an error. + */ +export async function POST(request: NextRequest) { + return createSessionHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/sessions/__tests__/baseChatRow.ts b/lib/sessions/__tests__/baseChatRow.ts new file mode 100644 index 000000000..4c295ed49 --- /dev/null +++ b/lib/sessions/__tests__/baseChatRow.ts @@ -0,0 +1,19 @@ +import type { Tables } from "@/types/database.types"; + +/** + * Returns a fully-populated `chats` row suitable for mocking + * `insertChat` in tests. Pass `overrides` to customize fields per case. + */ +export function baseChatRow(overrides: Partial> = {}): Tables<"chats"> { + return { + id: "chat_1", + session_id: "sess_1", + title: "New chat", + model_id: null, + active_stream_id: null, + last_assistant_message_at: null, + created_at: "2026-05-04T00:00:00.000Z", + updated_at: "2026-05-04T00:00:00.000Z", + ...overrides, + }; +} diff --git a/lib/sessions/__tests__/baseSessionRow.ts b/lib/sessions/__tests__/baseSessionRow.ts new file mode 100644 index 000000000..8c5fe8095 --- /dev/null +++ b/lib/sessions/__tests__/baseSessionRow.ts @@ -0,0 +1,39 @@ +import type { Tables } from "@/types/database.types"; + +/** + * Returns a fully-populated `sessions` row suitable for mocking + * `insertSession` / `selectSession` in tests. Pass `overrides` to + * customize fields per case. + */ +export function baseSessionRow(overrides: Partial> = {}): Tables<"sessions"> { + return { + id: "sess_1", + account_id: "acc-uuid-1", + title: "Test session", + status: "running", + repo_owner: null, + repo_name: null, + branch: null, + clone_url: null, + is_new_branch: false, + global_skill_refs: [], + sandbox_state: { type: "vercel" }, + lifecycle_state: "provisioning", + lifecycle_version: 0, + last_activity_at: null, + sandbox_expires_at: null, + hibernate_after: null, + lifecycle_run_id: null, + lifecycle_error: null, + lines_added: 0, + lines_removed: 0, + snapshot_url: null, + snapshot_created_at: null, + snapshot_size_bytes: null, + cached_diff: null, + cached_diff_updated_at: null, + created_at: "2026-05-04T00:00:00.000Z", + updated_at: "2026-05-04T00:00:00.000Z", + ...overrides, + }; +} diff --git a/lib/sessions/__tests__/buildSessionInsertRow.test.ts b/lib/sessions/__tests__/buildSessionInsertRow.test.ts new file mode 100644 index 000000000..a786871f5 --- /dev/null +++ b/lib/sessions/__tests__/buildSessionInsertRow.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { buildSessionInsertRow } from "@/lib/sessions/buildSessionInsertRow"; + +describe("buildSessionInsertRow", () => { + it("returns sane defaults for an empty body", () => { + const row = buildSessionInsertRow({ body: {}, accountId: "acc-1", title: "Berlin" }); + expect(row.account_id).toBe("acc-1"); + expect(row.title).toBe("Berlin"); + expect(row.status).toBe("running"); + expect(row.lifecycle_state).toBe("provisioning"); + expect(row.lifecycle_version).toBe(0); + expect(row.sandbox_state).toEqual({ type: "vercel" }); + expect(row.branch).toBeNull(); + expect(row.clone_url).toBeNull(); + expect(row.id).toMatch(/^[0-9a-f-]{36}$/i); + }); + + it("forwards branch + clone fields verbatim", () => { + const row = buildSessionInsertRow({ + body: { + branch: "main", + cloneUrl: "https://github.com/recoupable/ai.git", + }, + accountId: "acc-1", + title: "Berlin", + }); + expect(row.branch).toBe("main"); + expect(row.clone_url).toBe("https://github.com/recoupable/ai.git"); + }); + + it("uses the provided sandboxType when set", () => { + const row = buildSessionInsertRow({ + body: { sandboxType: "vercel" }, + accountId: "acc-1", + title: "Berlin", + }); + expect(row.sandbox_state).toEqual({ type: "vercel" }); + }); +}); diff --git a/lib/sessions/__tests__/createSessionHandler.persistence.test.ts b/lib/sessions/__tests__/createSessionHandler.persistence.test.ts new file mode 100644 index 000000000..038889a3d --- /dev/null +++ b/lib/sessions/__tests__/createSessionHandler.persistence.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { validateCreateSessionBody } from "@/lib/sessions/validateCreateSessionBody"; +import { insertSession } from "@/lib/supabase/sessions/insertSession"; +import { deleteSessionById } from "@/lib/supabase/sessions/deleteSessionById"; +import { insertChat } from "@/lib/supabase/chats/insertChat"; +import { resolveSessionTitle } from "@/lib/sessions/resolveSessionTitle"; +import { createSessionHandler } from "@/lib/sessions/createSessionHandler"; +import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; +import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; +import { makeCreateSessionReq } from "@/lib/sessions/__tests__/makeCreateSessionReq"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/sessions/validateCreateSessionBody", () => ({ + validateCreateSessionBody: vi.fn(), +})); +vi.mock("@/lib/supabase/sessions/insertSession", () => ({ insertSession: vi.fn() })); +vi.mock("@/lib/supabase/sessions/deleteSessionById", () => ({ deleteSessionById: vi.fn() })); +vi.mock("@/lib/supabase/chats/insertChat", () => ({ insertChat: vi.fn() })); +vi.mock("@/lib/sessions/resolveSessionTitle", () => ({ + resolveSessionTitle: vi.fn(async () => "Anchorage"), +})); + +const okValidated = (overrides: { body?: object; accountId?: string } = {}) => ({ + body: overrides.body ?? {}, + auth: { + accountId: overrides.accountId ?? "acc-uuid-1", + orgId: null, + authToken: "key_test", + }, +}); + +describe("createSessionHandler — persistence", () => { + beforeEach(() => vi.clearAllMocks()); + + it("creates session and chat with defaults on empty body", async () => { + vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); + vi.mocked(insertSession).mockResolvedValue(baseSessionRow()); + vi.mocked(insertChat).mockResolvedValue(baseChatRow()); + + const res = await createSessionHandler(makeCreateSessionReq({})); + expect(res.status).toBe(200); + + const body = (await res.json()) as { session: { userId: string }; chat: { sessionId: string } }; + expect(body.session.userId).toBe("acc-uuid-1"); + expect(body.chat.sessionId).toBe("sess_1"); + + const insertArgs = vi.mocked(insertSession).mock.calls[0][0]; + expect(insertArgs.account_id).toBe("acc-uuid-1"); + expect(insertArgs.status).toBe("running"); + expect(insertArgs.lifecycle_state).toBe("provisioning"); + expect(insertArgs.sandbox_state).toEqual({ type: "vercel" }); + + const chatArgs = vi.mocked(insertChat).mock.calls[0][0]; + expect(chatArgs.session_id).toBe("sess_1"); + expect(chatArgs.title).toBe("New chat"); + }); + + it("forwards body title to resolveSessionTitle and writes the resolved title", async () => { + vi.mocked(validateCreateSessionBody).mockResolvedValue( + okValidated({ body: { title: "Hello world" } }), + ); + vi.mocked(resolveSessionTitle).mockResolvedValueOnce("Hello world"); + vi.mocked(insertSession).mockResolvedValue(baseSessionRow({ title: "Hello world" })); + vi.mocked(insertChat).mockResolvedValue(baseChatRow()); + + await createSessionHandler(makeCreateSessionReq({ title: "Hello world" })); + + expect(resolveSessionTitle).toHaveBeenCalledWith({ + providedTitle: "Hello world", + accountId: "acc-uuid-1", + }); + expect(vi.mocked(insertSession).mock.calls[0][0].title).toBe("Hello world"); + }); + + it("returns 500 when insertSession fails", async () => { + vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); + vi.mocked(insertSession).mockResolvedValue(null); + + const res = await createSessionHandler(makeCreateSessionReq({})); + expect(res.status).toBe(500); + expect(insertChat).not.toHaveBeenCalled(); + }); + + it("rolls back the session and returns 500 when insertChat fails", async () => { + vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); + vi.mocked(insertSession).mockResolvedValue(baseSessionRow({ id: "sess_rollback" })); + vi.mocked(insertChat).mockResolvedValue(null); + vi.mocked(deleteSessionById).mockResolvedValue(true); + + const res = await createSessionHandler(makeCreateSessionReq({})); + expect(res.status).toBe(500); + expect(deleteSessionById).toHaveBeenCalledWith("sess_rollback"); + }); + + it("logs an orphan-session error when rollback also fails", async () => { + vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); + vi.mocked(insertSession).mockResolvedValue(baseSessionRow({ id: "sess_orphan" })); + vi.mocked(insertChat).mockResolvedValue(null); + vi.mocked(deleteSessionById).mockResolvedValue(false); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const res = await createSessionHandler(makeCreateSessionReq({})); + expect(res.status).toBe(500); + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("orphaned session"), "sess_orphan"); + errSpy.mockRestore(); + }); +}); diff --git a/lib/sessions/__tests__/createSessionHandler.test.ts b/lib/sessions/__tests__/createSessionHandler.test.ts new file mode 100644 index 000000000..a239ccd82 --- /dev/null +++ b/lib/sessions/__tests__/createSessionHandler.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; + +import { validateCreateSessionBody } from "@/lib/sessions/validateCreateSessionBody"; +import { insertSession } from "@/lib/supabase/sessions/insertSession"; +import { createSessionHandler } from "@/lib/sessions/createSessionHandler"; +import { makeCreateSessionReq } from "@/lib/sessions/__tests__/makeCreateSessionReq"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/sessions/validateCreateSessionBody", () => ({ + validateCreateSessionBody: vi.fn(), +})); +vi.mock("@/lib/supabase/sessions/insertSession", () => ({ insertSession: vi.fn() })); +vi.mock("@/lib/supabase/sessions/deleteSessionById", () => ({ deleteSessionById: vi.fn() })); +vi.mock("@/lib/supabase/chats/insertChat", () => ({ insertChat: vi.fn() })); +vi.mock("@/lib/sessions/resolveSessionTitle", () => ({ + resolveSessionTitle: vi.fn(async () => "Anchorage"), +})); + +describe("createSessionHandler — short-circuits on validation failure", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the NextResponse from validateCreateSessionBody as-is", async () => { + const failure = NextResponse.json({ status: "error", error: "bad" }, { status: 401 }); + vi.mocked(validateCreateSessionBody).mockResolvedValue(failure); + + const res = await createSessionHandler(makeCreateSessionReq({})); + expect(res).toBe(failure); + expect(insertSession).not.toHaveBeenCalled(); + }); + + it("returns 400 when validateCreateSessionBody rejects with 400", async () => { + vi.mocked(validateCreateSessionBody).mockResolvedValue( + NextResponse.json({ status: "error", error: "Invalid sandbox type" }, { status: 400 }), + ); + + const res = await createSessionHandler(makeCreateSessionReq({ sandboxType: "wrong" })); + expect(res.status).toBe(400); + expect(insertSession).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/sessions/__tests__/getRandomCityName.test.ts b/lib/sessions/__tests__/getRandomCityName.test.ts new file mode 100644 index 000000000..a9d2e0754 --- /dev/null +++ b/lib/sessions/__tests__/getRandomCityName.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { getRandomCityName } from "@/lib/sessions/getRandomCityName"; +import { cityNames } from "@/lib/sessions/cityNames"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("getRandomCityName", () => { + it("returns a city from the curated list when none are used", () => { + const result = getRandomCityName(new Set()); + expect(cityNames).toContain(result); + }); + + it("avoids cities that are already in use", () => { + const used = new Set(cityNames.slice(0, cityNames.length - 1)); + const result = getRandomCityName(used); + expect(result).toBe(cityNames[cityNames.length - 1]); + }); + + it("appends a numeric suffix once every city is used", () => { + const used = new Set(cityNames); + const result = getRandomCityName(used); + expect(result).toMatch(/ \d+$/); + const base = result.replace(/ \d+$/, ""); + expect(cityNames).toContain(base); + }); + + it("increments the suffix when the next number is also used", () => { + const baseCity = cityNames[0]; + const used = new Set([...cityNames, `${baseCity} 2`, `${baseCity} 3`]); + // Pin Math.random so the fallback always picks index 0 (= baseCity), + // making the suffix-increment behavior deterministic to assert. + vi.spyOn(Math, "random").mockReturnValue(0); + + const result = getRandomCityName(used); + + expect(result).toBe(`${baseCity} 4`); + }); +}); diff --git a/lib/sessions/__tests__/makeCreateSessionReq.ts b/lib/sessions/__tests__/makeCreateSessionReq.ts new file mode 100644 index 000000000..e7b368f68 --- /dev/null +++ b/lib/sessions/__tests__/makeCreateSessionReq.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; + +/** + * Builds a NextRequest pointing at `/api/sessions` with a JSON body + * and the standard test API key already attached. + * + * @param body - The body to send (object literal or raw string). + */ +export function makeCreateSessionReq(body: unknown): NextRequest { + const headers = new Headers({ + "Content-Type": "application/json", + "x-api-key": "key_test", + }); + return new NextRequest("http://localhost/api/sessions", { + method: "POST", + headers, + body: typeof body === "string" ? body : JSON.stringify(body), + }); +} diff --git a/lib/sessions/__tests__/resolveSessionTitle.test.ts b/lib/sessions/__tests__/resolveSessionTitle.test.ts new file mode 100644 index 000000000..07615db28 --- /dev/null +++ b/lib/sessions/__tests__/resolveSessionTitle.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import { getRandomCityName } from "@/lib/sessions/getRandomCityName"; +import { resolveSessionTitle } from "@/lib/sessions/resolveSessionTitle"; +import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; + +vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ + selectSessions: vi.fn(), +})); +vi.mock("@/lib/sessions/getRandomCityName", () => ({ + getRandomCityName: vi.fn(() => "Anchorage"), +})); + +describe("resolveSessionTitle", () => { + beforeEach(() => vi.clearAllMocks()); + + it("uses the provided title verbatim when present", async () => { + const result = await resolveSessionTitle({ providedTitle: "Hello", accountId: "acc-1" }); + expect(result).toBe("Hello"); + expect(selectSessions).not.toHaveBeenCalled(); + expect(getRandomCityName).not.toHaveBeenCalled(); + }); + + it("trims whitespace around a provided title", async () => { + const result = await resolveSessionTitle({ providedTitle: " Hi ", accountId: "acc-1" }); + expect(result).toBe("Hi"); + }); + + it("falls back to getRandomCityName when no title is provided", async () => { + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ title: "Berlin" }), + baseSessionRow({ id: "sess_2", title: "Paris" }), + ]); + + const result = await resolveSessionTitle({ accountId: "acc-1" }); + + expect(result).toBe("Anchorage"); + expect(selectSessions).toHaveBeenCalledWith({ accountId: "acc-1" }); + expect(vi.mocked(getRandomCityName).mock.calls[0][0]).toEqual(new Set(["Berlin", "Paris"])); + }); + + it("falls back to getRandomCityName when title is whitespace-only", async () => { + vi.mocked(selectSessions).mockResolvedValue([]); + + const result = await resolveSessionTitle({ providedTitle: " ", accountId: "acc-1" }); + + expect(result).toBe("Anchorage"); + expect(selectSessions).toHaveBeenCalledWith({ accountId: "acc-1" }); + }); +}); diff --git a/lib/sessions/__tests__/validateCreateSessionBody.test.ts b/lib/sessions/__tests__/validateCreateSessionBody.test.ts new file mode 100644 index 000000000..e5e863b73 --- /dev/null +++ b/lib/sessions/__tests__/validateCreateSessionBody.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateCreateSessionBody } from "@/lib/sessions/validateCreateSessionBody"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn() })); + +const okAuth = { accountId: "acc-1", orgId: null, authToken: "key" }; + +function req(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); +} + +describe("validateCreateSessionBody", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the auth NextResponse when validateAuthContext rejects", async () => { + const failure = NextResponse.json({ status: "error", error: "no auth" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(failure); + + const result = await validateCreateSessionBody(req({})); + expect(result).toBe(failure); + }); + + it("returns 400 when sandboxType is not 'vercel'", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + + const result = await validateCreateSessionBody(req({ sandboxType: "wrong" })); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = (await result.json()) as { status: string; error: string }; + expect(body.error).toBe("Invalid sandbox type"); + } + }); + + it("returns body + auth on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + + const result = await validateCreateSessionBody(req({ title: "Hello", sandboxType: "vercel" })); + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.body).toEqual({ title: "Hello", sandboxType: "vercel" }); + expect(result.auth).toBe(okAuth); + } + }); + + it("treats malformed JSON as an empty body and accepts it", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + + const result = await validateCreateSessionBody(req("{not valid")); + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.body).toEqual({}); + } + }); +}); diff --git a/lib/sessions/buildSessionInsertRow.ts b/lib/sessions/buildSessionInsertRow.ts new file mode 100644 index 000000000..8c718f57f --- /dev/null +++ b/lib/sessions/buildSessionInsertRow.ts @@ -0,0 +1,37 @@ +import type { TablesInsert } from "@/types/database.types"; +import type { CreateSessionBody } from "@/lib/sessions/validateCreateSessionBody"; +import { generateUUID } from "@/lib/uuid/generateUUID"; + +interface BuildSessionInsertRowInput { + body: CreateSessionBody; + accountId: string; + title: string; +} + +/** + * Normalizes a validated `POST /api/sessions` body plus a resolved + * title into a `sessions` insert row. Centralizes the default / + * null-coalescing rules so the handler can stay focused on HTTP and + * persistence flow. + * + * Title resolution is intentionally not done here — that lives in + * `resolveSessionTitle` so this function stays synchronous and pure. + * + * @param input - The validated body, owning account id, and resolved title. + * @returns A row ready to pass to `insertSession`. + */ +export function buildSessionInsertRow(input: BuildSessionInsertRowInput): TablesInsert<"sessions"> { + const { body, accountId, title } = input; + return { + id: generateUUID(), + account_id: accountId, + title, + status: "running", + branch: body.branch ?? null, + clone_url: body.cloneUrl ?? null, + global_skill_refs: [], + sandbox_state: { type: body.sandboxType ?? "vercel" }, + lifecycle_state: "provisioning", + lifecycle_version: 0, + }; +} diff --git a/lib/sessions/cityNames.ts b/lib/sessions/cityNames.ts new file mode 100644 index 000000000..92cd18a17 --- /dev/null +++ b/lib/sessions/cityNames.ts @@ -0,0 +1,198 @@ +/** + * Curated list of ~200 notable cities used as memorable, unique + * fallback titles for sessions that don't have a user-provided title. + * Ported verbatim from open-agents so generated titles look familiar + * to the existing frontend. + */ +export const cityNames: readonly string[] = [ + // Africa + "Abidjan", + "Accra", + "Addis Ababa", + "Algiers", + "Antananarivo", + "Cairo", + "Cape Town", + "Casablanca", + "Dakar", + "Dar es Salaam", + "Johannesburg", + "Kampala", + "Kigali", + "Kinshasa", + "Lagos", + "Luanda", + "Lusaka", + "Maputo", + "Marrakech", + "Nairobi", + "Tunis", + "Windhoek", + + // Asia + "Almaty", + "Amman", + "Baku", + "Bangkok", + "Beijing", + "Beirut", + "Bengaluru", + "Bishkek", + "Colombo", + "Dhaka", + "Dubai", + "Hanoi", + "Ho Chi Minh City", + "Hong Kong", + "Hyderabad", + "Islamabad", + "Istanbul", + "Jakarta", + "Jeddah", + "Jerusalem", + "Kabul", + "Karachi", + "Kathmandu", + "Kolkata", + "Kuala Lumpur", + "Kyoto", + "Manila", + "Mumbai", + "Muscat", + "New Delhi", + "Osaka", + "Phnom Penh", + "Riyadh", + "Seoul", + "Shanghai", + "Shenzhen", + "Singapore", + "Taipei", + "Tashkent", + "Tbilisi", + "Tehran", + "Tel Aviv", + "Thimphu", + "Tokyo", + "Ulaanbaatar", + "Vientiane", + "Yangon", + "Yerevan", + + // Europe + "Amsterdam", + "Athens", + "Barcelona", + "Belgrade", + "Berlin", + "Bern", + "Bordeaux", + "Bratislava", + "Brussels", + "Bucharest", + "Budapest", + "Copenhagen", + "Dublin", + "Edinburgh", + "Florence", + "Geneva", + "Hamburg", + "Helsinki", + "Kraków", + "Kyiv", + "Lisbon", + "Ljubljana", + "London", + "Lyon", + "Madrid", + "Marseille", + "Milan", + "Minsk", + "Monaco", + "Moscow", + "Munich", + "Naples", + "Oslo", + "Paris", + "Porto", + "Prague", + "Reykjavik", + "Riga", + "Rome", + "Sarajevo", + "Seville", + "Sofia", + "Stockholm", + "Tallinn", + "Valencia", + "Venice", + "Vienna", + "Vilnius", + "Warsaw", + "Zagreb", + "Zurich", + + // North America + "Anchorage", + "Atlanta", + "Austin", + "Boston", + "Calgary", + "Chicago", + "Denver", + "Detroit", + "Guadalajara", + "Havana", + "Honolulu", + "Houston", + "Kingston", + "Los Angeles", + "Mexico City", + "Miami", + "Minneapolis", + "Montreal", + "Nashville", + "New Orleans", + "New York", + "Ottawa", + "Philadelphia", + "Phoenix", + "Portland", + "San Diego", + "San Francisco", + "San Juan", + "Seattle", + "Toronto", + "Vancouver", + "Washington", + + // South America + "Bogotá", + "Brasília", + "Buenos Aires", + "Caracas", + "Cartagena", + "Cusco", + "Guayaquil", + "La Paz", + "Lima", + "Medellín", + "Montevideo", + "Quito", + "Recife", + "Rio de Janeiro", + "Santiago", + "São Paulo", + "Valparaíso", + + // Oceania + "Adelaide", + "Auckland", + "Brisbane", + "Christchurch", + "Fiji", + "Melbourne", + "Perth", + "Sydney", + "Wellington", +]; diff --git a/lib/sessions/createSessionHandler.ts b/lib/sessions/createSessionHandler.ts new file mode 100644 index 000000000..d27b0b9e1 --- /dev/null +++ b/lib/sessions/createSessionHandler.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { generateUUID } from "@/lib/uuid/generateUUID"; +import { validateCreateSessionBody } from "@/lib/sessions/validateCreateSessionBody"; +import { resolveSessionTitle } from "@/lib/sessions/resolveSessionTitle"; +import { buildSessionInsertRow } from "@/lib/sessions/buildSessionInsertRow"; +import { failedToCreateSession } from "@/lib/sessions/failedToCreateSession"; +import { insertSession } from "@/lib/supabase/sessions/insertSession"; +import { deleteSessionById } from "@/lib/supabase/sessions/deleteSessionById"; +import { insertChat } from "@/lib/supabase/chats/insertChat"; +import { toSessionResponse } from "@/lib/sessions/toSessionResponse"; +import { toChatResponse } from "@/lib/sessions/toChatResponse"; + +const INITIAL_CHAT_TITLE = "New chat"; + +/** + * Handles `POST /api/sessions`. + * + * Authenticates, validates the request, resolves a final session + * title (provided > random city fallback), then creates a session + * row and an initial chat row. If the chat insert fails after the + * session row is persisted, the session is rolled back so callers + * never observe an orphaned session. + * + * @param request - The incoming request. + * @returns A NextResponse with `{ session, chat }` on 200, or an error. + */ +export async function createSessionHandler(request: NextRequest): Promise { + const validated = await validateCreateSessionBody(request); + if (validated instanceof NextResponse) { + return validated; + } + const { body, auth } = validated; + + const title = await resolveSessionTitle({ + providedTitle: body.title, + accountId: auth.accountId, + }); + + const sessionRow = await insertSession( + buildSessionInsertRow({ body, accountId: auth.accountId, title }), + ); + + if (!sessionRow) { + return failedToCreateSession(); + } + + const chatRow = await insertChat({ + id: generateUUID(), + session_id: sessionRow.id, + title: INITIAL_CHAT_TITLE, + }); + + if (!chatRow) { + const rolledBack = await deleteSessionById(sessionRow.id); + if (!rolledBack) { + console.error( + "[createSessionHandler] chat insert failed and session rollback failed — orphaned session:", + sessionRow.id, + ); + } + return failedToCreateSession(); + } + + return NextResponse.json( + { session: toSessionResponse(sessionRow), chat: toChatResponse(chatRow) }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/sessions/failedToCreateSession.ts b/lib/sessions/failedToCreateSession.ts new file mode 100644 index 000000000..a9e010c04 --- /dev/null +++ b/lib/sessions/failedToCreateSession.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +/** + * 500 response shared by every internal failure on `POST /api/sessions`. + * Returns the standard `{status, error}` envelope with the generic + * `"Internal server error"` message — specific failure modes are logged + * server-side rather than leaked to clients. + */ +export function failedToCreateSession(): NextResponse { + return NextResponse.json( + { status: "error", error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); +} diff --git a/lib/sessions/getRandomCityName.ts b/lib/sessions/getRandomCityName.ts new file mode 100644 index 000000000..72238ba52 --- /dev/null +++ b/lib/sessions/getRandomCityName.ts @@ -0,0 +1,26 @@ +import { cityNames } from "@/lib/sessions/cityNames"; + +/** + * Picks a random city name not already in `usedNames`. When every + * curated city has been used at least once, falls back to suffixing + * with the smallest unused integer (e.g. `"Tokyo 2"`, `"Tokyo 3"`). + * + * Ported from open-agents so generated titles look familiar to the + * existing frontend after cutover. + * + * @param usedNames - Session titles the account already has. + * @returns A title not present in `usedNames`. + */ +export function getRandomCityName(usedNames: Set): string { + const available = cityNames.filter(city => !usedNames.has(city)); + if (available.length > 0) { + return available[Math.floor(Math.random() * available.length)]!; + } + + const base = cityNames[Math.floor(Math.random() * cityNames.length)]!; + let suffix = 2; + while (usedNames.has(`${base} ${suffix}`)) { + suffix++; + } + return `${base} ${suffix}`; +} diff --git a/lib/sessions/resolveSessionTitle.ts b/lib/sessions/resolveSessionTitle.ts new file mode 100644 index 000000000..1e2270b40 --- /dev/null +++ b/lib/sessions/resolveSessionTitle.ts @@ -0,0 +1,30 @@ +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import { getRandomCityName } from "@/lib/sessions/getRandomCityName"; + +interface ResolveSessionTitleInput { + providedTitle?: string; + accountId: string; +} + +/** + * Resolves the final title for a new session. + * + * If the caller provided a non-blank title, returns it trimmed. + * Otherwise queries the account's existing session titles and picks + * a random city name that doesn't collide with them — mirroring the + * open-agents fallback so generated titles look familiar after + * frontend cutover. + * + * @param input - Provided title (optional) and the owning account id. + * @returns The resolved title. + */ +export async function resolveSessionTitle(input: ResolveSessionTitleInput): Promise { + const trimmed = input.providedTitle?.trim(); + if (trimmed) { + return trimmed; + } + + const rows = await selectSessions({ accountId: input.accountId }); + const usedTitles = rows.map(row => row.title); + return getRandomCityName(new Set(usedTitles)); +} diff --git a/lib/sessions/toChatResponse.ts b/lib/sessions/toChatResponse.ts new file mode 100644 index 000000000..2b89105aa --- /dev/null +++ b/lib/sessions/toChatResponse.ts @@ -0,0 +1,22 @@ +import type { Tables } from "@/types/database.types"; + +/** + * Translates a Supabase `chats` row into the camelCase shape returned + * by the API. Mirrors `toSessionResponse` so wire format stays aligned + * with what open-agents' frontend already consumes. + * + * @param row - The Supabase chats row. + * @returns The camelCase chat payload for HTTP responses. + */ +export function toChatResponse(row: Tables<"chats">) { + return { + id: row.id, + sessionId: row.session_id, + title: row.title, + modelId: row.model_id, + activeStreamId: row.active_stream_id, + lastAssistantMessageAt: row.last_assistant_message_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} diff --git a/lib/sessions/toSessionResponse.ts b/lib/sessions/toSessionResponse.ts new file mode 100644 index 000000000..d7c08cbac --- /dev/null +++ b/lib/sessions/toSessionResponse.ts @@ -0,0 +1,43 @@ +import type { Tables } from "@/types/database.types"; + +/** + * Translates the snake_case Supabase row into the camelCase shape that + * open-agents' frontend expects, preserving its existing field names + * (e.g. `userId` for what is now `account_id`). This keeps the wire + * format identical so the open-agents frontend can cut over to api + * with zero frontend code changes. + * + * @param row - The Supabase sessions row. + * @returns The camelCase session payload for HTTP responses. + */ +export function toSessionResponse(row: Tables<"sessions">) { + return { + id: row.id, + userId: row.account_id, + title: row.title, + status: row.status, + repoOwner: row.repo_owner, + repoName: row.repo_name, + branch: row.branch, + cloneUrl: row.clone_url, + isNewBranch: row.is_new_branch, + globalSkillRefs: row.global_skill_refs, + sandboxState: row.sandbox_state, + lifecycleState: row.lifecycle_state, + lifecycleVersion: row.lifecycle_version, + lastActivityAt: row.last_activity_at, + sandboxExpiresAt: row.sandbox_expires_at, + hibernateAfter: row.hibernate_after, + lifecycleRunId: row.lifecycle_run_id, + lifecycleError: row.lifecycle_error, + linesAdded: row.lines_added, + linesRemoved: row.lines_removed, + snapshotUrl: row.snapshot_url, + snapshotCreatedAt: row.snapshot_created_at, + snapshotSizeBytes: row.snapshot_size_bytes, + cachedDiff: row.cached_diff, + cachedDiffUpdatedAt: row.cached_diff_updated_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} diff --git a/lib/sessions/validateCreateSessionBody.ts b/lib/sessions/validateCreateSessionBody.ts new file mode 100644 index 000000000..f794dfb07 --- /dev/null +++ b/lib/sessions/validateCreateSessionBody.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import type { AuthContext } from "@/lib/auth/validateAuthContext"; + +export const createSessionBodySchema = z.object({ + title: z.string().optional(), + branch: z.string().optional(), + cloneUrl: z.string().optional(), + sandboxType: z.literal("vercel", { error: "Invalid sandbox type" }).optional(), +}); + +export type CreateSessionBody = z.infer; + +export interface ValidatedCreateSessionRequest { + body: CreateSessionBody; + auth: AuthContext; +} + +/** + * Validates a `POST /api/sessions` request end-to-end: + * 1. Authenticates the caller via Privy Bearer / x-api-key + * 2. Parses the JSON body (treating malformed JSON as an empty body) + * 3. Validates the body against the Zod schema + * + * Returns either a 4xx NextResponse describing the first failure, or + * the validated `{ body, auth }` ready for the handler to consume. + * + * @param request - The incoming request. + * @returns A NextResponse on validation failure, or the validated body + auth. + */ +export async function validateCreateSessionBody( + request: NextRequest, +): Promise { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; + } + + const rawBody = await safeParseJson(request); + const result = createSessionBodySchema.safeParse(rawBody); + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return { body: result.data, auth }; +} diff --git a/lib/supabase/chats/insertChat.ts b/lib/supabase/chats/insertChat.ts new file mode 100644 index 000000000..6b6102198 --- /dev/null +++ b/lib/supabase/chats/insertChat.ts @@ -0,0 +1,19 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables, TablesInsert } from "@/types/database.types"; + +/** + * Inserts a new row into the `chats` table and returns it. + * + * @param row - The chat row to insert. + * @returns The inserted row, or `null` if the insert failed. + */ +export async function insertChat(row: TablesInsert<"chats">): Promise | null> { + const { data, error } = await supabase.from("chats").insert(row).select().maybeSingle(); + + if (error) { + console.error("Error inserting chat:", error); + return null; + } + + return data; +} diff --git a/lib/supabase/sessions/deleteSessionById.ts b/lib/supabase/sessions/deleteSessionById.ts new file mode 100644 index 000000000..731f75a34 --- /dev/null +++ b/lib/supabase/sessions/deleteSessionById.ts @@ -0,0 +1,20 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * Deletes the session with the given id. Used for rollback when a + * subsequent insert (e.g. the initial chat) fails after the session + * row was already persisted. + * + * @param id - The session id to delete. + * @returns `true` on success, `false` if the delete failed. + */ +export async function deleteSessionById(id: string): Promise { + const { error } = await supabase.from("sessions").delete().eq("id", id); + + if (error) { + console.error("Error deleting session:", error); + return false; + } + + return true; +} diff --git a/lib/supabase/sessions/insertSession.ts b/lib/supabase/sessions/insertSession.ts new file mode 100644 index 000000000..7ac6f7bf7 --- /dev/null +++ b/lib/supabase/sessions/insertSession.ts @@ -0,0 +1,21 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables, TablesInsert } from "@/types/database.types"; + +/** + * Inserts a new row into the `sessions` table and returns it. + * + * @param row - The session row to insert. + * @returns The inserted row, or `null` if the insert failed. + */ +export async function insertSession( + row: TablesInsert<"sessions">, +): Promise | null> { + const { data, error } = await supabase.from("sessions").insert(row).select().maybeSingle(); + + if (error) { + console.error("Error inserting session:", error); + return null; + } + + return data; +} diff --git a/lib/supabase/sessions/selectSessions.ts b/lib/supabase/sessions/selectSessions.ts new file mode 100644 index 000000000..69477b9cb --- /dev/null +++ b/lib/supabase/sessions/selectSessions.ts @@ -0,0 +1,41 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +interface SelectSessionsFilter { + /** Optional id filter — when set, returns at most one row. */ + id?: string; + /** Optional account filter — when set, returns every session owned by the account. */ + accountId?: string; +} + +/** + * General-purpose `sessions` reader. Pass any combination of filters + * to narrow the result set; an unset filter is ignored. Returns an + * empty array on Supabase error after logging. + * + * Callers project to whatever shape they need (single row by id, + * titles by account, etc.) — keeping this single function as the + * sole entry point keeps `lib/supabase/sessions/` DRY. + * + * @param filter - Optional filters narrowing the query. + * @returns Matching rows, or `[]` on error / no match. + */ +export async function selectSessions( + filter: SelectSessionsFilter = {}, +): Promise[]> { + let query = supabase.from("sessions").select("*"); + if (filter.id) query = query.eq("id", filter.id); + if (filter.accountId) query = query.eq("account_id", filter.accountId); + + try { + const { data, error } = await query; + if (error) { + console.error("[selectSessions] error:", error); + return []; + } + return data ?? []; + } catch (e) { + console.error("[selectSessions] threw:", e); + return []; + } +} diff --git a/types/database.types.ts b/types/database.types.ts index 50b0a8ef6..a3c4b7876 100644 --- a/types/database.types.ts +++ b/types/database.types.ts @@ -298,23 +298,23 @@ export type Database = { Row: { account_id: string; created_at: string | null; - expires_at: string; + expires_at: string | null; github_repo: string | null; - snapshot_id: string; + snapshot_id: string | null; }; Insert: { account_id: string; created_at?: string | null; - expires_at: string; + expires_at?: string | null; github_repo?: string | null; - snapshot_id: string; + snapshot_id?: string | null; }; Update: { account_id?: string; created_at?: string | null; - expires_at?: string; + expires_at?: string | null; github_repo?: string | null; - snapshot_id?: string; + snapshot_id?: string | null; }; Relationships: [ { @@ -1079,6 +1079,118 @@ export type Database = { }; Relationships: []; }; + chat_messages: { + Row: { + chat_id: string; + created_at: string; + id: string; + parts: Json; + role: string; + }; + Insert: { + chat_id: string; + created_at?: string; + id: string; + parts: Json; + role: string; + }; + Update: { + chat_id?: string; + created_at?: string; + id?: string; + parts?: Json; + role?: string; + }; + Relationships: [ + { + foreignKeyName: "chat_messages_chat_id_fkey"; + columns: ["chat_id"]; + isOneToOne: false; + referencedRelation: "chats"; + referencedColumns: ["id"]; + }, + ]; + }; + chat_reads: { + Row: { + account_id: string; + chat_id: string; + created_at: string; + last_read_at: string; + updated_at: string; + }; + Insert: { + account_id: string; + chat_id: string; + created_at?: string; + last_read_at?: string; + updated_at?: string; + }; + Update: { + account_id?: string; + chat_id?: string; + created_at?: string; + last_read_at?: string; + updated_at?: string; + }; + Relationships: [ + { + foreignKeyName: "chat_reads_account_id_fkey"; + columns: ["account_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "chat_reads_chat_id_fkey"; + columns: ["chat_id"]; + isOneToOne: false; + referencedRelation: "chats"; + referencedColumns: ["id"]; + }, + ]; + }; + chats: { + Row: { + active_stream_id: string | null; + created_at: string; + id: string; + last_assistant_message_at: string | null; + model_id: string | null; + session_id: string; + title: string; + updated_at: string; + }; + Insert: { + active_stream_id?: string | null; + created_at?: string; + id: string; + last_assistant_message_at?: string | null; + model_id?: string | null; + session_id: string; + title: string; + updated_at?: string; + }; + Update: { + active_stream_id?: string | null; + created_at?: string; + id?: string; + last_assistant_message_at?: string | null; + model_id?: string | null; + session_id?: string; + title?: string; + updated_at?: string; + }; + Relationships: [ + { + foreignKeyName: "chats_session_id_fkey"; + columns: ["session_id"]; + isOneToOne: false; + referencedRelation: "sessions"; + referencedColumns: ["id"]; + }, + ]; + }; config: { Row: { billing_provider: Database["public"]["Enums"]["billing_provider"]; @@ -2763,6 +2875,104 @@ export type Database = { }; Relationships: []; }; + sessions: { + Row: { + account_id: string; + branch: string | null; + cached_diff: Json | null; + cached_diff_updated_at: string | null; + clone_url: string | null; + created_at: string; + global_skill_refs: Json; + hibernate_after: string | null; + id: string; + is_new_branch: boolean; + last_activity_at: string | null; + lifecycle_error: string | null; + lifecycle_run_id: string | null; + lifecycle_state: string | null; + lifecycle_version: number; + lines_added: number | null; + lines_removed: number | null; + repo_name: string | null; + repo_owner: string | null; + sandbox_expires_at: string | null; + sandbox_state: Json | null; + snapshot_created_at: string | null; + snapshot_size_bytes: number | null; + snapshot_url: string | null; + status: string; + title: string; + updated_at: string; + }; + Insert: { + account_id: string; + branch?: string | null; + cached_diff?: Json | null; + cached_diff_updated_at?: string | null; + clone_url?: string | null; + created_at?: string; + global_skill_refs?: Json; + hibernate_after?: string | null; + id: string; + is_new_branch?: boolean; + last_activity_at?: string | null; + lifecycle_error?: string | null; + lifecycle_run_id?: string | null; + lifecycle_state?: string | null; + lifecycle_version?: number; + lines_added?: number | null; + lines_removed?: number | null; + repo_name?: string | null; + repo_owner?: string | null; + sandbox_expires_at?: string | null; + sandbox_state?: Json | null; + snapshot_created_at?: string | null; + snapshot_size_bytes?: number | null; + snapshot_url?: string | null; + status?: string; + title: string; + updated_at?: string; + }; + Update: { + account_id?: string; + branch?: string | null; + cached_diff?: Json | null; + cached_diff_updated_at?: string | null; + clone_url?: string | null; + created_at?: string; + global_skill_refs?: Json; + hibernate_after?: string | null; + id?: string; + is_new_branch?: boolean; + last_activity_at?: string | null; + lifecycle_error?: string | null; + lifecycle_run_id?: string | null; + lifecycle_state?: string | null; + lifecycle_version?: number; + lines_added?: number | null; + lines_removed?: number | null; + repo_name?: string | null; + repo_owner?: string | null; + sandbox_expires_at?: string | null; + sandbox_state?: Json | null; + snapshot_created_at?: string | null; + snapshot_size_bytes?: number | null; + snapshot_url?: string | null; + status?: string; + title?: string; + updated_at?: string; + }; + Relationships: [ + { + foreignKeyName: "sessions_account_id_fkey"; + columns: ["account_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; + }, + ]; + }; social_fans: { Row: { artist_social_id: string; @@ -3548,6 +3758,91 @@ export type Database = { }; Relationships: []; }; + workflow_run_steps: { + Row: { + created_at: string; + duration_ms: number; + finish_reason: string | null; + finished_at: string; + id: string; + raw_finish_reason: string | null; + started_at: string; + step_number: number; + workflow_run_id: string; + }; + Insert: { + created_at?: string; + duration_ms: number; + finish_reason?: string | null; + finished_at: string; + id: string; + raw_finish_reason?: string | null; + started_at: string; + step_number: number; + workflow_run_id: string; + }; + Update: { + created_at?: string; + duration_ms?: number; + finish_reason?: string | null; + finished_at?: string; + id?: string; + raw_finish_reason?: string | null; + started_at?: string; + step_number?: number; + workflow_run_id?: string; + }; + Relationships: [ + { + foreignKeyName: "workflow_run_steps_workflow_run_id_fkey"; + columns: ["workflow_run_id"]; + isOneToOne: false; + referencedRelation: "workflow_runs"; + referencedColumns: ["id"]; + }, + ]; + }; + workflow_runs: { + Row: { + chat_id: string; + created_at: string; + finished_at: string; + id: string; + model_id: string | null; + started_at: string; + status: string; + total_duration_ms: number; + }; + Insert: { + chat_id: string; + created_at?: string; + finished_at: string; + id: string; + model_id?: string | null; + started_at: string; + status: string; + total_duration_ms: number; + }; + Update: { + chat_id?: string; + created_at?: string; + finished_at?: string; + id?: string; + model_id?: string | null; + started_at?: string; + status?: string; + total_duration_ms?: number; + }; + Relationships: [ + { + foreignKeyName: "workflow_runs_chat_id_fkey"; + columns: ["chat_id"]; + isOneToOne: false; + referencedRelation: "chats"; + referencedColumns: ["id"]; + }, + ]; + }; youtube_tokens: { Row: { access_token: string; From b2dd01711c2a6f43a3334333e62f77ff8756cb13 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Mon, 4 May 2026 19:00:51 -0500 Subject: [PATCH 4/6] feat(sessions): port GET /api/sessions/[sessionId] from open-agents (#514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto current main (which now has the POST endpoint + shared infra from PR #515). Three pieces of GET-specific work: - app/api/sessions/[sessionId]/route.ts: thin shell delegating to the handler, plus OPTIONS for CORS preflight + cache directives - lib/sessions/getSessionByIdHandler.ts: validates auth via validateAuthContext, reads via selectSessions({id}), enforces ownership (403 if account_id mismatch), 404 if missing - app/api/sessions/[sessionId]/__tests__/route.test.ts: 5 cases — 401 / 404 / 403 / 200 happy path / OPTIONS smoke Uses the new general selectSessions({id}) reader rather than the deleted single-purpose selectSession helper. All other shared infra (types, toSessionResponse) is already on main from #515. Co-authored-by: Claude Opus 4.7 (1M context) --- .../[sessionId]/__tests__/route.test.ts | 163 ++++++++++++++++++ app/api/sessions/[sessionId]/route.ts | 42 +++++ lib/sessions/getSessionByIdHandler.ts | 51 ++++++ 3 files changed, 256 insertions(+) create mode 100644 app/api/sessions/[sessionId]/__tests__/route.test.ts create mode 100644 app/api/sessions/[sessionId]/route.ts create mode 100644 lib/sessions/getSessionByIdHandler.ts diff --git a/app/api/sessions/[sessionId]/__tests__/route.test.ts b/app/api/sessions/[sessionId]/__tests__/route.test.ts new file mode 100644 index 000000000..685a4a205 --- /dev/null +++ b/app/api/sessions/[sessionId]/__tests__/route.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { GET, OPTIONS } from "../route"; +import type { Tables } from "@/types/database.types"; + +type SessionRow = Tables<"sessions">; + +vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ + selectSessions: vi.fn(), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +const { selectSessions } = await import("@/lib/supabase/sessions/selectSessions"); +const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); + +function makeReq(url = "https://example.com/api/sessions/sess_1"): NextRequest { + return new NextRequest(url); +} + +const mockRow: SessionRow = { + id: "sess_1", + account_id: "acc-uuid-1", + title: "Test session", + status: "running", + repo_owner: "acme", + repo_name: "demo", + branch: "main", + clone_url: "https://github.com/acme/demo.git", + is_new_branch: false, + global_skill_refs: [], + sandbox_state: { type: "vercel" }, + lifecycle_state: "active", + lifecycle_version: 1, + last_activity_at: "2026-05-04T00:00:00.000Z", + sandbox_expires_at: null, + hibernate_after: null, + lifecycle_run_id: null, + lifecycle_error: null, + lines_added: 12, + lines_removed: 3, + snapshot_url: null, + snapshot_created_at: null, + snapshot_size_bytes: null, + cached_diff: null, + cached_diff_updated_at: null, + created_at: "2026-05-01T00:00:00.000Z", + updated_at: "2026-05-04T00:00:00.000Z", +}; + +describe("OPTIONS /api/sessions/[sessionId]", () => { + it("returns 200 with CORS headers", async () => { + const res = await OPTIONS(); + expect(res.status).toBe(200); + }); +}); + +describe("GET /api/sessions/[sessionId]", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const res = await GET(makeReq(), { + params: Promise.resolve({ sessionId: "sess_1" }), + }); + expect(res.status).toBe(401); + expect(selectSessions).not.toHaveBeenCalled(); + }); + + it("returns 404 when session does not exist", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-uuid-1", + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([]); + + const res = await GET(makeReq(), { + params: Promise.resolve({ sessionId: "sess_missing" }), + }); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + status: "error", + error: "Session not found", + }); + expect(selectSessions).toHaveBeenCalledWith({ id: "sess_missing" }); + }); + + it("returns 403 when session is owned by a different account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-uuid-OTHER", + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([mockRow]); + + const res = await GET(makeReq(), { + params: Promise.resolve({ sessionId: "sess_1" }), + }); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ + status: "error", + error: "Forbidden", + }); + }); + + it("returns 200 with camelCase session shape on happy path", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-uuid-1", + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([mockRow]); + + const res = await GET(makeReq(), { + params: Promise.resolve({ sessionId: "sess_1" }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ + session: { + id: "sess_1", + userId: "acc-uuid-1", + title: "Test session", + status: "running", + repoOwner: "acme", + repoName: "demo", + branch: "main", + cloneUrl: "https://github.com/acme/demo.git", + isNewBranch: false, + globalSkillRefs: [], + sandboxState: { type: "vercel" }, + lifecycleState: "active", + lifecycleVersion: 1, + lastActivityAt: "2026-05-04T00:00:00.000Z", + sandboxExpiresAt: null, + hibernateAfter: null, + lifecycleRunId: null, + lifecycleError: null, + linesAdded: 12, + linesRemoved: 3, + snapshotUrl: null, + snapshotCreatedAt: null, + snapshotSizeBytes: null, + cachedDiff: null, + cachedDiffUpdatedAt: null, + createdAt: "2026-05-01T00:00:00.000Z", + updatedAt: "2026-05-04T00:00:00.000Z", + }, + }); + }); +}); diff --git a/app/api/sessions/[sessionId]/route.ts b/app/api/sessions/[sessionId]/route.ts new file mode 100644 index 000000000..dbc04994a --- /dev/null +++ b/app/api/sessions/[sessionId]/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getSessionByIdHandler } from "@/lib/sessions/getSessionByIdHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/sessions/{sessionId} + * + * Reads a single agent session by id. Authenticates via Privy Bearer + * token or x-api-key header. Returns 404 if the session does not exist + * and 403 if it exists but is not owned by the authenticated account. + * + * Response shape mirrors open-agents' /api/sessions/[sessionId] so the + * existing frontend can cut over to api without code changes. + * + * @param request - The request object + * @param options - Route options containing the async params + * @param options.params - Route params containing the session id + * @returns A NextResponse with `{ session }` on 200, or an error. + */ +export async function GET( + request: NextRequest, + options: { params: Promise<{ sessionId: string }> }, +) { + const { sessionId } = await options.params; + return getSessionByIdHandler(request, sessionId); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/sessions/getSessionByIdHandler.ts b/lib/sessions/getSessionByIdHandler.ts new file mode 100644 index 000000000..11a22f5a7 --- /dev/null +++ b/lib/sessions/getSessionByIdHandler.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import { toSessionResponse } from "@/lib/sessions/toSessionResponse"; + +/** + * Handles GET /api/sessions/{sessionId}. + * + * Reads a single agent session by id. Authenticates via Privy Bearer + * token or x-api-key header. Returns 404 if the session does not exist + * and 403 if it exists but is not owned by the authenticated account. + * + * Response shape mirrors open-agents' /api/sessions/[sessionId] so the + * existing frontend can cut over to api without code changes. + * + * @param request - The incoming request. + * @param sessionId - The id of the session to fetch. + * @returns A NextResponse with `{ session }` on 200, or an error. + */ +export async function getSessionByIdHandler( + request: NextRequest, + sessionId: string, +): Promise { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; + } + + const rows = await selectSessions({ id: sessionId }); + const row = rows[0] ?? null; + + if (!row) { + return NextResponse.json( + { status: "error", error: "Session not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + if (row.account_id !== auth.accountId) { + return NextResponse.json( + { status: "error", error: "Forbidden" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { session: toSessionResponse(row) }, + { status: 200, headers: getCorsHeaders() }, + ); +} From 5752e1182267db9a824c9cb8b3452ad704b555f3 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Tue, 5 May 2026 09:09:10 -0500 Subject: [PATCH 5/6] feat(ai/models): enrich response with context_window + cost (#518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ai/models): enrich response with context_window + cost from models.dev api's GET /api/ai/models previously returned just the gateway entries. Open-agents' frontend depends on two extra fields per model that come from the public models.dev catalog: - context_window (integer) — gates model selection in the picker - cost ({input, output}) — per-million-token pricing for display Adds three pure helpers (TDD'd individually) plus a small refactor of the existing fetcher to merge metadata in: - lib/ai/parseModelsDevMetadata.ts: tolerant unknown→Map parser - lib/ai/fetchModelsDevMetadata.ts: 750ms-bounded fetch with full error swallowing (metadata is best-effort, must never gate the underlying gateway response) - lib/ai/enrichGatewayModel.ts: pure, non-mutating merge getAvailableModels now fetches gateway + metadata in parallel and maps each non-embed model through enrichGatewayModel. If models.dev is unreachable the response is identical to today (gateway models unenriched). Documented in recoupable/docs#188 (merged). Unblocks the eventual open-agents frontend cutover for the model picker. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(ai): extract isRecord into its own lib (SRP) Per PR feedback: each file should export one primary function. Pulled isRecord out of parseModelsDevMetadata.ts into lib/ai/isRecord.ts so the parser file is single-purpose. Also includes the typecheck fix for enrichGatewayModel — the `[key: string]: unknown` index signature on its generic constraint was rejecting `GatewayLanguageModelEntry` and breaking the Vercel build. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- lib/ai/__tests__/enrichGatewayModel.test.ts | 37 ++++++ .../__tests__/fetchModelsDevMetadata.test.ts | 62 ++++++++++ lib/ai/__tests__/getAvailableModels.test.ts | 42 +++++++ lib/ai/__tests__/isRecord.test.ts | 22 ++++ .../__tests__/parseModelsDevMetadata.test.ts | 106 ++++++++++++++++++ lib/ai/enrichGatewayModel.ts | 29 +++++ lib/ai/fetchModelsDevMetadata.ts | 30 +++++ lib/ai/getAvailableModels.ts | 17 ++- lib/ai/isRecord.ts | 12 ++ lib/ai/parseModelsDevMetadata.ts | 56 +++++++++ 10 files changed, 409 insertions(+), 4 deletions(-) create mode 100644 lib/ai/__tests__/enrichGatewayModel.test.ts create mode 100644 lib/ai/__tests__/fetchModelsDevMetadata.test.ts create mode 100644 lib/ai/__tests__/isRecord.test.ts create mode 100644 lib/ai/__tests__/parseModelsDevMetadata.test.ts create mode 100644 lib/ai/enrichGatewayModel.ts create mode 100644 lib/ai/fetchModelsDevMetadata.ts create mode 100644 lib/ai/isRecord.ts create mode 100644 lib/ai/parseModelsDevMetadata.ts diff --git a/lib/ai/__tests__/enrichGatewayModel.test.ts b/lib/ai/__tests__/enrichGatewayModel.test.ts new file mode 100644 index 000000000..06335d53d --- /dev/null +++ b/lib/ai/__tests__/enrichGatewayModel.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { enrichGatewayModel } from "@/lib/ai/enrichGatewayModel"; + +describe("enrichGatewayModel", () => { + it("returns the original model when the metadata map has no matching entry", () => { + const model = { id: "x/y", name: "X" }; + const result = enrichGatewayModel(model, new Map()); + expect(result).toEqual(model); + }); + + it("merges context_window when present in metadata", () => { + const model = { id: "x/y", name: "X" }; + const map = new Map([["x/y", { context_window: 4096 }]]); + expect(enrichGatewayModel(model, map)).toEqual({ + id: "x/y", + name: "X", + context_window: 4096, + }); + }); + + it("merges cost when present in metadata", () => { + const model = { id: "x/y", name: "X" }; + const map = new Map([["x/y", { cost: { input: 1, output: 2 } }]]); + expect(enrichGatewayModel(model, map)).toEqual({ + id: "x/y", + name: "X", + cost: { input: 1, output: 2 }, + }); + }); + + it("does not mutate the original model", () => { + const model = { id: "x/y", name: "X" } as Record; + const map = new Map([["x/y", { context_window: 100 }]]); + enrichGatewayModel(model, map); + expect(model.context_window).toBeUndefined(); + }); +}); diff --git a/lib/ai/__tests__/fetchModelsDevMetadata.test.ts b/lib/ai/__tests__/fetchModelsDevMetadata.test.ts new file mode 100644 index 000000000..c64fd8dda --- /dev/null +++ b/lib/ai/__tests__/fetchModelsDevMetadata.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { fetchModelsDevMetadata } from "@/lib/ai/fetchModelsDevMetadata"; + +const fetchMock = vi.fn(); + +beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("fetchModelsDevMetadata", () => { + it("returns the parsed metadata map on a 200 response", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + anthropic: { + models: { + "claude-3-5-sonnet": { + id: "claude-3-5-sonnet", + limit: { context: 200000 }, + cost: { input: 3, output: 15 }, + }, + }, + }, + }), + }); + + const result = await fetchModelsDevMetadata(); + expect(fetchMock).toHaveBeenCalledWith( + "https://models.dev/api.json", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(result.get("anthropic/claude-3-5-sonnet")).toEqual({ + context_window: 200000, + cost: { input: 3, output: 15 }, + }); + }); + + it("returns an empty map when the response is non-200", async () => { + fetchMock.mockResolvedValue({ ok: false, json: async () => ({}) }); + expect(await fetchModelsDevMetadata()).toEqual(new Map()); + }); + + it("returns an empty map when fetch throws", async () => { + fetchMock.mockRejectedValue(new Error("network down")); + expect(await fetchModelsDevMetadata()).toEqual(new Map()); + }); + + it("returns an empty map when JSON parsing throws", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => { + throw new Error("not json"); + }, + }); + expect(await fetchModelsDevMetadata()).toEqual(new Map()); + }); +}); diff --git a/lib/ai/__tests__/getAvailableModels.test.ts b/lib/ai/__tests__/getAvailableModels.test.ts index 0fdcf0121..359402b0f 100644 --- a/lib/ai/__tests__/getAvailableModels.test.ts +++ b/lib/ai/__tests__/getAvailableModels.test.ts @@ -2,11 +2,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { gateway } from "@ai-sdk/gateway"; import { getAvailableModels } from "../getAvailableModels"; +import { fetchModelsDevMetadata } from "@/lib/ai/fetchModelsDevMetadata"; vi.mock("@ai-sdk/gateway", () => ({ gateway: { getAvailableModels: vi.fn() }, })); +vi.mock("@/lib/ai/fetchModelsDevMetadata", () => ({ + fetchModelsDevMetadata: vi.fn(), +})); + describe("getAvailableModels", () => { beforeEach(() => vi.clearAllMocks()); @@ -18,22 +23,59 @@ describe("getAvailableModels", () => { { id: "claude-3-opus", pricing: { input: "0.00001", output: "0.00003" } }, ], } as Awaited>); + vi.mocked(fetchModelsDevMetadata).mockResolvedValue(new Map()); const models = await getAvailableModels(); expect(models.map(m => m.id)).toEqual(["gpt-4", "claude-3-opus"]); }); + it("enriches models with context_window + cost when metadata is available", async () => { + vi.mocked(gateway.getAvailableModels).mockResolvedValue({ + models: [ + { + id: "anthropic/claude-3-5-sonnet", + pricing: { input: "0.000003", output: "0.000015" }, + }, + ], + } as Awaited>); + vi.mocked(fetchModelsDevMetadata).mockResolvedValue( + new Map([ + ["anthropic/claude-3-5-sonnet", { context_window: 200000, cost: { input: 3, output: 15 } }], + ]), + ); + + const [model] = await getAvailableModels(); + expect((model as { context_window?: number }).context_window).toBe(200000); + expect((model as { cost?: { input: number; output: number } }).cost).toEqual({ + input: 3, + output: 15, + }); + }); + + it("returns gateway models unchanged when metadata fetch returns an empty map", async () => { + vi.mocked(gateway.getAvailableModels).mockResolvedValue({ + models: [{ id: "gpt-4", pricing: { input: "0.00003", output: "0.00006" } }], + } as Awaited>); + vi.mocked(fetchModelsDevMetadata).mockResolvedValue(new Map()); + + const [model] = await getAvailableModels(); + expect((model as { context_window?: number }).context_window).toBeUndefined(); + expect((model as { cost?: unknown }).cost).toBeUndefined(); + }); + it("returns empty array when gateway returns no models", async () => { vi.mocked(gateway.getAvailableModels).mockResolvedValue({ models: [] } as Awaited< ReturnType >); + vi.mocked(fetchModelsDevMetadata).mockResolvedValue(new Map()); expect(await getAvailableModels()).toEqual([]); }); it("returns empty array when gateway throws", async () => { vi.mocked(gateway.getAvailableModels).mockRejectedValue(new Error("kaboom")); + vi.mocked(fetchModelsDevMetadata).mockResolvedValue(new Map()); expect(await getAvailableModels()).toEqual([]); }); diff --git a/lib/ai/__tests__/isRecord.test.ts b/lib/ai/__tests__/isRecord.test.ts new file mode 100644 index 000000000..8062f5d1b --- /dev/null +++ b/lib/ai/__tests__/isRecord.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { isRecord } from "@/lib/ai/isRecord"; + +describe("isRecord", () => { + it("returns true for plain objects", () => { + expect(isRecord({})).toBe(true); + expect(isRecord({ a: 1 })).toBe(true); + }); + + it("returns false for null and primitives", () => { + expect(isRecord(null)).toBe(false); + expect(isRecord(undefined)).toBe(false); + expect(isRecord(0)).toBe(false); + expect(isRecord("x")).toBe(false); + expect(isRecord(true)).toBe(false); + }); + + it("returns false for arrays", () => { + expect(isRecord([])).toBe(false); + expect(isRecord([{ a: 1 }])).toBe(false); + }); +}); diff --git a/lib/ai/__tests__/parseModelsDevMetadata.test.ts b/lib/ai/__tests__/parseModelsDevMetadata.test.ts new file mode 100644 index 000000000..78b2fe575 --- /dev/null +++ b/lib/ai/__tests__/parseModelsDevMetadata.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from "vitest"; +import { parseModelsDevMetadata } from "@/lib/ai/parseModelsDevMetadata"; + +describe("parseModelsDevMetadata", () => { + it("returns an empty map when input is not a record", () => { + expect(parseModelsDevMetadata(null)).toEqual(new Map()); + expect(parseModelsDevMetadata("nope")).toEqual(new Map()); + expect(parseModelsDevMetadata(123)).toEqual(new Map()); + expect(parseModelsDevMetadata([])).toEqual(new Map()); + }); + + it("extracts context_window and cost from a well-formed entry", () => { + const result = parseModelsDevMetadata({ + anthropic: { + models: { + "claude-3-opus": { + id: "claude-3-opus", + limit: { context: 200000 }, + cost: { input: 15, output: 75 }, + }, + }, + }, + }); + + expect(result.get("anthropic/claude-3-opus")).toEqual({ + context_window: 200000, + cost: { input: 15, output: 75 }, + }); + }); + + it("respects an already-namespaced id without double-prefixing", () => { + const result = parseModelsDevMetadata({ + openai: { + models: { + "gpt-4o": { + id: "openai/gpt-4o", + limit: { context: 128000 }, + }, + }, + }, + }); + + expect(result.has("openai/gpt-4o")).toBe(true); + expect(result.has("openai/openai/gpt-4o")).toBe(false); + }); + + it("falls back to the model key when id is missing or non-string", () => { + const result = parseModelsDevMetadata({ + acme: { + models: { + "fancy-model": { + limit: { context: 8192 }, + }, + }, + }, + }); + expect(result.get("acme/fancy-model")?.context_window).toBe(8192); + }); + + it("skips entries with neither context_window nor cost", () => { + const result = parseModelsDevMetadata({ + acme: { models: { ghost: { id: "ghost", limit: {} } } }, + }); + expect(result.has("acme/ghost")).toBe(false); + }); + + it("skips cost when input or output is missing", () => { + const result = parseModelsDevMetadata({ + acme: { + models: { + partial: { + id: "partial", + limit: { context: 100 }, + cost: { input: 1 }, + }, + }, + }, + }); + expect(result.get("acme/partial")).toEqual({ context_window: 100 }); + }); + + it("skips non-positive context_window values", () => { + const result = parseModelsDevMetadata({ + acme: { + models: { + weird: { + id: "weird", + limit: { context: 0 }, + cost: { input: 1, output: 2 }, + }, + }, + }, + }); + expect(result.get("acme/weird")).toEqual({ cost: { input: 1, output: 2 } }); + }); + + it("ignores providers and models that aren't records", () => { + const result = parseModelsDevMetadata({ + anthropic: "not-a-record", + openai: { models: "also-broken" }, + cohere: { models: { "good-model": { id: "good", limit: { context: 1 } } } }, + }); + expect(result.size).toBe(1); + expect(result.has("cohere/good")).toBe(true); + }); +}); diff --git a/lib/ai/enrichGatewayModel.ts b/lib/ai/enrichGatewayModel.ts new file mode 100644 index 000000000..5aa4b4571 --- /dev/null +++ b/lib/ai/enrichGatewayModel.ts @@ -0,0 +1,29 @@ +import type { ModelsDevMetadata } from "@/lib/ai/parseModelsDevMetadata"; + +interface GatewayModelLike { + id: string; +} + +/** + * Returns a copy of `model` enriched with `context_window` / `cost` + * fields when the metadata map has matching values. The original + * model is never mutated, and missing metadata is a no-op (returns + * the model unchanged shape-wise) so callers can use this in a map + * regardless of whether the metadata fetch succeeded. + * + * @param model - The gateway model to enrich. + * @param metadataMap - Result of `fetchModelsDevMetadata`. + * @returns The enriched model, or the original if no metadata matched. + */ +export function enrichGatewayModel( + model: T, + metadataMap: Map, +): T & Partial { + const meta = metadataMap.get(model.id); + if (!meta) return model; + + const enriched: T & Partial = { ...model }; + if (meta.context_window !== undefined) enriched.context_window = meta.context_window; + if (meta.cost !== undefined) enriched.cost = meta.cost; + return enriched; +} diff --git a/lib/ai/fetchModelsDevMetadata.ts b/lib/ai/fetchModelsDevMetadata.ts new file mode 100644 index 000000000..522a200a9 --- /dev/null +++ b/lib/ai/fetchModelsDevMetadata.ts @@ -0,0 +1,30 @@ +import { parseModelsDevMetadata } from "@/lib/ai/parseModelsDevMetadata"; +import type { ModelsDevMetadata } from "@/lib/ai/parseModelsDevMetadata"; + +const MODELS_DEV_URL = "https://models.dev/api.json"; +const TIMEOUT_MS = 750; + +/** + * Fetches the public `models.dev` catalog and returns parsed enrichment + * metadata. Treats every failure mode (timeout, non-2xx, malformed JSON, + * thrown error) as "no metadata" and returns an empty map — never + * propagates the error, since enrichment is best-effort and must not + * gate the underlying gateway-models response. + * + * @returns Map keyed by `provider/model`, possibly empty. + */ +export async function fetchModelsDevMetadata(): Promise> { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); + + try { + const response = await fetch(MODELS_DEV_URL, { signal: controller.signal }); + if (!response.ok) return new Map(); + const data: unknown = await response.json(); + return parseModelsDevMetadata(data); + } catch { + return new Map(); + } finally { + clearTimeout(timeoutId); + } +} diff --git a/lib/ai/getAvailableModels.ts b/lib/ai/getAvailableModels.ts index 5ad85aae4..823f6c80f 100644 --- a/lib/ai/getAvailableModels.ts +++ b/lib/ai/getAvailableModels.ts @@ -1,14 +1,23 @@ import { gateway } from "@ai-sdk/gateway"; import isEmbedModel from "./isEmbedModel"; +import { enrichGatewayModel } from "@/lib/ai/enrichGatewayModel"; +import { fetchModelsDevMetadata } from "@/lib/ai/fetchModelsDevMetadata"; /** - * Returns the list of available LLMs from the Vercel AI Gateway. - * Filters out embed models that are not suitable for chat. + * Returns the list of available LLMs from the Vercel AI Gateway, + * enriched with `context_window` and `cost` from the public + * `models.dev` catalog. The enrichment fetch is best-effort — on + * any failure the gateway list is returned without enrichment so + * the endpoint stays available for clients that don't require + * those fields. */ export const getAvailableModels = async () => { try { - const { models } = await gateway.getAvailableModels(); - return models.filter(m => !isEmbedModel(m)); + const [{ models }, metadataMap] = await Promise.all([ + gateway.getAvailableModels(), + fetchModelsDevMetadata(), + ]); + return models.filter(m => !isEmbedModel(m)).map(m => enrichGatewayModel(m, metadataMap)); } catch (error) { console.error("[getAvailableModels] gateway fetch failed:", error); return []; diff --git a/lib/ai/isRecord.ts b/lib/ai/isRecord.ts new file mode 100644 index 000000000..c75626f8c --- /dev/null +++ b/lib/ai/isRecord.ts @@ -0,0 +1,12 @@ +/** + * Type guard for "plain object" values: anything that is not null, + * not a primitive, and not an array. Used by tolerant parsers that + * walk an `unknown` payload (e.g. a third-party metadata catalog) + * without ever throwing on shape mismatches. + * + * @param value - Candidate to test. + * @returns `true` if `value` is a non-null, non-array object. + */ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/lib/ai/parseModelsDevMetadata.ts b/lib/ai/parseModelsDevMetadata.ts new file mode 100644 index 000000000..63b1f022d --- /dev/null +++ b/lib/ai/parseModelsDevMetadata.ts @@ -0,0 +1,56 @@ +import { isRecord } from "@/lib/ai/isRecord"; + +export interface ModelsDevMetadata { + context_window?: number; + cost?: { input: number; output: number }; +} + +/** + * Parses a `models.dev` API.json payload into a map of model-id → enrichment + * metadata (context window + per-million-token cost). Tolerant of malformed + * input — anything that doesn't fit the expected shape is skipped silently + * so a third-party catalog change can never crash the gateway proxy. + * + * @param data - Raw payload from https://models.dev/api.json. + * @returns Map keyed by fully-qualified model id (`provider/model`). + */ +export function parseModelsDevMetadata(data: unknown): Map { + const map = new Map(); + if (!isRecord(data)) return map; + + for (const [providerKey, providerValue] of Object.entries(data)) { + if (!isRecord(providerValue)) continue; + if (!isRecord(providerValue.models)) continue; + + for (const [modelKey, modelValue] of Object.entries(providerValue.models)) { + if (!isRecord(modelValue)) continue; + + const rawId = typeof modelValue.id === "string" ? modelValue.id : modelKey; + const modelId = rawId.includes("/") ? rawId : `${providerKey}/${rawId}`; + + const meta: ModelsDevMetadata = {}; + + if ( + isRecord(modelValue.limit) && + typeof modelValue.limit.context === "number" && + modelValue.limit.context > 0 + ) { + meta.context_window = modelValue.limit.context; + } + + if ( + isRecord(modelValue.cost) && + typeof modelValue.cost.input === "number" && + typeof modelValue.cost.output === "number" + ) { + meta.cost = { input: modelValue.cost.input, output: modelValue.cost.output }; + } + + if (meta.context_window !== undefined || meta.cost !== undefined) { + map.set(modelId, meta); + } + } + } + + return map; +} From d0aa31d016c073f8856b9329b68264c8076594f0 Mon Sep 17 00:00:00 2001 From: ahmednahima0-beep Date: Wed, 6 May 2026 21:04:51 +0700 Subject: [PATCH 6/6] feat(api): add GET /api/subscriptions/status (#506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(sandbox): merge updates from main and align with @vercel/sandbox v2.0.0-beta.11 This commit merges the latest changes from the main branch and ensures compatibility with the updated @vercel/sandbox version. The API has been adjusted to reflect the renaming of Sandbox.sandboxId to Sandbox.name, with corresponding updates to method parameters. All relevant tests have been updated to mock the new Sandbox structure and verify functionality. Verification steps have been executed successfully, confirming no issues with installation, type checking, linting, or tests. * feat(stripe): enhance isActiveSubscription logic and add tests - Refactored the isActiveSubscription function to improve clarity and efficiency in determining subscription status. - Added comprehensive unit tests for isActiveSubscription to cover various subscription states, including active, trialing, and canceled scenarios. - Ensured that the function correctly handles null or undefined inputs, returning false as expected. * feat(stripe): improve getActiveSubscriptions to handle pagination and enhance filtering - Refactored getActiveSubscriptions to implement pagination for fetching active subscriptions from Stripe, allowing for more than 100 results. - Introduced a constant PAGE_LIMIT for better maintainability of the subscription listing limit. - Enhanced filtering logic to ensure only subscriptions matching the specified accountId are returned. - Improved error handling to log issues encountered during the subscription fetching process. * refactor(stripe): optimize getOrgSubscription to return first active subscription - Changed the implementation of getOrgSubscription to iterate through organization IDs and return the first active subscription found, improving efficiency by eliminating unnecessary parallel requests. - Removed the previous logic that collected all subscriptions and filtered them, simplifying the function's flow. * test(subscriptions): enhance route tests for GET and OPTIONS handlers - Added tests for OPTIONS handler to verify it returns 200 status with CORS headers. - Implemented tests for GET handler to ensure it correctly forwards requests to getSubscriptionStatusHandler and returns the expected response. - Introduced beforeEach hook to clear mocks before each test, improving test isolation. * feat(stripe): export querySchema and simplify request validation - Exported the querySchema for use in other modules, enhancing reusability. - Simplified the ValidatedGetSubscriptionStatusRequest type by inferring it directly from querySchema, improving type safety and maintainability. * refactor(stripe): rename validation function and remove deprecated request validation - Renamed `validateGetSubscriptionStatusRequest` to `validateGetSubscriptionStatusQuery` for clarity and consistency. - Updated references in `getSubscriptionStatusHandler` and related tests to use the new validation function. - Removed the deprecated `validateGetSubscriptionStatusRequest` file and its associated tests, streamlining the codebase. * feat(stripe): enhance getActiveSubscriptions to limit pagination and improve filtering - Introduced a maximum page limit for `subscriptions.list` calls to prevent excessive latency on large accounts. - Updated the function to stop fetching after the first page that contains a matching subscription, optimizing performance. - Added support for an optional `stripeCustomerId` parameter to scope the subscription list to a specific customer. - Enhanced unit tests to cover new functionality, including early termination on matches and pagination limits. * refactor(stripe): remove pagination limit in getActiveSubscriptions for improved match retrieval - Eliminated the fixed page limit for `subscriptions.list` calls, allowing the function to paginate until all matches are found. - Updated the logic to break pagination if the cursor does not advance, preventing infinite loops. - Enhanced unit tests to verify behavior with no artificial page limits and to ensure correct handling of pagination scenarios. * refactor(tests): streamline getActiveSubscriptions tests and improve mock handling - Simplified test setup by consolidating mock definitions for `stripeClient.subscriptions.list`. - Enhanced readability and maintainability of tests by using helper functions for mock data generation. - Ensured consistent behavior across tests by standardizing the way mock responses are defined and utilized. * refactor(tests): restructure getActiveSubscriptions test helpers for improved clarity - Consolidated subscription-related helper functions into a single `getActiveSubscriptionsTestHelpers` function. - Enhanced test readability by using destructured imports for mock data generation. - Improved maintainability of tests by centralizing mock definitions and reducing redundancy. * refactor(api): move subscription status to GET /api/accounts/{id}/subscription Aligns the implementation with recoupable/docs#183: documents subscription status as a resource nested under the account it belongs to, identifies the account via path param, and returns the documented response shape. - New route: app/api/accounts/[id]/subscription - New response: { isPro, status, plan, source } (was { isPro }) - New handler/validator/mapper with unit tests covering account-active, org-active, neither-active, trialing-with-canceled_at, and unsupported Stripe statuses - Deletes the old query-param endpoint and helpers * refactor(stripe): drop unused stripeCustomerId; extract toStatus to its own file - YAGNI: getActiveSubscriptions no longer accepts the unused stripeCustomerId parameter; corresponding test removed. - SRP: toStatus (Stripe status → SubscriptionStatus enum) lives in its own module with focused unit tests; buildSubscriptionResponse now imports it. --------- Co-authored-by: Sweets Sweetman --- .../[id]/subscription/__tests__/route.test.ts | 46 +++++++++ .../subscription/__tests__/routeTestMocks.ts | 9 ++ app/api/accounts/[id]/subscription/route.ts | 35 +++++++ .../buildSubscriptionResponse.test.ts | 69 +++++++++++++ .../getAccountSubscriptionHandler.test.ts | 98 +++++++++++++++++++ .../__tests__/getActiveSubscriptions.test.ts | 71 ++++++++++++++ .../getActiveSubscriptionsTestHelpers.ts | 18 ++++ .../__tests__/getOrgSubscription.test.ts | 66 +++++++++++++ .../__tests__/isActiveSubscription.test.ts | 36 +++++++ lib/stripe/__tests__/toStatus.test.ts | 19 ++++ .../validateAccountSubscriptionParams.test.ts | 56 +++++++++++ lib/stripe/buildSubscriptionResponse.ts | 47 +++++++++ lib/stripe/getAccountSubscriptionHandler.ts | 42 ++++++++ lib/stripe/getActiveSubscriptionDetails.ts | 11 +++ lib/stripe/getActiveSubscriptions.ts | 49 ++++++++++ lib/stripe/getOrgSubscription.ts | 24 +++++ lib/stripe/isActiveSubscription.ts | 12 +++ lib/stripe/toStatus.ts | 20 ++++ .../validateAccountSubscriptionParams.ts | 31 ++++++ 19 files changed, 759 insertions(+) create mode 100644 app/api/accounts/[id]/subscription/__tests__/route.test.ts create mode 100644 app/api/accounts/[id]/subscription/__tests__/routeTestMocks.ts create mode 100644 app/api/accounts/[id]/subscription/route.ts create mode 100644 lib/stripe/__tests__/buildSubscriptionResponse.test.ts create mode 100644 lib/stripe/__tests__/getAccountSubscriptionHandler.test.ts create mode 100644 lib/stripe/__tests__/getActiveSubscriptions.test.ts create mode 100644 lib/stripe/__tests__/getActiveSubscriptionsTestHelpers.ts create mode 100644 lib/stripe/__tests__/getOrgSubscription.test.ts create mode 100644 lib/stripe/__tests__/isActiveSubscription.test.ts create mode 100644 lib/stripe/__tests__/toStatus.test.ts create mode 100644 lib/stripe/__tests__/validateAccountSubscriptionParams.test.ts create mode 100644 lib/stripe/buildSubscriptionResponse.ts create mode 100644 lib/stripe/getAccountSubscriptionHandler.ts create mode 100644 lib/stripe/getActiveSubscriptionDetails.ts create mode 100644 lib/stripe/getActiveSubscriptions.ts create mode 100644 lib/stripe/getOrgSubscription.ts create mode 100644 lib/stripe/isActiveSubscription.ts create mode 100644 lib/stripe/toStatus.ts create mode 100644 lib/stripe/validateAccountSubscriptionParams.ts diff --git a/app/api/accounts/[id]/subscription/__tests__/route.test.ts b/app/api/accounts/[id]/subscription/__tests__/route.test.ts new file mode 100644 index 000000000..6943eba98 --- /dev/null +++ b/app/api/accounts/[id]/subscription/__tests__/route.test.ts @@ -0,0 +1,46 @@ +import "./routeTestMocks"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getAccountSubscriptionHandler } from "@/lib/stripe/getAccountSubscriptionHandler"; + +const { GET, OPTIONS } = await import("../route"); + +const ACCOUNT_ID = "123e4567-e89b-12d3-a456-426614174000"; + +describe("app/api/accounts/[id]/subscription/route", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("OPTIONS returns 200 with CORS headers", async () => { + const res = await OPTIONS(); + expect(res.status).toBe(200); + expect(getCorsHeaders).toHaveBeenCalled(); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); + + it("GET delegates to getAccountSubscriptionHandler with the path params", async () => { + const handlerRes = NextResponse.json( + { isPro: true, status: "active", plan: "pro", source: "account" }, + { status: 200 }, + ); + vi.mocked(getAccountSubscriptionHandler).mockResolvedValue(handlerRes); + + const req = new NextRequest(`http://localhost/api/accounts/${ACCOUNT_ID}/subscription`, { + headers: { "x-api-key": "test-key" }, + }); + const params = Promise.resolve({ id: ACCOUNT_ID }); + const res = await GET(req, { params }); + + expect(getAccountSubscriptionHandler).toHaveBeenCalledTimes(1); + expect(getAccountSubscriptionHandler).toHaveBeenCalledWith(req, params); + expect(res).toBe(handlerRes); + await expect(res.json()).resolves.toEqual({ + isPro: true, + status: "active", + plan: "pro", + source: "account", + }); + }); +}); diff --git a/app/api/accounts/[id]/subscription/__tests__/routeTestMocks.ts b/app/api/accounts/[id]/subscription/__tests__/routeTestMocks.ts new file mode 100644 index 000000000..b9d9a2994 --- /dev/null +++ b/app/api/accounts/[id]/subscription/__tests__/routeTestMocks.ts @@ -0,0 +1,9 @@ +import { vi } from "vitest"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/stripe/getAccountSubscriptionHandler", () => ({ + getAccountSubscriptionHandler: vi.fn(), +})); diff --git a/app/api/accounts/[id]/subscription/route.ts b/app/api/accounts/[id]/subscription/route.ts new file mode 100644 index 000000000..e7e06730a --- /dev/null +++ b/app/api/accounts/[id]/subscription/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getAccountSubscriptionHandler } from "@/lib/stripe/getAccountSubscriptionHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A 200 NextResponse carrying the CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/accounts/[id]/subscription + * + * Returns the subscription resource for an account, including coverage via organization + * membership. Requires authentication via `x-api-key` or `Authorization: Bearer`; the caller + * must be the account itself or have access via organization membership. + * + * @param request - Incoming request; auth is read from headers. + * @param context - Route context from Next.js. + * @param context.params - Promise resolving to `{ id }`, the account UUID from the URL path. + * @returns A 200 NextResponse with `{ isPro, status, plan, source }`, or 4xx with `{ error }`. + */ +export async function GET(request: NextRequest, context: { params: Promise<{ id: string }> }) { + return getAccountSubscriptionHandler(request, context.params); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/stripe/__tests__/buildSubscriptionResponse.test.ts b/lib/stripe/__tests__/buildSubscriptionResponse.test.ts new file mode 100644 index 000000000..83acd562c --- /dev/null +++ b/lib/stripe/__tests__/buildSubscriptionResponse.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import type Stripe from "stripe"; +import { buildSubscriptionResponse } from "@/lib/stripe/buildSubscriptionResponse"; + +const activeSub = (status: Stripe.Subscription.Status = "active") => + ({ status, canceled_at: null }) as unknown as Stripe.Subscription; + +describe("buildSubscriptionResponse", () => { + it("returns isPro:false / none / null / null when neither subscription is active", () => { + expect(buildSubscriptionResponse({ account: null, organization: null })).toEqual({ + isPro: false, + status: "none", + plan: null, + source: null, + }); + }); + + it("prefers the account subscription when active", () => { + expect( + buildSubscriptionResponse({ + account: activeSub("active"), + organization: activeSub("trialing"), + }), + ).toEqual({ + isPro: true, + status: "active", + plan: "pro", + source: "account", + }); + }); + + it("falls back to the organization subscription when only org is active", () => { + expect( + buildSubscriptionResponse({ + account: null, + organization: activeSub("trialing"), + }), + ).toEqual({ + isPro: true, + status: "trialing", + plan: "pro", + source: "organization", + }); + }); + + it("treats trialing-with-canceled_at as inactive", () => { + const canceledTrial = { + status: "trialing", + canceled_at: 1700000000, + } as unknown as Stripe.Subscription; + + expect(buildSubscriptionResponse({ account: canceledTrial, organization: null })).toEqual({ + isPro: false, + status: "none", + plan: null, + source: null, + }); + }); + + it("normalizes unsupported Stripe statuses to 'none' when somehow active", () => { + const weird = { status: "incomplete", canceled_at: null } as unknown as Stripe.Subscription; + expect(buildSubscriptionResponse({ account: weird, organization: null })).toEqual({ + isPro: false, + status: "none", + plan: null, + source: null, + }); + }); +}); diff --git a/lib/stripe/__tests__/getAccountSubscriptionHandler.test.ts b/lib/stripe/__tests__/getAccountSubscriptionHandler.test.ts new file mode 100644 index 000000000..e874548eb --- /dev/null +++ b/lib/stripe/__tests__/getAccountSubscriptionHandler.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getAccountSubscriptionHandler } from "@/lib/stripe/getAccountSubscriptionHandler"; + +import { validateAccountSubscriptionParams } from "@/lib/stripe/validateAccountSubscriptionParams"; +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; +import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/stripe/validateAccountSubscriptionParams", () => ({ + validateAccountSubscriptionParams: vi.fn(), +})); + +vi.mock("@/lib/stripe/getActiveSubscriptionDetails", () => ({ + getActiveSubscriptionDetails: vi.fn(), +})); + +vi.mock("@/lib/stripe/getOrgSubscription", () => ({ + getOrgSubscription: vi.fn(), +})); + +const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; + +const buildRequest = () => new NextRequest(`http://localhost/api/accounts/${ACCOUNT}/subscription`); + +const buildParams = () => Promise.resolve({ id: ACCOUNT }); + +describe("getAccountSubscriptionHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("forwards validation/auth errors as { error } with original status", async () => { + const denial = NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAccountSubscriptionParams).mockResolvedValue(denial); + + const res = await getAccountSubscriptionHandler(buildRequest(), buildParams()); + expect(res.status).toBe(401); + await expect(res.json()).resolves.toEqual({ error: "Unauthorized" }); + expect(getActiveSubscriptionDetails).not.toHaveBeenCalled(); + }); + + it("returns the resource shape for an active account subscription", async () => { + vi.mocked(validateAccountSubscriptionParams).mockResolvedValue(ACCOUNT); + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue({ + id: "sub_1", + status: "active", + canceled_at: null, + } as never); + vi.mocked(getOrgSubscription).mockResolvedValue(null); + + const res = await getAccountSubscriptionHandler(buildRequest(), buildParams()); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + isPro: true, + status: "active", + plan: "pro", + source: "account", + }); + }); + + it("returns source: organization when only the org subscription is active", async () => { + vi.mocked(validateAccountSubscriptionParams).mockResolvedValue(ACCOUNT); + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); + vi.mocked(getOrgSubscription).mockResolvedValue({ + id: "sub_org", + status: "trialing", + canceled_at: null, + } as never); + + const res = await getAccountSubscriptionHandler(buildRequest(), buildParams()); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + isPro: true, + status: "trialing", + plan: "pro", + source: "organization", + }); + }); + + it("returns isPro:false / none / null when neither subscription is active", async () => { + vi.mocked(validateAccountSubscriptionParams).mockResolvedValue(ACCOUNT); + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); + vi.mocked(getOrgSubscription).mockResolvedValue(null); + + const res = await getAccountSubscriptionHandler(buildRequest(), buildParams()); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + isPro: false, + status: "none", + plan: null, + source: null, + }); + }); +}); diff --git a/lib/stripe/__tests__/getActiveSubscriptions.test.ts b/lib/stripe/__tests__/getActiveSubscriptions.test.ts new file mode 100644 index 000000000..a352d8ee8 --- /dev/null +++ b/lib/stripe/__tests__/getActiveSubscriptions.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; +import stripeClient from "@/lib/stripe/client"; +import { getActiveSubscriptionsTestHelpers } from "./getActiveSubscriptionsTestHelpers"; + +vi.mock("@/lib/stripe/client", () => ({ + default: { subscriptions: { list: vi.fn() } }, +})); + +const { + testAccountId: ACC, + subscription: sub, + subscriptionListPage: apiList, +} = getActiveSubscriptionsTestHelpers(); +const list = () => vi.mocked(stripeClient.subscriptions.list); + +describe("getActiveSubscriptions", () => { + beforeEach(() => vi.clearAllMocks()); + + it("walks pages until a batch matches accountId", async () => { + list() + .mockResolvedValueOnce(apiList([sub("sub_x", "other")], true)) + .mockResolvedValueOnce(apiList([sub("sub_1", ACC)], true)); + const result = await getActiveSubscriptions(ACC); + expect(list()).toHaveBeenCalledTimes(2); + expect(result.map(s => s.id)).toEqual(["sub_1"]); + expect(list().mock.calls[1][0]).toMatchObject({ starting_after: "sub_x", limit: 100 }); + }); + + it("finds a match on a later page (no artificial page limit)", async () => { + for (let i = 0; i < 52; i++) + list().mockResolvedValueOnce(apiList([sub(`sub_${i}`, "other")], true)); + list().mockResolvedValueOnce(apiList([sub("sub_late", ACC)], false)); + const result = await getActiveSubscriptions(ACC); + expect(result.map(s => s.id)).toEqual(["sub_late"]); + expect(list()).toHaveBeenCalledTimes(53); + }); + + it("stops after the first page that includes a match", async () => { + list().mockResolvedValueOnce( + apiList([sub("sub_x", "other"), sub("sub_1", ACC), sub("sub_2", ACC)], true), + ); + const result = await getActiveSubscriptions(ACC); + expect(list()).toHaveBeenCalledTimes(1); + expect(result.map(s => s.id)).toEqual(["sub_1", "sub_2"]); + }); + + it("returns [] when nothing matches after Stripe exhausts pages", async () => { + for (let i = 0; i < 3; i++) { + list().mockResolvedValueOnce(apiList([sub(`sub_${i}`, "other")], i < 2)); + } + const result = await getActiveSubscriptions(ACC); + expect(result).toEqual([]); + expect(list()).toHaveBeenCalledTimes(3); + }); + + it("breaks if pagination cursor does not advance", async () => { + const s = sub("sub_stuck", "other"); + list() + .mockResolvedValueOnce(apiList([s], true)) + .mockResolvedValueOnce(apiList([s], true)); + const result = await getActiveSubscriptions(ACC); + expect(result).toEqual([]); + expect(list()).toHaveBeenCalledTimes(2); + }); + + it("returns [] when Stripe throws", async () => { + list().mockRejectedValue(new Error("stripe error")); + await expect(getActiveSubscriptions(ACC)).resolves.toEqual([]); + }); +}); diff --git a/lib/stripe/__tests__/getActiveSubscriptionsTestHelpers.ts b/lib/stripe/__tests__/getActiveSubscriptionsTestHelpers.ts new file mode 100644 index 000000000..08e11bb89 --- /dev/null +++ b/lib/stripe/__tests__/getActiveSubscriptionsTestHelpers.ts @@ -0,0 +1,18 @@ +import type Stripe from "stripe"; + +export function getActiveSubscriptionsTestHelpers() { + const testAccountId = "acc-a"; + + function subscription(id: string, accountId: string): Stripe.Subscription { + return { id, metadata: { accountId } } as Stripe.Subscription; + } + + function subscriptionListPage( + data: Stripe.Subscription[], + hasMore: boolean, + ): Stripe.Response> { + return { data, has_more: hasMore } as Stripe.Response>; + } + + return { testAccountId, subscription, subscriptionListPage }; +} diff --git a/lib/stripe/__tests__/getOrgSubscription.test.ts b/lib/stripe/__tests__/getOrgSubscription.test.ts new file mode 100644 index 000000000..791c4a57d --- /dev/null +++ b/lib/stripe/__tests__/getOrgSubscription.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type Stripe from "stripe"; +import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; + +vi.mock("@/lib/supabase/account_organization_ids/getAccountOrganizations", () => ({ + getAccountOrganizations: vi.fn(), +})); + +vi.mock("@/lib/stripe/getActiveSubscriptionDetails", () => ({ + getActiveSubscriptionDetails: vi.fn(), +})); + +describe("getOrgSubscription", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null when accountId is empty", async () => { + await expect(getOrgSubscription("")).resolves.toBeNull(); + expect(getAccountOrganizations).not.toHaveBeenCalled(); + }); + + it("returns first org subscription and avoids extra Stripe work after a match", async () => { + vi.mocked(getAccountOrganizations).mockResolvedValue([ + { organization_id: "org-a" }, + { organization_id: "org-b" }, + { organization_id: "org-c" }, + ] as Awaited>); + + const sub = { id: "sub_from_a" } as Stripe.Subscription; + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(sub); + + await expect(getOrgSubscription("acc-1")).resolves.toBe(sub); + + expect(getActiveSubscriptionDetails).toHaveBeenCalledTimes(1); + expect(getActiveSubscriptionDetails).toHaveBeenCalledWith("org-a"); + }); + + it("walks orgs in order until a subscription is found", async () => { + vi.mocked(getAccountOrganizations).mockResolvedValue([ + { organization_id: "org-a" }, + { organization_id: "org-b" }, + ] as Awaited>); + + const sub = { id: "sub_from_b" } as Stripe.Subscription; + vi.mocked(getActiveSubscriptionDetails).mockResolvedValueOnce(null).mockResolvedValueOnce(sub); + + await expect(getOrgSubscription("acc-1")).resolves.toBe(sub); + + expect(getActiveSubscriptionDetails).toHaveBeenCalledTimes(2); + expect(getActiveSubscriptionDetails).toHaveBeenNthCalledWith(1, "org-a"); + expect(getActiveSubscriptionDetails).toHaveBeenNthCalledWith(2, "org-b"); + }); + + it("returns null when no org has a subscription", async () => { + vi.mocked(getAccountOrganizations).mockResolvedValue([{ organization_id: "org-a" }] as Awaited< + ReturnType + >); + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); + + await expect(getOrgSubscription("acc-1")).resolves.toBeNull(); + expect(getActiveSubscriptionDetails).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/stripe/__tests__/isActiveSubscription.test.ts b/lib/stripe/__tests__/isActiveSubscription.test.ts new file mode 100644 index 000000000..ab99f56d7 --- /dev/null +++ b/lib/stripe/__tests__/isActiveSubscription.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import type Stripe from "stripe"; +import isActiveSubscription from "@/lib/stripe/isActiveSubscription"; + +function sub( + partial: Pick & Partial, +): Stripe.Subscription { + return partial as Stripe.Subscription; +} + +describe("isActiveSubscription", () => { + it("returns false for null/undefined", () => { + expect(isActiveSubscription(null)).toBe(false); + expect(isActiveSubscription(undefined)).toBe(false); + }); + + it("returns true for status active", () => { + expect(isActiveSubscription(sub({ status: "active" }))).toBe(true); + }); + + it("returns true for trialing without canceled_at", () => { + expect(isActiveSubscription(sub({ status: "trialing", canceled_at: null }))).toBe(true); + }); + + it("returns false for trialing with canceled_at (canceled trial)", () => { + expect(isActiveSubscription(sub({ status: "trialing", canceled_at: 1234567890 }))).toBe(false); + }); + + it("returns false for canceled and other non-entitled statuses", () => { + expect(isActiveSubscription(sub({ status: "canceled" }))).toBe(false); + expect(isActiveSubscription(sub({ status: "unpaid" }))).toBe(false); + expect(isActiveSubscription(sub({ status: "past_due" }))).toBe(false); + expect(isActiveSubscription(sub({ status: "incomplete" }))).toBe(false); + expect(isActiveSubscription(sub({ status: "incomplete_expired" }))).toBe(false); + }); +}); diff --git a/lib/stripe/__tests__/toStatus.test.ts b/lib/stripe/__tests__/toStatus.test.ts new file mode 100644 index 000000000..b291db150 --- /dev/null +++ b/lib/stripe/__tests__/toStatus.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import type Stripe from "stripe"; +import { toStatus } from "@/lib/stripe/toStatus"; + +describe("toStatus", () => { + it.each([["active"], ["trialing"], ["canceled"], ["past_due"]] as const)( + "passes through supported Stripe status %s", + status => { + expect(toStatus(status as Stripe.Subscription.Status)).toBe(status); + }, + ); + + it.each([["incomplete"], ["incomplete_expired"], ["unpaid"], ["paused"]] as const)( + "normalizes unsupported status %s to 'none'", + status => { + expect(toStatus(status as Stripe.Subscription.Status)).toBe("none"); + }, + ); +}); diff --git a/lib/stripe/__tests__/validateAccountSubscriptionParams.test.ts b/lib/stripe/__tests__/validateAccountSubscriptionParams.test.ts new file mode 100644 index 000000000..64dd0cf5d --- /dev/null +++ b/lib/stripe/__tests__/validateAccountSubscriptionParams.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateAccountSubscriptionParams } from "@/lib/stripe/validateAccountSubscriptionParams"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; + +const getRequest = () => + new NextRequest(`http://localhost/api/accounts/${ACCOUNT}/subscription`, { + headers: { "x-api-key": "test-key" }, + }); + +describe("validateAccountSubscriptionParams", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when id is not a valid UUID", async () => { + const res = await validateAccountSubscriptionParams(getRequest(), "not-a-uuid"); + expect(res).toBeInstanceOf(NextResponse); + const response = res as NextResponse; + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toMatch(/id must be a valid UUID/i); + expect(validateAuthContext).not.toHaveBeenCalled(); + }); + + it("forwards the auth response when authentication fails", async () => { + const denial = NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(denial); + + const req = getRequest(); + const res = await validateAccountSubscriptionParams(req, ACCOUNT); + expect(res).toBe(denial); + expect(validateAuthContext).toHaveBeenCalledWith(req, { accountId: ACCOUNT }); + }); + + it("returns the validated accountId when auth succeeds", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT, + orgId: null, + authToken: "tok", + }); + + const res = await validateAccountSubscriptionParams(getRequest(), ACCOUNT); + expect(res).toBe(ACCOUNT); + }); +}); diff --git a/lib/stripe/buildSubscriptionResponse.ts b/lib/stripe/buildSubscriptionResponse.ts new file mode 100644 index 000000000..12b99031d --- /dev/null +++ b/lib/stripe/buildSubscriptionResponse.ts @@ -0,0 +1,47 @@ +import type Stripe from "stripe"; +import isActiveSubscription from "@/lib/stripe/isActiveSubscription"; +import { toStatus, type SubscriptionStatus } from "@/lib/stripe/toStatus"; + +export type SubscriptionSource = "account" | "organization"; +export type { SubscriptionStatus }; + +export interface SubscriptionResponse { + isPro: boolean; + status: SubscriptionStatus; + plan: string | null; + source: SubscriptionSource | null; +} + +const inactive: SubscriptionResponse = { + isPro: false, + status: "none", + plan: null, + source: null, +}; + +/** + * Maps the account- and organization-level subscriptions into the documented response shape. + * Account subscription wins when both are active. + */ +export function buildSubscriptionResponse(args: { + account: Stripe.Subscription | null; + organization: Stripe.Subscription | null; +}): SubscriptionResponse { + if (isActiveSubscription(args.account) && args.account) { + return { + isPro: true, + status: toStatus(args.account.status), + plan: "pro", + source: "account", + }; + } + if (isActiveSubscription(args.organization) && args.organization) { + return { + isPro: true, + status: toStatus(args.organization.status), + plan: "pro", + source: "organization", + }; + } + return inactive; +} diff --git a/lib/stripe/getAccountSubscriptionHandler.ts b/lib/stripe/getAccountSubscriptionHandler.ts new file mode 100644 index 000000000..92e44d3e5 --- /dev/null +++ b/lib/stripe/getAccountSubscriptionHandler.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; +import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; +import { validateAccountSubscriptionParams } from "@/lib/stripe/validateAccountSubscriptionParams"; +import { buildSubscriptionResponse } from "@/lib/stripe/buildSubscriptionResponse"; +import { mapToSubscriptionSessionError } from "@/lib/stripe/mapToSubscriptionSessionError"; + +/** + * GET /api/accounts/[id]/subscription + * + * Returns the documented subscription resource for an account, including coverage + * via organization membership. Forwards auth/validation failures as `{ error }` bodies. + */ +export async function getAccountSubscriptionHandler( + request: NextRequest, + params: Promise<{ id: string }>, +): Promise { + try { + const { id } = await params; + const validated = await validateAccountSubscriptionParams(request, id); + if (validated instanceof NextResponse) { + return mapToSubscriptionSessionError(validated); + } + + const [account, organization] = await Promise.all([ + getActiveSubscriptionDetails(validated), + getOrgSubscription(validated), + ]); + + return NextResponse.json(buildSubscriptionResponse({ account, organization }), { + status: 200, + headers: getCorsHeaders(), + }); + } catch (error) { + console.error("[getAccountSubscriptionHandler]", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/stripe/getActiveSubscriptionDetails.ts b/lib/stripe/getActiveSubscriptionDetails.ts new file mode 100644 index 000000000..3604de673 --- /dev/null +++ b/lib/stripe/getActiveSubscriptionDetails.ts @@ -0,0 +1,11 @@ +import { getActiveSubscriptions } from "./getActiveSubscriptions"; + +export const getActiveSubscriptionDetails = async (accountId: string) => { + try { + const activeSubscriptions = await getActiveSubscriptions(accountId); + return activeSubscriptions.length > 0 ? activeSubscriptions[0] : null; + } catch (error) { + console.error("Error fetching subscription:", error); + return null; + } +}; diff --git a/lib/stripe/getActiveSubscriptions.ts b/lib/stripe/getActiveSubscriptions.ts new file mode 100644 index 000000000..be0c603f8 --- /dev/null +++ b/lib/stripe/getActiveSubscriptions.ts @@ -0,0 +1,49 @@ +import stripeClient from "@/lib/stripe/client"; +import Stripe from "stripe"; + +const PAGE_LIMIT = 100; + +/** + * Lists active subscriptions whose `metadata.accountId` matches. + * Stops after the first page that yields a match (callers only need one). + * Paginates until Stripe reports no more pages (no fixed page cap — avoids missing matches deep in the list). + */ +export const getActiveSubscriptions = async (accountId: string) => { + try { + const now = Math.floor(Date.now() / 1000); + const activeSubscriptions: Stripe.Subscription[] = []; + let startingAfter: string | undefined; + let hasMore = true; + + while (hasMore) { + const listParams: Stripe.SubscriptionListParams = { + limit: PAGE_LIMIT, + current_period_end: { gt: now }, + }; + if (startingAfter) { + listParams.starting_after = startingAfter; + } + + const page = await stripeClient.subscriptions.list(listParams); + + activeSubscriptions.push( + ...page.data.filter((s: Stripe.Subscription) => s.metadata?.accountId === accountId), + ); + + if (activeSubscriptions.length > 0) { + break; + } + + hasMore = page.has_more; + const lastId = page.data.at(-1)?.id; + if (!lastId) break; + if (startingAfter !== undefined && lastId === startingAfter) break; + startingAfter = lastId; + } + + return activeSubscriptions; + } catch (error) { + console.error("Error fetching subscriptions:", error); + return []; + } +}; diff --git a/lib/stripe/getOrgSubscription.ts b/lib/stripe/getOrgSubscription.ts new file mode 100644 index 000000000..f8944ca73 --- /dev/null +++ b/lib/stripe/getOrgSubscription.ts @@ -0,0 +1,24 @@ +import { getActiveSubscriptionDetails } from "./getActiveSubscriptionDetails"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; +import Stripe from "stripe"; + +/** + * First active Stripe subscription for any organization linked to the account, if any. + */ +export async function getOrgSubscription(accountId: string): Promise { + if (!accountId) return null; + + const accountOrgs = await getAccountOrganizations({ accountId }); + if (accountOrgs.length === 0) return null; + + const orgIds = accountOrgs + .map(org => org.organization_id) + .filter((id): id is string => id !== null); + + for (const orgId of orgIds) { + const sub = await getActiveSubscriptionDetails(orgId); + if (sub) return sub; + } + + return null; +} diff --git a/lib/stripe/isActiveSubscription.ts b/lib/stripe/isActiveSubscription.ts new file mode 100644 index 000000000..8ceaefd48 --- /dev/null +++ b/lib/stripe/isActiveSubscription.ts @@ -0,0 +1,12 @@ +import Stripe from "stripe"; + +/** Stripe statuses that may grant paid access; others (e.g. canceled, unpaid) are not active. */ +const isActiveSubscription = (subscription?: Stripe.Subscription | null) => { + if (!subscription) return false; + const { status, canceled_at: canceledAt } = subscription; + if (status === "active") return true; + if (status === "trialing") return !canceledAt; + return false; +}; + +export default isActiveSubscription; diff --git a/lib/stripe/toStatus.ts b/lib/stripe/toStatus.ts new file mode 100644 index 000000000..7e67890e6 --- /dev/null +++ b/lib/stripe/toStatus.ts @@ -0,0 +1,20 @@ +import type Stripe from "stripe"; + +export type SubscriptionStatus = "active" | "trialing" | "canceled" | "past_due" | "none"; + +const SUPPORTED: ReadonlySet = new Set([ + "active", + "trialing", + "canceled", + "past_due", +]); + +/** + * Maps a Stripe subscription status to the documented `SubscriptionStatus` enum. + * Unsupported Stripe statuses (e.g. `incomplete`, `unpaid`) collapse to `"none"`. + */ +export function toStatus(stripeStatus: Stripe.Subscription.Status): SubscriptionStatus { + return SUPPORTED.has(stripeStatus as SubscriptionStatus) + ? (stripeStatus as SubscriptionStatus) + : "none"; +} diff --git a/lib/stripe/validateAccountSubscriptionParams.ts b/lib/stripe/validateAccountSubscriptionParams.ts new file mode 100644 index 000000000..a03ff23d2 --- /dev/null +++ b/lib/stripe/validateAccountSubscriptionParams.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +const idSchema = z.string().uuid("id must be a valid UUID"); + +/** + * Validates the `[id]` path param and confirms the caller may access that account. + * + * @returns The validated account UUID, or a NextResponse with the error to forward. + */ +export async function validateAccountSubscriptionParams( + request: NextRequest, + id: string, +): Promise { + const parsed = idSchema.safeParse(id); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0].message }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const auth = await validateAuthContext(request, { accountId: parsed.data }); + if (auth instanceof NextResponse) { + return auth; + } + + return parsed.data; +}