mcp: fake-upstream — credits and subscription routes

Credits ported from infra/fake-storyteller-web (seeded 500 monthly +
120 banked); the subscription route written from the spec, which that
branch does not fake. Both are session-only, as upstream: anonymous
callers get the common 401 envelope; unknown namespaces 400.

Tests (5): balances and subscription in the spec shape (validated),
401 without a session, 400 on an unknown namespace.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
This commit is contained in:
Tanish Manku committed 2026-08-24 21:12:38 -07:00
1 parent 3ade6330d2
commit 0ce7dc22b3
4 files changed
+151

No files matched your search

+2
View File
@@ -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;
}
+68
View File
@@ -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<string, unknown> {
return { success: false, error_code: 400, error_code_str: "BadInput", message };
}
+16
View File
@@ -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 {
+65
View File
@@ -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);
});
});