mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-31 07:27:51 +00:00
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:
1 parent
3ade6330d2
commit
0ce7dc22b3
4 files changed
+151
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user