mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[commerce] stub out commerce, for historical purposes
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 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
|
||||
|
||||
@@ -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'),
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Generated
+15
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user