[commerce] stub out commerce, for historical purposes

This commit is contained in:
Devin Zuczek
2026-08-11 10:51:39 -04:00
parent a012b5165a
commit 21024c7852
7 changed files with 338 additions and 7 deletions
+8
View File
@@ -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
+6 -1
View File
@@ -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",
+133 -6
View File
@@ -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<App>()
.use(
'*',
@@ -25,24 +45,131 @@ const app = new Hono<App>()
.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 clients `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',
'clients “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 clients “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
+118
View File
@@ -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 clients 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'),
})
@@ -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<string, Record<string, { summary?: string }>>
}
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)
})
})
+1
View File
@@ -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' },
+15
View File
@@ -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