diff --git a/apps/api/migrations/0033_invention_price_cap.sql b/apps/api/migrations/0033_invention_price_cap.sql new file mode 100644 index 0000000..b005036 --- /dev/null +++ b/apps/api/migrations/0033_invention_price_cap.sql @@ -0,0 +1,11 @@ +-- Inventions are capped at 1000 tokens (`MAX_INVENTION_PRICE` in src/inventions-db.ts): the +-- three writes a creator has (`v3/publish`, `v4/publish`, `v1/updateprice`) now refuse a +-- price above it, and this lowers every existing row already over it to exactly the cap. +-- Drafts included — a draft's price is what its next publish would list it at. +-- +-- `ModifiedAt` is left alone, as `v1/updateprice` leaves it: a reprice is not an edit of +-- the invention. `price` is not a generated column here, so the JSON is the whole update. + +UPDATE invention +SET data = json_set(data, '$.Price', 1000) +WHERE json_extract(data, '$.Price') > 1000; diff --git a/apps/api/src/inventions-db.ts b/apps/api/src/inventions-db.ts index 4a099fc..f62cc6d 100644 --- a/apps/api/src/inventions-db.ts +++ b/apps/api/src/inventions-db.ts @@ -992,9 +992,19 @@ export async function publishInvention( return updated } +/** + * The most tokens an invention may sell for, inclusive. Enforced on every price a player + * can send — `v3/publish`, `v4/publish` and `v1/updateprice` — by refusing the write; the + * client has no ceiling of its own. The same number as the custom-shirt cap + * (`MAX_PLAYER_ITEM_PRICE`), kept separate because they are separate policies that happen + * to agree. migrations/0033_invention_price_cap.sql lowered the rows already over it. + */ +export const MAX_INVENTION_PRICE = 1000 + /** * Set an invention's price (`v1/updateprice`). Returns the updated invention, or - * null when there's no such row; the caller rejects negative prices. + * null when there's no such row; the caller rejects negative prices and ones over + * {@link MAX_INVENTION_PRICE}. */ export async function setInventionPrice( db: D1Database, diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 8e0db40..884e8dd 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -765,7 +765,10 @@ export const PublishInventionRequest = z.object({ .int() .nullable() .optional() - .describe('Price in tokens; null leaves it as it is, and a negative one is ignored'), + .describe( + 'Price in tokens, at most 1000 (MAX_INVENTION_PRICE); null leaves it as it is, a ' + + 'negative one is ignored, and one over the cap refuses the publish' + ), }) /** `POST /api/inventions/v2/delete` JSON body — the id and nothing else. */ @@ -789,7 +792,7 @@ export const InventionDeleteResult = z.object({ /** `POST /api/inventions/v1/updateprice` JSON body. */ export const UpdatePriceRequest = z.object({ InventionId: z.int(), - Price: z.int().describe('Must be >= 0'), + Price: z.int().describe('Tokens, 0–1000 (MAX_INVENTION_PRICE)'), }) /** `POST /api/inventions/v6/save` JSON body — camelCase, unlike the read shapes. */ diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index 491e7ec..90fa487 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -45,6 +45,7 @@ import { inventionDeleteResult, inventionSaveV9Failure, isInventionCheered, + MAX_INVENTION_PRICE, normalizeInventionTags, ownsAllInventions, parsePermissionLevel, @@ -1475,15 +1476,18 @@ export const avatarRoutes = new Hono({ strict: false }) summary: 'Publish an invention', description: 'What puts an invention into search and the feeds. Sets the permission other ' + - 'players get (defaulting to UseOnly) and its price. Another GET that writes.', + 'players get (defaulting to UseOnly) and its price. Another GET that writes. A ' + + 'price over 1000 tokens (`MAX_INVENTION_PRICE`) refuses the whole publish with a ' + + '400 rather than being clamped or dropped.', security: AUTHED, parameters: [ intQuery('inventionId', 'Invention id; required'), stringQuery('permissionLevel', 'A name like `useonly`, or the raw number'), - intQuery('price', 'Price in tokens; negative is ignored'), + intQuery('price', 'Price in tokens, at most 1000; negative is ignored'), ], responses: { 200: json(InventionSaveResult, 'The published invention, in the save envelope'), + 400: json(ErrorResponse, 'A price over 1000'), 401: UNAUTHORIZED_RESPONSE, 403: json(ErrorResponse, 'Not the caller’s invention'), 404: { description: 'No such invention' }, @@ -1495,6 +1499,8 @@ export const avatarRoutes = new Hono({ strict: false }) const permissionLevel = c.req.query('permissionLevel') const price = Number.parseInt(c.req.query('price') ?? '', 10) + if (price > MAX_INVENTION_PRICE) + return c.json({ error: `Price must be <= ${MAX_INVENTION_PRICE}` }, 400) const published = await publishInvention(c.env.DB, gate.invention.InventionId, { permissionLevel: @@ -1514,12 +1520,12 @@ export const avatarRoutes = new Hono({ strict: false }) summary: 'Set an invention’s price', description: 'Unlike update/publish, this one POSTs a JSON body. Creator only; a negative price ' + - 'is rejected.', + 'is rejected, as is one over 1000 tokens (`MAX_INVENTION_PRICE`).', security: AUTHED, requestBody: jsonBody(UpdatePriceRequest, 'The invention and its new price'), responses: { 200: json(InventionSaveResult, 'The repriced invention, in the save envelope'), - 400: json(ErrorResponse, 'Unparseable body, or a price below 0'), + 400: json(ErrorResponse, 'Unparseable body, or a price below 0 or over 1000'), 401: UNAUTHORIZED_RESPONSE, 403: json(ErrorResponse, 'Not the caller’s invention'), 404: { description: 'No such invention' }, @@ -1535,6 +1541,8 @@ export const avatarRoutes = new Hono({ strict: false }) const price = typeof body.Price === 'number' ? body.Price : Number.NaN if (Number.isNaN(price) || price < 0) return c.json({ error: 'Price must be >= 0' }, 400) + if (price > MAX_INVENTION_PRICE) + return c.json({ error: `Price must be <= ${MAX_INVENTION_PRICE}` }, 400) const updated = await setInventionPrice(c.env.DB, gate.invention.InventionId, price) return updated === null ? c.notFound() : c.json(toSaveResult(updated)) @@ -2266,7 +2274,8 @@ export const avatarRoutes = new Hono({ strict: false }) 'keeps it out of browse and search. A null `Price` leaves the price alone rather ' + 'than zeroing it, so re-publishing something that was for sale doesn’t give it ' + 'away; every field but `InventionId` is nullable and an omitted one keeps what ' + - 'the invention has.\n\n' + + 'the invention has. A price over 1000 tokens (`MAX_INVENTION_PRICE`) refuses the ' + + 'publish in-band.\n\n' + 'Publishing is not undone here, and re-publishing doesn’t re-date the first ' + 'publish. Refusals answer `Success: false` with a null `Value`, the way ' + '`v9/save` does.', @@ -2304,6 +2313,8 @@ export const avatarRoutes = new Hono({ strict: false }) const int = (key: string): number | undefined => typeof body[key] === 'number' && Number.isInteger(body[key]) ? body[key] : undefined const price = int('Price') + if (price !== undefined && price > MAX_INVENTION_PRICE) + return c.json(inventionSaveV9Failure(`Price must be <= ${MAX_INVENTION_PRICE}`)) const published = await publishInvention(c.env.DB, gate.invention.InventionId, { permissionLevel: int('Permission'), diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 03963dc..97581ff 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -39,6 +39,7 @@ import { THREAD_SCHEMA_DDL, } from '../../../../chat/src/thread-db' import priceCapSql from '../../../migrations/0032_custom_avatar_item_price_cap.sql?raw' +import inventionPriceCapSql from '../../../migrations/0033_invention_price_cap.sql?raw' import { banEvasionMatch, resolveBan } from '../../bans-db' import { createCustomAvatarItem, @@ -54,7 +55,7 @@ import { getEventAttendees, getEventResponse, } from '../../events-db' -import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db' +import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL, MAX_INVENTION_PRICE } from '../../inventions-db' import { banFromReport, createReport, @@ -2863,6 +2864,20 @@ describe('public endpoints', () => { // A negative price is dropped rather than stored. expect((await publish({ Price: -5 })).Price).toBe(250) + // One over the cap refuses the publish in-band, and the price stands. + const over = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, { + method: 'POST', + headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: inventionId, Price: 1001 }), + }) + expect(over.status).toBe(200) + expect(await over.json()).toMatchObject({ + Success: false, + Value: null, + Error: 'Price must be <= 1000', + }) + expect((await publish({ Price: 1000 })).Price).toBe(1000) + // Out of the published catalogue again — see the note in the publish test above. await env.DB.prepare('DELETE FROM invention WHERE id = ?1').bind(inventionId).run() }) @@ -4140,6 +4155,21 @@ describe('public endpoints', () => { expect(await search()).toEqual([Invention.InventionId]) + // A price over the cap refuses the whole publish, leaving the invention as it was. + const over = await exports.default.fetch( + `${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}&price=1001`, + { headers: await bearer('2121') } + ) + expect(over.status).toBe(400) + expect(await over.json()).toEqual({ error: 'Price must be <= 1000' }) + expect( + (await env.DB.prepare( + `SELECT json_extract(data, '$.Price') AS price FROM invention WHERE id = ?1` + ) + .bind(Invention.InventionId) + .first<{ price: number }>())!.price + ).toBe(250) + // Publishing with no permissionLevel defaults to UseOnly, and price to 0. const other = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { method: 'POST', @@ -4192,6 +4222,59 @@ describe('public endpoints', () => { expect( (await updateprice({ InventionId: Invention.InventionId, Price: 10 }, '9999')).status ).toBe(403) + + // Capped at MAX_INVENTION_PRICE, inclusive: one over is refused and the price stands. + expect(MAX_INVENTION_PRICE).toBe(1000) + const over = await updateprice({ InventionId: Invention.InventionId, Price: 1001 }) + expect(over.status).toBe(400) + expect(await over.json()).toEqual({ error: 'Price must be <= 1000' }) + const atCap = await updateprice({ InventionId: Invention.InventionId, Price: 1000 }) + expect(atCap.status).toBe(200) + expect(((await atCap.json()) as InventionSaveResult).Invention.Price).toBe(1000) + }) + + // The cap arrived after creators had priced inventions. Migration 0033 lowers every row + // over it to exactly the cap, published or not, and leaves the rest alone. Tests build the + // schema from SCHEMA_DDL rather than the migrations, so the file's UPDATE is run by hand. + test('migration 0033 lowers existing invention prices to the cap', async () => { + const make = async (name: string, price: number): Promise => { + const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { + method: 'POST', + headers: { ...(await bearer('1213')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, inventionDataFilename: 'a.inv' }), + }) + const id = ((await save.json()) as InventionSaveResult).Invention.InventionId + // Written straight into the row: no endpoint will store this any more. + await env.DB.prepare( + `UPDATE invention SET data = json_set(data, '$.Price', ?2) WHERE id = ?1` + ) + .bind(id, price) + .run() + return id + } + const dear = await make('Dear Lamp', 25000) + const fair = await make('Fair Lamp', 1000) + const cheap = await make('Cheap Lamp', 50) + + const statements = inventionPriceCapSql + .split('\n') + .filter((line) => !line.startsWith('--')) + .join('\n') + .split(';') + .map((sql) => sql.trim()) + .filter(Boolean) + expect(statements).toHaveLength(1) + await env.DB.prepare(statements[0]!).run() + + const price = async (id: number): Promise => + (await env.DB.prepare( + `SELECT json_extract(data, '$.Price') AS price FROM invention WHERE id = ?1` + ) + .bind(id) + .first<{ price: number }>())!.price + expect(await price(dear)).toBe(1000) + expect(await price(fair)).toBe(1000) + expect(await price(cheap)).toBe(50) }) test('GET /api/inventions/v1/fromcreators is an empty feed for now', async () => {