From b09f014b6df2c755d042ccac3bd8d891687a5a25 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 1 Oct 2026 13:29:04 -0400 Subject: [PATCH] [econ] fix key images --- apps/econ/README.md | 1 + apps/econ/src/econ.app.ts | 133 +++++++++++++++- apps/econ/src/openapi.ts | 36 ++++- apps/econ/src/room-key-db.ts | 59 ++++++- apps/econ/src/test/integration/api.test.ts | 175 +++++++++++++++++++++ 5 files changed, 392 insertions(+), 12 deletions(-) diff --git a/apps/econ/README.md b/apps/econ/README.md index c6999f4..60caefb 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -51,6 +51,7 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali | GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) | | GET | `/api/roomkeys/v1/room` | | The keys a room has listed | | POST | `/api/roomkeys/v1/create` | ✓ | List a key for a room (owner/co-owner) | +| PUT | `/api/roomkeys/v1/updateAll` | ✓ | Edit one key (owner/co-owner) | | POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | ~ | Gold year for `developer`s, else `{}` | | GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) | diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 3473c2d..a4759f3 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -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({ 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({ 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({ 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({ 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 + 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, diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index 3ae1c63..587aae9 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -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'), }) /** diff --git a/apps/econ/src/room-key-db.ts b/apps/econ/src/room-key-db.ts index 3d1cc47..0fe690f 100644 --- a/apps/econ/src/room-key-db.ts +++ b/apps/econ/src/room-key-db.ts @@ -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 { + const row = await db + .prepare(`SELECT ${SELECT_COLUMNS} FROM room_key WHERE room_key_id = ?1`) + .bind(roomKeyId) + .first() + + 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 { + 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() + + 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. diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 1cab078..9c72e9e 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -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, headers?: Record) => + 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) => + 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