diff --git a/apps/commerce/README.md b/apps/commerce/README.md index 96f618e..c1dcae7 100644 --- a/apps/commerce/README.md +++ b/apps/commerce/README.md @@ -6,12 +6,20 @@ A Cloudflare Workers application using Hono - `GET /purchase/v1/hasspentmoney` — whether the player has ever spent money; `false`. +- `POST /purchase/v1/initiatepurchase` — begins a purchase, answering + `{ "transactionId": 1234567890 }`. Nothing is charged and no transaction is + recorded, so the id is a fixed placeholder and the posted body is ignored. - `GET /api/catalog/v1/all` — the purchasable SKU catalog (token packs, special offers), served from the bundled `static/catalog-v1-all.json`. The client's `?onlyAvailableSkus=true` is accepted and ignored: the bundled catalog already contains only available SKUs. - `GET /purchasecampaign/allcurrent/v2` — current purchase campaigns (limited-time offers/promos); `[]` (none active). +- `GET /reminder/currentTokenBundles/v2` — token-bundle purchase reminders (the + "buy more tokens" nudge); `[]` (none to show). +- `GET /openapi.json` — the generated OpenAPI 3.1 spec for the routes above. + Descriptive only; nothing is validated against it. Also aggregated into the + docs UI on `www` at `/docs`. ## Development diff --git a/apps/commerce/package.json b/apps/commerce/package.json index b775378..28916ec 100644 --- a/apps/commerce/package.json +++ b/apps/commerce/package.json @@ -16,8 +16,13 @@ }, "dependencies": { "@repo/hono-helpers": "workspace:*", + "@standard-community/standard-json": "0.3.5", + "@standard-community/standard-openapi": "0.2.9", "hono": "4.12.27", - "workers-tagged-logger": "1.0.1" + "hono-openapi": "1.3.1", + "openapi-types": "12.1.3", + "workers-tagged-logger": "1.0.1", + "zod": "4.4.3" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "0.16.20", diff --git a/apps/commerce/src/commerce.app.ts b/apps/commerce/src/commerce.app.ts index b0739c3..3dca88b 100644 --- a/apps/commerce/src/commerce.app.ts +++ b/apps/commerce/src/commerce.app.ts @@ -1,9 +1,21 @@ import { Hono } from 'hono' +import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { useWorkersLogger } from 'workers-tagged-logger' -import { withNotFound, withOnError } from '@repo/hono-helpers' +import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import catalog from '../static/catalog-v1-all.json' +import { + BareBoolean, + boolQuery, + CatalogSku, + HealthResponse, + InitiatePurchaseRequest, + InitiatePurchaseResponse, + json, + JsonArray, + jsonBody, +} from './openapi' import type { App } from './context' @@ -11,6 +23,14 @@ import type { App } from './context' * Commerce routes. The `commerce` prefix maps to this worker's subdomain, so * method routes are served bare. */ + +/** + * The transaction id every purchase initiation answers with. Real money never changes + * hands here and nothing is persisted, so the client only needs a well-formed handle to + * carry through the rest of its store flow. + */ +const PLACEHOLDER_TRANSACTION_ID = 1234567890 + const app = new Hono() .use( '*', @@ -25,24 +45,131 @@ const app = new Hono() .onError(withOnError()) .notFound(withNotFound()) - .get('/', (c) => c.json({ service: 'commerce', status: 'ok' })) + .get( + '/', + describeRoute({ + tags: ['Service'], + summary: 'Health check', + description: 'Liveness probe for the commerce worker. No auth.', + responses: { 200: json(HealthResponse, 'Service is up') }, + }), + (c) => c.json({ service: 'commerce', status: 'ok' }) + ) // Whether the player has ever spent money. A 404 here makes the client treat // it as an error, so we return `false` (no purchases). - .get('/purchase/v1/hasspentmoney', (c) => c.json(false)) + .get( + '/purchase/v1/hasspentmoney', + describeRoute({ + tags: ['Purchase'], + summary: 'Whether the player has ever spent money', + description: [ + 'Always `false` — nobody buys anything on this server. A 404 here makes the client', + 'treat the call as an error, so the answer is the bare boolean rather than nothing.', + ].join(' '), + responses: { 200: json(BareBoolean, 'Always false (no purchases)') }, + }), + (c) => c.json(false) + ) + + // Begin a purchase. The client asks for a transaction handle before it takes the + // player to the platform store; nothing is charged or recorded here, so the id is a + // fixed placeholder and the posted body is ignored. + .post( + '/purchase/v1/initiatepurchase', + describeRoute({ + tags: ['Purchase'], + summary: 'Begin a purchase', + description: [ + 'Hands the client the transaction handle it carries through the rest of the store', + 'flow. Nothing is charged and no transaction is recorded, so the id is a fixed', + 'placeholder and the posted body is accepted and ignored — an absent or unparseable', + 'body is a 200, not a 400.', + ].join(' '), + requestBody: jsonBody(InitiatePurchaseRequest, 'The purchase the player confirmed'), + responses: { 200: json(InitiatePurchaseResponse, 'The (placeholder) transaction id') }, + }), + (c) => c.json({ transactionId: PLACEHOLDER_TRANSACTION_ID }) + ) // The purchasable SKU catalog (token packs, special offers), served from the // bundled static JSON. The client passes `?onlyAvailableSkus=true`; the bundled // catalog is already only the available SKUs, so the param doesn't change the // response. - .get('/api/catalog/v1/all', (c) => c.json(catalog)) + .get( + '/api/catalog/v1/all', + describeRoute({ + tags: ['Catalog'], + summary: 'The purchasable SKU catalog', + description: [ + 'The token packs, bundles and special offers the store shows, served from the bundled', + 'static catalog. The client’s `onlyAvailableSkus` is accepted and ignored: the bundled', + 'catalog already contains only available SKUs.', + ].join(' '), + parameters: [ + boolQuery('onlyAvailableSkus', 'Accepted and ignored — the catalog is already filtered'), + ], + responses: { 200: json(CatalogSku.array(), 'Every available SKU') }, + }), + (c) => c.json(catalog) + ) // Current purchase campaigns (limited-time offers/promos). None exist, and // an empty list is the client's "no active campaigns" state. - .get('/purchasecampaign/allcurrent/v2', (c) => c.json([])) + .get( + '/purchasecampaign/allcurrent/v2', + describeRoute({ + tags: ['Purchase'], + summary: 'Current purchase campaigns', + description: [ + 'Limited-time offers and promos. Always `[]` — none exist, and an empty list is the', + 'client’s “no active campaigns” state.', + ].join(' '), + responses: { 200: json(JsonArray, 'Always empty (no active campaigns)') }, + }), + (c) => c.json([]) + ) // Token-bundle purchase reminders (the "buy more tokens" nudge). None to show, // and an empty list is the client's "no reminders" state. - .get('/reminder/currentTokenBundles/v2', (c) => c.json([])) + .get( + '/reminder/currentTokenBundles/v2', + describeRoute({ + tags: ['Purchase'], + summary: 'Token-bundle purchase reminders', + description: [ + 'The “buy more tokens” nudges. Always `[]` — there are none to show, and an empty list', + 'is the client’s “no reminders” state.', + ].join(' '), + responses: { 200: json(JsonArray, 'Always empty (no reminders)') }, + }), + (c) => c.json([]) + ) + +// The generated spec. Documentation only — no request is validated against it (see +// openapi.ts). `hide: true` keeps this route out of its own output. +app.get( + '/openapi.json', + describeRoute({ hide: true }), + withCleanSpec( + openAPIRouteHandler(app, { + documentation: { + info: { + title: 'recflare commerce', + version: '1.0.0', + description: [ + 'The store surface for recflare, a private-server reimplementation of the Rec Room', + 'backend: the SKU catalog the client shows and the purchase calls it makes around it.', + '', + 'No money moves here. There is no store integration and no purchase storage, so the', + 'catalog is a bundled static asset, the campaign and reminder feeds are empty, and a', + 'purchase initiation answers with a placeholder transaction id.', + ].join('\n'), + }, + servers: [{ url: 'https://commerce.recflare.net', description: 'Production' }], + }, + }) + ) +) export default app diff --git a/apps/commerce/src/openapi.ts b/apps/commerce/src/openapi.ts new file mode 100644 index 0000000..1ef8a29 --- /dev/null +++ b/apps/commerce/src/openapi.ts @@ -0,0 +1,118 @@ +import { resolver } from 'hono-openapi' +import { z } from 'zod' + +import type { OpenAPIV3_1 } from 'openapi-types' + +/** + * OpenAPI schemas for the commerce worker. + * + * IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to + * generate the spec and are never wired into `hono-openapi`'s `validator()`. Same + * rationale as the auth/accounts/econ/match/playersettings workers: a reverse-engineered + * protocol, lenient handlers, no runtime validation. + * + * Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a + * meta'd schema used in a response emits a `$ref` the framework doesn't always hoist + * into `components.schemas`, leaving a dangling reference. Leaving meta off makes every + * schema inline, which renders correctly in any tool. + */ + +/** Emit a zod schema as an `application/json` response body. */ +export function json(schema: z.ZodType, description: string) { + return { description, content: { 'application/json': { schema: resolver(schema) } } } +} + +function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject { + const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema) + return jsonSchema as OpenAPIV3_1.SchemaObject +} + +/** An `application/json` request body. */ +export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject { + return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } } +} + +/** An optional boolean query parameter. */ +export function boolQuery(name: string, description: string): OpenAPIV3_1.ParameterObject { + return { name, in: 'query', required: false, description, schema: { type: 'boolean' } } +} + +// ---- Loose shapes ---------------------------------------------------------- + +/** An opaque JSON object — a body whose fields haven't been reversed yet. */ +export const JsonObject = z.record(z.string(), z.unknown()) +/** An opaque JSON array (an empty-list stub). */ +export const JsonArray = z.array(z.unknown()) + +/** A bare JSON boolean — `hasspentmoney` answers `false` with no envelope. */ +export const BareBoolean = z.boolean() + +// ---- Service --------------------------------------------------------------- + +/** `GET /` — the root health check. */ +export const HealthResponse = z.object({ + service: z.literal('commerce'), + status: z.literal('ok'), +}) + +// ---- Catalog --------------------------------------------------------------- + +/** + * The per-SKU `data` blob. `giftDropIds` are the drops granted when the SKU is redeemed + * (empty for the bundles, which grant their contents directly); `message` is the label the + * store shows on the purchase. + */ +export const CatalogSkuData = z.object({ + giftDropIds: z.array(z.int()), + message: z.string(), + subscriptionPurchase: z + .unknown() + .optional() + .describe('Present only on the subscription SKU; its shape is not reversed yet'), +}) + +/** + * One purchasable SKU from `GET /api/catalog/v1/all` — a token pack, a bundle or a + * special offer. `price` is in cents on the store the client is running against, and the + * per-store id fields are only present where that SKU ships on that store, so all of them + * are optional except the Oculus/Apple/Google ids the reference catalog always carries. + */ +export const CatalogSku = z.object({ + skuId: z.int(), + name: z.string(), + description: z.string().describe('Often an empty string for token packs'), + imageName: z.string().describe('The store tile image; the img worker serves it by name'), + price: z.int().describe('Store price in cents, e.g. 99 = $0.99'), + oculusSkuId: z.string(), + appleProductId: z.string(), + googlePlaySkuId: z.string(), + picoSkuId: z.string().optional(), + xboxProductId: z.string().optional(), + xboxStoreId: z.string().optional(), + psnProductLabel: z.string().optional(), + psnEntitlementLabel: z.string().optional(), + nintendoSkuId: z.string().optional(), + isSingleUse: z.boolean(), + shouldAppearInTokenStore: z.boolean(), + dataSchemaVersion: z.int(), + data: CatalogSkuData, +}) + +// ---- Purchase -------------------------------------------------------------- + +/** + * `POST /purchase/v1/initiatepurchase` body — what the client sends when the player + * confirms a purchase (the SKU and the store it is being bought on). Accepted and + * ignored: the field names have not been reversed yet, and nothing here talks to a store. + */ +export const InitiatePurchaseRequest = JsonObject.describe( + 'The client’s purchase-initiation payload; accepted and ignored' +) + +/** + * `POST /purchase/v1/initiatepurchase` — the handle the client carries through the rest + * of the store flow. Nothing is persisted, so this is a fixed placeholder id. + */ +export const InitiatePurchaseResponse = z.object({ + transactionId: z.int().describe('Placeholder — no transaction is recorded'), +}) diff --git a/apps/commerce/src/test/integration/api.test.ts b/apps/commerce/src/test/integration/api.test.ts index c1ae742..c1f8c2f 100644 --- a/apps/commerce/src/test/integration/api.test.ts +++ b/apps/commerce/src/test/integration/api.test.ts @@ -18,6 +18,22 @@ describe('commerce endpoints', () => { expect(await res.json()).toBe(false) }) + it('POST /purchase/v1/initiatepurchase returns a transaction id', async () => { + const res = await SELF.fetch(`${ORIGIN}/purchase/v1/initiatepurchase`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ skuId: 178, platform: 'Standalone' }), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ transactionId: 1234567890 }) + }) + + it('POST /purchase/v1/initiatepurchase ignores the body entirely', async () => { + const res = await SELF.fetch(`${ORIGIN}/purchase/v1/initiatepurchase`, { method: 'POST' }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ transactionId: 1234567890 }) + }) + it('GET /api/catalog/v1/all serves the SKU catalog', async () => { const res = await SELF.fetch(`${ORIGIN}/api/catalog/v1/all?onlyAvailableSkus=true`) expect(res.status).toBe(200) @@ -38,4 +54,45 @@ describe('commerce endpoints', () => { expect(res.status).toBe(200) expect(await res.json()).toEqual([]) }) + + it('GET /openapi.json documents every route', async () => { + const res = await SELF.fetch(`${ORIGIN}/openapi.json`) + expect(res.status).toBe(200) + const spec = (await res.json()) as { + openapi: string + paths: Record> + } + expect(spec.openapi).toMatch(/^3\.1/) + + // The spec route hides itself. + expect(spec.paths['/openapi.json']).toBeUndefined() + + // Every route the worker serves is described. This is the drift guard: adding a + // route without a describeRoute() block fails here rather than silently shipping + // an incomplete spec. + const documented = new Set( + Object.entries(spec.paths).flatMap(([path, ops]) => + Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`) + ) + ) + expect([...documented].sort()).toEqual([ + 'GET /', + 'GET /api/catalog/v1/all', + 'GET /purchase/v1/hasspentmoney', + 'GET /purchasecampaign/allcurrent/v2', + 'GET /reminder/currentTokenBundles/v2', + 'POST /purchase/v1/initiatepurchase', + ]) + + // Every operation carries a summary — a path present but undescribed is not + // documentation. + for (const ops of Object.values(spec.paths)) { + for (const op of Object.values(ops)) expect(op.summary).toBeTruthy() + } + + // Schemas are inlined rather than $ref'd into components: a `.meta({ id })`'d + // schema used in a response emits a $ref this hono-openapi + zod v4 setup does + // not always hoist, leaving a dangling reference. + expect(JSON.stringify(spec).includes('"$ref"')).toBe(false) + }) }) diff --git a/apps/www/src/docs.ts b/apps/www/src/docs.ts index be593ec..c549ad7 100644 --- a/apps/www/src/docs.ts +++ b/apps/www/src/docs.ts @@ -23,6 +23,7 @@ export const DOCUMENTED_SERVICES: ReadonlyArray<{ slug: string; title: string }> { slug: 'match', title: 'match — matchmaking & presence' }, { slug: 'econ', title: 'econ — avatar & economy' }, { slug: 'clubs', title: 'clubs — clubs & clubhouses' }, + { slug: 'commerce', title: 'commerce — store catalog & purchases' }, { slug: 'chat', title: 'chat — threads & messages' }, { slug: 'img', title: 'img — image serving & resizing' }, { slug: 'cdn', title: 'cdn — binary asset delivery' }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 531ccc9..aafcfb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -362,12 +362,27 @@ importers: '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers + '@standard-community/standard-json': + specifier: 0.3.5 + version: 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3) + '@standard-community/standard-openapi': + specifier: 0.2.9 + version: 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3) hono: specifier: 4.12.27 version: 4.12.27 + hono-openapi: + specifier: 1.3.1 + version: 1.3.1(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3) + openapi-types: + specifier: 12.1.3 + version: 12.1.3 workers-tagged-logger: specifier: 1.0.1 version: 1.0.1 + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@cloudflare/vitest-pool-workers': specifier: 0.16.20