diff --git a/mcp/fake-upstream/src/index.ts b/mcp/fake-upstream/src/index.ts index 23c5f22c68..be63bddf22 100644 --- a/mcp/fake-upstream/src/index.ts +++ b/mcp/fake-upstream/src/index.ts @@ -1,5 +1,6 @@ import { Hono } from "hono"; +import { billingRoutes } from "./routes/billing"; import { sessionRoutes } from "./routes/session"; import { createStore, type FakeStore } from "./state"; @@ -16,6 +17,7 @@ export function createFakeUpstream(store: FakeStore = createStore()): Hono { app.get("/_status", (c) => c.json({ success: true, fake: true })); app.route("/", sessionRoutes(store)); + app.route("/", billingRoutes(store)); return app; } diff --git a/mcp/fake-upstream/src/routes/billing.ts b/mcp/fake-upstream/src/routes/billing.ts new file mode 100644 index 0000000000..8bc865743d --- /dev/null +++ b/mcp/fake-upstream/src/routes/billing.ts @@ -0,0 +1,68 @@ +import { Hono } from "hono"; + +import { currentUser } from "../session"; +import type { FakeStore } from "../state"; + +/** + * Credits and subscription lookups — credits ported from `infra/fake-storyteller-web` + * (routes/billing.ts); the subscription route written from the spec, which that branch does + * not fake. Both are session-only upstream (cookie or `session` header), never API key. + */ + +const NAMESPACES = new Set(["artcraft", "fakeyou"]); + +export function billingRoutes(store: FakeStore): Hono { + const app = new Hono(); + + app.get("/v1/credits/namespace/:namespace", (c) => { + const namespace = c.req.param("namespace"); + if (!NAMESPACES.has(namespace)) { + return c.json(badInput(`unknown payments namespace: ${namespace}`), 400); + } + const user = currentUser(store, c.req.raw); + if (user === undefined) return c.json(NOT_AUTHORIZED, 401); + return c.json({ + success: true, + free_credits: 0, + monthly_credits: user.monthlyCredits, + banked_credits: user.bankedCredits, + sum_total_credits: user.monthlyCredits + user.bankedCredits, + }); + }); + + app.get("/v1/subscriptions/namespace/:namespace", (c) => { + const namespace = c.req.param("namespace"); + if (!NAMESPACES.has(namespace)) { + return c.json(badInput(`unknown payments namespace: ${namespace}`), 400); + } + const user = currentUser(store, c.req.raw); + if (user === undefined) return c.json(NOT_AUTHORIZED, 401); + const subscription = user.subscription; + return c.json({ + success: true, + active_subscription: + subscription === null + ? null + : { + subscription_token: subscription.subscriptionToken, + namespace, + product_slug: subscription.productSlug, + next_bill_at: subscription.nextBillAt, + subscription_end_at: null, + }, + }); + }); + + return app; +} + +const NOT_AUTHORIZED = { + success: false, + error_code: 401, + error_code_str: "NotAuthorized", + message: "not authorized", +}; + +function badInput(message: string): Record { + return { success: false, error_code: 400, error_code_str: "BadInput", message }; +} diff --git a/mcp/fake-upstream/src/state.ts b/mcp/fake-upstream/src/state.ts index 785790fce3..e6302fd135 100644 --- a/mcp/fake-upstream/src/state.ts +++ b/mcp/fake-upstream/src/state.ts @@ -4,12 +4,21 @@ * isolate restarts, which is the point. */ +export interface FakeSubscription { + readonly subscriptionToken: string; + readonly productSlug: string; + readonly nextBillAt: string; +} + export interface FakeUser { readonly userToken: string; readonly username: string; readonly displayName: string; readonly email: string; readonly password: string; + readonly monthlyCredits: number; + readonly bankedCredits: number; + readonly subscription: FakeSubscription | null; } export interface FakeStore { @@ -24,6 +33,13 @@ export const SEEDED_USER: FakeUser = { displayName: "Local Dev", email: "localdev1@example.test", password: "localdev1pass", + monthlyCredits: 500, + bankedCredits: 120, + subscription: { + subscriptionToken: "usub_localdev1", + productSlug: "artcraft_creator_monthly", + nextBillAt: "2026-09-24T00:00:00Z", + }, }; export function createStore(): FakeStore { diff --git a/mcp/fake-upstream/test/billing.test.ts b/mcp/fake-upstream/test/billing.test.ts new file mode 100644 index 0000000000..915cb5aa8d --- /dev/null +++ b/mcp/fake-upstream/test/billing.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { expectValid } from "../../test/helpers/contract"; +import { createFakeUpstream } from "../src/index"; +import { SEEDED_USER } from "../src/state"; + +async function signedIn() { + const app = createFakeUpstream(); + const login = await app.request("/v1/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + username_or_email: SEEDED_USER.username, + password: SEEDED_USER.password, + }), + }); + const { signed_session } = await login.json<{ signed_session: string }>(); + return { app, headers: { session: signed_session } }; +} + +describe("credits", () => { + it("returns the seeded balances in the spec shape", async () => { + const { app, headers } = await signedIn(); + const response = await app.request("/v1/credits/namespace/artcraft", { headers }); + expect(response.status).toBe(200); + const body = await response.json(); + expectValid("GetSessionCreditsResponse", body); + expect(body).toEqual({ + success: true, + free_credits: 0, + monthly_credits: 500, + banked_credits: 120, + sum_total_credits: 620, + }); + }); + + it("is session-only: anonymous callers get the common 401 envelope", async () => { + const response = await createFakeUpstream().request("/v1/credits/namespace/artcraft"); + expect(response.status).toBe(401); + expectValid("CommonWebError", await response.json()); + }); + + it("rejects an unknown namespace", async () => { + const { app, headers } = await signedIn(); + const response = await app.request("/v1/credits/namespace/nope", { headers }); + expect(response.status).toBe(400); + expectValid("CommonWebError", await response.json()); + }); +}); + +describe("subscription", () => { + it("returns the seeded subscription in the spec shape", async () => { + const { app, headers } = await signedIn(); + const response = await app.request("/v1/subscriptions/namespace/artcraft", { headers }); + expect(response.status).toBe(200); + const body = await response.json<{ active_subscription: { product_slug: string } | null }>(); + expectValid("GetSessionSubscriptionResponse", body); + expect(body.active_subscription?.product_slug).toBe("artcraft_creator_monthly"); + }); + + it("is session-only too", async () => { + const response = await createFakeUpstream().request("/v1/subscriptions/namespace/artcraft"); + expect(response.status).toBe(401); + }); +});