mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[api] prevent inflation via inventions
This commit is contained in:
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
inventionDeleteResult,
|
||||
inventionSaveV9Failure,
|
||||
isInventionCheered,
|
||||
MAX_INVENTION_PRICE,
|
||||
normalizeInventionTags,
|
||||
ownsAllInventions,
|
||||
parsePermissionLevel,
|
||||
@@ -1475,15 +1476,18 @@ export const avatarRoutes = new Hono<App>({ 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<App>({ 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<App>({ 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<App>({ 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<App>({ 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<App>({ 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'),
|
||||
|
||||
@@ -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<number> => {
|
||||
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<number> =>
|
||||
(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 () => {
|
||||
|
||||
Reference in New Issue
Block a user