[econ] fix key images

This commit is contained in:
Devin Zuczek
2026-10-01 13:29:04 -04:00
parent de607b7632
commit b09f014b6d
5 changed files with 392 additions and 12 deletions
+128 -5
View File
@@ -144,6 +144,7 @@ import {
UpdateObjectiveRequest,
UpdateObjectiveResponse,
UpdateRoomCurrencyRequest,
UpdateRoomKeyRequest,
UpsertRoomConsumableRequest,
} from './openapi'
import { claimReward, isNewActivity } from './reward-db'
@@ -162,7 +163,14 @@ import {
getRoomCurrency,
updateRoomCurrency,
} from './room-currency-db'
import { awardRoomKey, createRoomKey, getRoomKeys, ownsRoomKeys } from './room-key-db'
import {
awardRoomKey,
createRoomKey,
getRoomKey,
getRoomKeys,
ownsRoomKeys,
updateRoomKey,
} from './room-key-db'
import type { Context } from 'hono'
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
@@ -295,6 +303,14 @@ const ROOM_KEY_STATUS_FAILED = 1
*/
const MAX_KEYS_PER_ROOM = 10
/**
* The most a room key may cost, by what it is priced in: tokens (a null `PurchaseCurrencyId`)
* are real money to the player paying, so they are capped low; a room's own currency is the
* room's to inflate.
*/
const MAX_KEY_PRICE_TOKENS = 1000
const MAX_KEY_PRICE_ROOM_CURRENCY = 1_000_000_000
/**
* The envelope the create endpoint answers in: `{ Value, Success, Error, error_id }`.
*
@@ -5565,12 +5581,13 @@ const app = new Hono<App>({ strict: false })
'',
'`Name` and `Description` are masked by the same word list as every other string a',
'player types. A room may list at most 10 keys — the `RoomKeyConfig.MaxKeysPerRoom`',
'the `api` config tells the client — and the eleventh is refused.',
'the `api` config tells the client — and the eleventh is refused. A new key is priced',
'in tokens (the body names no currency), so a `Price` over 1000 is refused too.',
'',
'Answers `{ Status, RoomKey }` — `Status` 0 and the created key, as the live client',
'reads it — not the `{ Value, Success, Error, error_id }` envelope the room-currency',
'writes use. A recoverable refusal (an unusable `RoomId`, an empty `Name`, an unknown',
'room, a full room) is a 200 carrying a non-zero `Status` and a null `RoomKey`; only',
'room, a full room, a `Price` over the limit) is a 200 carrying a non-zero `Status` and a null `RoomKey`; only',
'the auth gates answer with an HTTP status of their own.',
'',
'Pushes `LocalRoomKeyCreated` (120), carrying the same object, to everyone in the',
@@ -5599,6 +5616,10 @@ const app = new Hono<App>({ strict: false })
if (!Number.isInteger(roomId)) return roomKeyEnvelope(c, null)
const name = str(body.Name).trim()
if (name === '') return roomKeyEnvelope(c, null)
// A key cannot cost less than nothing — nor, being priced in tokens until an edit
// says otherwise, more than the token cap.
const price = Math.max(0, int(body.Price, 0))
if (price > MAX_KEY_PRICE_TOKENS) return roomKeyEnvelope(c, null)
const canManage = await canManageRoomById(c.env.DB, roomId, accountId)
if (canManage === null) return roomKeyEnvelope(c, null)
@@ -5613,8 +5634,7 @@ const app = new Hono<App>({ strict: false })
Type: str(body.Type).trim() || 'Key',
Name: censorSwears(name),
Description: censorSwears(str(body.Description)),
// A key cannot cost less than nothing.
Price: Math.max(0, int(body.Price, 0)),
Price: price,
})
await pushRoomKeyCreated(c, key, accountId)
@@ -5622,6 +5642,109 @@ const app = new Hono<App>({ strict: false })
}
)
// Edit a room key. "All" is the key's FIELDS, not the room's keys: the body names one
// `RoomKeyId` and carries everything editable about it. Gated like the create — the room
// comes off the KEY, never the request.
.put(
'/api/roomkeys/v1/updateAll',
describeRoute({
tags: ['Econ'],
summary: 'Update a room key',
description: [
'Edits ONE key — despite the name, "all" is its fields — form-encoded',
'(`RoomKeyId=43&Name=key1&Description=…&Price=1000&PurchaseCurrencyId=`).',
'',
'Gated to the CREATOR or a CO-OWNER of the room that lists the key; a valid token',
'from anyone else is a 403.',
'',
'`PurchaseCurrencyId` sent EMPTY prices the key in tokens (stored and served as',
'null) — that is how a key on a room currency is taken back off it; otherwise it',
'must be a `room_currency` id belonging to the key’s own room.',
'`ImageName` is the key’s art, stored as posted; sent EMPTY it clears the art',
'(served as null). That `ImageName` rides in this form is an ASSUMPTION.',
'`Price` is capped by what the key ends up priced in: 1000 in tokens, 1000000000 in',
'a room currency. A field left out of the body is left alone rather than reset, and',
'the cap is checked against the resulting pair.',
'',
'`Name` and `Description` are masked by the same word list as every other string a',
'player types. What can never change: the ids, the room, the `Type`, `CreatedAt`.',
'',
'Answers the create’s `{ Status, RoomKey }` envelope with the key as it now stands',
'— an ASSUMPTION: what the client reads from this response has not been observed.',
'A recoverable refusal (no such key, a blank `Name`, a `Price` over the cap, an',
'unknown or foreign currency) is a 200 carrying a non-zero `Status` and a null',
'`RoomKey`.',
].join('\n'),
security: AUTHED,
requestBody: form(UpdateRoomKeyRequest, 'The key and its fields'),
responses: {
200: json(RoomKeyEnvelope, 'The key as it now stands, or a refusal'),
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
},
}),
async (c) => {
const accountId = await authedId(c)
if (accountId === null) return unauthorized(c)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
const roomKeyId = Number.parseInt(str(body.RoomKeyId) ?? '', 10)
if (!Number.isInteger(roomKeyId)) return roomKeyEnvelope(c, null)
const existing = await getRoomKey(c.env.DB, roomKeyId)
if (!existing) return roomKeyEnvelope(c, null)
const canManage = await canManageRoomById(c.env.DB, existing.RoomId, accountId)
if (canManage === null) return roomKeyEnvelope(c, null)
if (!canManage) return c.body(null, 403)
// A name sent but blank is a bad edit, not an instruction to leave it alone.
const name = str(body.Name)?.trim()
if (name === '') return roomKeyEnvelope(c, null)
let price = existing.Price
if (str(body.Price) !== undefined) {
price = Number.parseInt(str(body.Price)!, 10)
if (!Number.isInteger(price) || price < 0) return roomKeyEnvelope(c, null)
}
// Present-but-empty is a VALUE here — tokens — unlike the fields above, where only
// absence means "leave alone".
let purchaseCurrencyId = existing.PurchaseCurrencyId
const postedCurrencyId = str(body.PurchaseCurrencyId)?.trim()
if (postedCurrencyId === '') {
purchaseCurrencyId = null
} else if (postedCurrencyId !== undefined && postedCurrencyId !== purchaseCurrencyId) {
// A key is sold in its own room's money, not another's.
const currency = await getRoomCurrency(c.env.DB, postedCurrencyId)
if (!currency || currency.RoomId !== existing.RoomId) return roomKeyEnvelope(c, null)
purchaseCurrencyId = currency.CurrencyId
}
const maxPrice =
purchaseCurrencyId === null ? MAX_KEY_PRICE_TOKENS : MAX_KEY_PRICE_ROOM_CURRENCY
if (price > maxPrice) return roomKeyEnvelope(c, null)
// Blank is a value here too: no art. Only absence leaves the stored name alone.
const postedImageName = str(body.ImageName)?.trim()
const imageName = postedImageName === undefined ? existing.ImageName : postedImageName || null
const description = str(body.Description)
return roomKeyEnvelope(
c,
await updateRoomKey(c.env.DB, roomKeyId, {
Name: name === undefined ? existing.Name : censorSwears(name),
Description: description === undefined ? existing.Description : censorSwears(description),
Price: price,
PurchaseCurrencyId: purchaseCurrencyId,
ImageName: imageName,
})
)
}
)
// Award the CALLER some room keys. Auth-gated (401); everything else is reported per
// entry, each in its OWN `{ Value, Success, Error, error_id }` envelope — the client
// decodes a list of single-value envelopes here. The body is a bare array of `{ RoomKeyId,
+33 -3
View File
@@ -645,9 +645,9 @@ export const RoomKeyDto = z.object({
PurchaseCurrencyId: z
.string()
.nullable()
.describe('A `room_currency` id, or null — the create body names none'),
.describe('A `room_currency` id, or null for tokens — which a new key is priced in'),
CreatedAt: z.string().describe('ISO-8601 UTC'),
ImageName: z.string().nullable().describe('Null — a key cannot carry art yet'),
ImageName: z.string().nullable().describe('The key’s art; null until an edit sets one'),
Type: z.int().describe('The key type’s ordinal: 0 `Key`, the only one seen'),
})
@@ -672,7 +672,37 @@ export const CreateRoomKeyRequest = z.object({
RoomId: z.string().describe('The room the key opens'),
Name: z.string().describe('Shown to players; profanity-masked like every typed string'),
Description: z.string().optional().describe('Defaults to empty'),
Price: z.string().optional().describe('What the key costs; defaults to 0'),
Price: z
.string()
.optional()
.describe('What the key costs, in tokens; defaults to 0. At most 1000'),
})
/**
* `PUT /api/roomkeys/v1/updateAll` — form-encoded. "All" is the FIELDS, not the keys: the body
* names one key and carries every editable field of it
* (`RoomKeyId=43&Name=key1&Description=…&Price=1000&PurchaseCurrencyId=`). For the two
* fields that can be emptied — `PurchaseCurrencyId` and `ImageName` — a BLANK value is an
* instruction to clear it; only a field absent from the body is left alone.
*/
export const UpdateRoomKeyRequest = z.object({
RoomKeyId: z.string().describe('The key to edit'),
Name: z.string().optional().describe('Left alone when absent; profanity-masked'),
Description: z.string().optional().describe('Left alone when absent; profanity-masked'),
Price: z
.string()
.optional()
.describe('Left alone when absent. At most 1000 in tokens, 1000000000 in a room currency'),
PurchaseCurrencyId: z
.string()
.optional()
.describe(
'EMPTY means tokens; otherwise a `room_currency` id of the key’s own room. Left alone when absent'
),
ImageName: z
.string()
.optional()
.describe('The key’s art. EMPTY clears it (served as null). Left alone when absent'),
})
/**
+55 -4
View File
@@ -7,6 +7,7 @@
* a player by being awarded. The endpoints:
* - `POST /api/roomkeys/v1/create` lists one
* - `GET /api/roomkeys/v1/room?roomId=` lists a room's
* - `PUT /api/roomkeys/v1/updateAll` edits one
* - `POST /api/roomkeys/v1/awardbulk` awards the caller some
* - `POST /api/roomkeys/v1/owns/bulk` says who holds which
*
@@ -24,8 +25,8 @@ export const ROOM_KEY_SCHEMA_DDL: string[] = [
// back as the ORDINAL (`Type: 0`) — see {@link ROOM_KEY_TYPE_ORDINALS}. Only `Key` has
// been observed.
//
// `purchase_currency_id` is the `room_currency` the key is charged in — nullable, and the
// create body names none. `image_name` is '' in the row (NOT NULL), but the client reads
// `purchase_currency_id` is the `room_currency` the key is charged in — null means tokens,
// which is what a new key costs: the create body names none, and an edit can set it. `image_name` is '' in the row (NOT NULL), but the client reads
// `ImageName` as null until a key carries art, so an empty column is served as null.
`CREATE TABLE IF NOT EXISTS room_key (
room_key_id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -76,11 +77,11 @@ export interface RoomKey {
Name: string
Description: string
Price: number
/** A `room_currency` id, or null — the create body names none. */
/** A `room_currency` id, or null for tokens — which is what a new key is priced in. */
PurchaseCurrencyId: string | null
/** ISO-8601 UTC. */
CreatedAt: string
/** Null until a key can carry art. */
/** Null until an edit gives the key art. */
ImageName: string | null
/** The key type's ordinal — 0 `Key`. See {@link ROOM_KEY_TYPE_ORDINALS}. */
Type: number
@@ -147,6 +148,56 @@ export async function createRoomKey(db: D1Database, key: NewRoomKey): Promise<Ro
return toRoomKey(row!)
}
/** One key by its id, or null when there is no such row. */
export async function getRoomKey(db: D1Database, roomKeyId: number): Promise<RoomKey | null> {
const row = await db
.prepare(`SELECT ${SELECT_COLUMNS} FROM room_key WHERE room_key_id = ?1`)
.bind(roomKeyId)
.first<RoomKeyRow>()
return row ? toRoomKey(row) : null
}
/** What an edit may change — the rest of a key (its ids, room, type, `CreatedAt`) is fixed. */
export interface RoomKeyChanges {
Name: string
Description: string
Price: number
/** A `room_currency` id, or null for tokens. */
PurchaseCurrencyId: string | null
/** The key's art, or null for none — stored as '' (the column is NOT NULL). */
ImageName: string | null
}
/**
* Rewrite a key's editable fields, returning it as it now stands — null when `roomKeyId`
* names no listed key.
*/
export async function updateRoomKey(
db: D1Database,
roomKeyId: number,
changes: RoomKeyChanges
): Promise<RoomKey | null> {
const row = await db
.prepare(
`UPDATE room_key
SET name = ?2, description = ?3, price = ?4, purchase_currency_id = ?5, image_name = ?6
WHERE room_key_id = ?1
RETURNING ${SELECT_COLUMNS}`
)
.bind(
roomKeyId,
changes.Name,
changes.Description,
changes.Price,
changes.PurchaseCurrencyId,
changes.ImageName ?? ''
)
.first<RoomKeyRow>()
return row ? toRoomKey(row) : null
}
/**
* Every key a room has listed, oldest first — the order its owner built them up in. The
* autoincrement id IS creation order, so it is the sort key.
+175
View File
@@ -2241,6 +2241,180 @@ describe('econ endpoints', () => {
expect((await keysOf(2511)).length).toBe(10)
await env.DB.prepare('DELETE FROM room_key').run()
})
test('POST create refuses a token price over 1000', async () => {
const atCap = await create({ ...body, Price: '1000' }, await bearer('1'))
expect((await envOf(atCap)).Status).toBe(0)
const over = await create({ ...body, Price: '1001' }, await bearer('1'))
expect(await envOf(over)).toEqual({ Status: 1, RoomKey: null })
await env.DB.prepare('DELETE FROM room_key').run()
})
test('PUT updateAll edits one key, capping the price by its currency', async () => {
const update = async (fields: Record<string, string>, headers?: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/updateAll`, {
method: 'PUT',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
body: new URLSearchParams(fields),
})
const mint = async (roomId: string, as: string) =>
(
(await (
await exports.default.fetch(`${ORIGIN}/api/roomcurrencies/v1/createCurrency`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(await bearer(as)),
},
body: new URLSearchParams({ RoomId: roomId, Name: 'coin' }),
})
).json()) as { Value: { CurrencyId: string } }
).Value.CurrencyId
const key = await keyOf(await create(body, await bearer('1')))
const other = await keyOf(await create({ ...body, Name: 'other' }, await bearer('1')))
const id = String(key.RoomKeyId)
// The body exactly as the client puts it.
const edit = {
RoomKeyId: id,
Name: 'key1',
Description: 'testssdfsdfsdf',
Price: '1000',
PurchaseCurrencyId: '',
}
const refused = { Status: 1, RoomKey: null }
expect((await update(edit)).status).toBe(401)
expect((await update(edit, await bearer('42'))).status).toBe(403)
// In tokens: 1000 passes, 1001 does not. A co-owner may edit.
const res = await update(edit, await bearer('2'))
expect(res.status).toBe(200)
expect(await envOf(res)).toEqual({
Status: 0,
RoomKey: { ...key, Name: 'key1', Description: 'testssdfsdfsdf', Price: 1000 },
})
expect(await envOf(await update({ ...edit, Price: '1001' }, await bearer('1')))).toEqual(
refused
)
// In the room's own currency the cap is a billion.
const currencyId = await mint('2511', '1')
const rich = { ...edit, Price: '1000000000', PurchaseCurrencyId: currencyId }
expect((await envOf(await update(rich, await bearer('1')))).RoomKey).toMatchObject({
Price: 1000000000,
PurchaseCurrencyId: currencyId,
})
expect(
await envOf(await update({ ...rich, Price: '1000000001' }, await bearer('1')))
).toEqual(refused)
// Back to tokens with the big price still on it: the token cap applies again.
expect(
await envOf(await update({ RoomKeyId: id, PurchaseCurrencyId: '' }, await bearer('1')))
).toEqual(refused)
// Refusals: no such key, a blank name, an unknown currency.
for (const bad of [
{ ...edit, RoomKeyId: '999999' },
{ ...edit, Name: ' ' },
{ ...edit, PurchaseCurrencyId: '46eddc63-e6d4-42cc-bf04-5d879e33ac17' },
]) {
expect(await envOf(await update(bad, await bearer('1')))).toEqual(refused)
}
// Only the named key moved, and the refused edits left it as the last good one.
expect(await keysOf(2511)).toEqual([
{
...key,
Name: 'key1',
Description: 'testssdfsdfsdf',
Price: 1000000000,
PurchaseCurrencyId: currencyId,
},
other,
])
await env.DB.prepare('DELETE FROM room_key').run()
await env.DB.prepare('DELETE FROM room_currency WHERE currency_id = ?1')
.bind(currencyId)
.run()
})
test('PUT updateAll: a BLANK PurchaseCurrencyId or ImageName clears it; an absent one is left alone', async () => {
const update = async (fields: Record<string, string>) =>
envOf(
await exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/updateAll`, {
method: 'PUT',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(await bearer('1')),
},
body: new URLSearchParams(fields),
})
)
const stored = async (roomKeyId: number) =>
env.DB.prepare(
'SELECT purchase_currency_id, image_name FROM room_key WHERE room_key_id = ?1'
)
.bind(roomKeyId)
.first<{ purchase_currency_id: string | null; image_name: string }>()
const currencyId = (
(await (
await exports.default.fetch(`${ORIGIN}/api/roomcurrencies/v1/createCurrency`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(await bearer('1')),
},
body: new URLSearchParams({ RoomId: '2511', Name: 'coin' }),
})
).json()) as { Value: { CurrencyId: string } }
).Value.CurrencyId
const key = await keyOf(await create(body, await bearer('1')))
const id = String(key.RoomKeyId)
expect(key).toMatchObject({ PurchaseCurrencyId: null, ImageName: null })
// Put the key on the room's currency and give it art.
const set = await update({
RoomKeyId: id,
PurchaseCurrencyId: currencyId,
ImageName: 'key.png',
})
expect(set.RoomKey).toMatchObject({ PurchaseCurrencyId: currencyId, ImageName: 'key.png' })
expect(await stored(key.RoomKeyId)).toEqual({
purchase_currency_id: currencyId,
image_name: 'key.png',
})
// An edit that mentions neither leaves both alone.
const renamed = await update({ RoomKeyId: id, Name: 'renamed' })
expect(renamed.RoomKey).toMatchObject({
Name: 'renamed',
PurchaseCurrencyId: currencyId,
ImageName: 'key.png',
})
// Blank clears the currency — NULL in the row, null on the wire — and nothing else.
const noCurrency = await update({ RoomKeyId: id, PurchaseCurrencyId: '' })
expect(noCurrency.RoomKey).toMatchObject({ PurchaseCurrencyId: null, ImageName: 'key.png' })
expect(await stored(key.RoomKeyId)).toEqual({
purchase_currency_id: null,
image_name: 'key.png',
})
// Blank clears the art: '' in the row, served as null. The list read agrees.
const noArt = await update({ RoomKeyId: id, ImageName: '' })
expect(noArt.RoomKey).toMatchObject({ PurchaseCurrencyId: null, ImageName: null })
expect(await stored(key.RoomKeyId)).toEqual({ purchase_currency_id: null, image_name: '' })
expect(await keysOf(2511)).toEqual([noArt.RoomKey])
await env.DB.prepare('DELETE FROM room_key').run()
await env.DB.prepare('DELETE FROM room_currency WHERE currency_id = ?1')
.bind(currencyId)
.run()
})
})
test('GET /api/roomconsumables/v1/roomConsumable/room/:id/me returns []', async () => {
@@ -6073,6 +6247,7 @@ describe('econ endpoints', () => {
'POST /api/ugcPurchasables/v1/items/bulk',
'PUT /api/equipment/v1/update',
'PUT /api/roomconsumables/v1/roomConsumable',
'PUT /api/roomkeys/v1/updateAll',
])
// Every operation carries a summary — a path present but undescribed is not