diff --git a/apps/econ/migrations/0025_room_key_player.sql b/apps/econ/migrations/0025_room_key_player.sql new file mode 100644 index 0000000..b95260e --- /dev/null +++ b/apps/econ/migrations/0025_room_key_player.sql @@ -0,0 +1,16 @@ +-- Who HOLDS a room key — one row per (key, player). +-- +-- Written by `POST /api/roomkeys/v1/awardbulk`, which awards the CALLER the keys its body +-- names. A key is held or it isn't — there is no quantity — so the pair is the primary key +-- and awarding a key a player already holds changes nothing (`awarded_at` keeps the first +-- award). `room_key_id` is `room_key.room_key_id`. Owned by the `econ` worker; generated +-- from src/room-key-db.ts (ROOM_KEY_SCHEMA_DDL) — keep in sync. +CREATE TABLE IF NOT EXISTS room_key_player ( + room_key_id INTEGER NOT NULL, + account_id INTEGER NOT NULL, + awarded_at TEXT NOT NULL, + PRIMARY KEY (room_key_id, account_id) + ); + +-- "This player's keys" — the primary key only serves lookups that lead with the key. +CREATE INDEX IF NOT EXISTS idx_room_key_player_account ON room_key_player (account_id); diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 46f2b6d..3473c2d 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -83,6 +83,8 @@ import { AwardRoomConsumablesRequest, AwardRoomCurrencyRequest, AwardRoomCurrencyResultList, + AwardRoomKeyResultList, + AwardRoomKeysRequest, BalanceEntry, BulkPurchaseRequest, BulkPurchaseResponse, @@ -119,6 +121,8 @@ import { MakerAiFreeTrialEligibilityResponse, OpaqueJsonBody, OPTIONAL_AUTHED, + OwnsRoomKeysRequest, + OwnsRoomKeysResponse, ReferralProgressResponse, RoomConsumableDto, RoomConsumableEnvelope, @@ -158,7 +162,7 @@ import { getRoomCurrency, updateRoomCurrency, } from './room-currency-db' -import { createRoomKey, getRoomKeys } from './room-key-db' +import { awardRoomKey, createRoomKey, getRoomKeys, ownsRoomKeys } from './room-key-db' import type { Context } from 'hono' import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain' @@ -183,7 +187,7 @@ import type { Equipment } from './equipment-db' import type { AvatarItem } from './inventory-db' import type { RoomConsumable } from './room-consumable-db' import type { RoomCurrency, RoomCurrencyPurchaseOffer } from './room-currency-db' -import type { RoomKey } from './room-key-db' +import type { RoomKey, RoomKeyHolding } from './room-key-db' // Invention storage (owned by the `api` worker, on this same `recflare` database). // Imported directly rather than copied: these are plain D1 helpers with no bindings of @@ -5618,6 +5622,129 @@ const app = new Hono({ strict: false }) } ) + // 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, + // AccountId }` — `AccountId` is accepted and unused: the token says who gets the key. + .post( + '/api/roomkeys/v1/awardbulk', + describeRoute({ + tags: ['Econ'], + summary: 'Award room keys to the caller', + description: [ + 'Records the CALLER as holding each key the body names (`room_key_player`). The body', + 'is a BARE JSON ARRAY, `[{ "RoomKeyId": 43, "AccountId": 205 }]`; `AccountId` is', + 'accepted and IGNORED — the token says who is awarded.', + '', + 'A key is held or it isn’t: awarding one the caller already holds succeeds and', + 'changes nothing.', + '', + 'The answer is an ARRAY OF ENVELOPES — one `{ Value, Success, Error, error_id }` per', + 'entry, in the order posted, as the client’s decoder reads it; not a bare array of', + 'awards, and not one envelope around a list. `Value` is `{ RoomKeyId, AccountId }`,', + '`AccountId` being the caller. Each entry stands alone: a `RoomKeyId` naming no listed', + 'key answers `Success` false with a null `Value` (the failure form is an assumption —', + 'only success has been observed) and leaves the rest to land.', + '', + 'NOTE: this awards to whoever is asking, and nothing is spent for it — the key’s', + '`Price` is not charged and no room membership is checked.', + ].join('\n'), + security: AUTHED, + requestBody: jsonBody(AwardRoomKeysRequest, 'The keys to give the caller'), + responses: { + 200: json(AwardRoomKeyResultList, 'One result per entry, in the order posted'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const accountId = await authedId(c) + if (accountId === null) return unauthorized(c) + + const body = await c.req.json().catch(() => null) + // A body that is no list has no entries to report on. + if (!Array.isArray(body)) return c.json([]) + + const failed = (error: string) => ({ + Value: null, + Success: false, + Error: error, + error_id: null, + }) + const results = [] + for (const entry of body as unknown[]) { + const roomKeyId = + typeof entry === 'object' && entry !== null + ? (entry as { RoomKeyId?: unknown }).RoomKeyId + : undefined + if (typeof roomKeyId !== 'number' || !Number.isInteger(roomKeyId)) { + results.push(failed('Invalid room key')) + continue + } + + const awardedAt = await awardRoomKey(c.env.DB, accountId, roomKeyId) + results.push( + awardedAt === null + ? failed('No such room key') + : { + Value: { RoomKeyId: roomKeyId, AccountId: accountId }, + Success: true, + Error: null, + error_id: null, + } + ) + } + + return c.json(results) + } + ) + + // Who holds which room key. Auth-gated (401). The body is a bare array of `{ AccountId, + // RoomKeyId }` pairs and — unlike the award's — `AccountId` is READ here: a room asks + // about the players standing in it, not only the caller. A bare array back, one answer + // per pair. + .post( + '/api/roomkeys/v1/owns/bulk', + describeRoute({ + tags: ['Econ'], + summary: 'Whether players hold room keys', + description: [ + 'Answers, for each `{ AccountId, RoomKeyId }` pair posted, whether that player holds', + 'that key (`room_key_player`). The body is a BARE JSON ARRAY, and so is the answer:', + '`[{ "AccountId": 205, "RoomKeyId": 43, "DoesPlayerOwnRoomKey": true }]`, one entry', + 'per pair in the order posted — no envelope, unlike the award beside it.', + '', + '`AccountId` is honoured, not replaced by the caller: any signed-in player may ask', + 'about any player. An unknown key or player is simply not held; an entry without two', + 'integer ids is skipped.', + ].join('\n'), + security: AUTHED, + requestBody: jsonBody(OwnsRoomKeysRequest, 'The (player, key) pairs to check'), + responses: { + 200: json(OwnsRoomKeysResponse, 'One answer per pair, in the order posted'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const accountId = await authedId(c) + if (accountId === null) return unauthorized(c) + + const body = await c.req.json().catch(() => null) + if (!Array.isArray(body)) return c.json([]) + + const pairs: RoomKeyHolding[] = [] + for (const entry of body as unknown[]) { + if (typeof entry !== 'object' || entry === null) continue + const { AccountId, RoomKeyId } = entry as Record + if (typeof AccountId !== 'number' || !Number.isInteger(AccountId)) continue + if (typeof RoomKeyId !== 'number' || !Number.isInteger(RoomKeyId)) continue + pairs.push({ AccountId, RoomKeyId }) + } + + const owned = await ownsRoomKeys(c.env.DB, pairs) + return c.json(pairs.map((pair, i) => ({ ...pair, DoesPlayerOwnRoomKey: owned[i] }))) + } + ) + // The Rec Room Plus sign-up bonus: which bonus is running and the token price window // the free items are drawn from. Fixed numbers, the same for every caller. // diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index a4d116a..3ae1c63 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -675,6 +675,61 @@ export const CreateRoomKeyRequest = z.object({ Price: z.string().optional().describe('What the key costs; defaults to 0'), }) +/** + * `POST /api/roomkeys/v1/awardbulk` — a BARE JSON ARRAY of the keys to award, as the client + * posts it. `AccountId` is accepted and IGNORED: the token says who is awarded. + */ +export const AwardRoomKeysRequest = z.array( + z.object({ + RoomKeyId: z.int().describe('The key to award — a `room_key` id'), + AccountId: z + .int() + .optional() + .describe('Accepted and IGNORED — the caller is who is awarded the key'), + }) +) + +/** + * `POST /api/roomkeys/v1/awardbulk` answers an ARRAY OF ENVELOPES — every element its own + * `{ Value, Success, Error, error_id }` around one award (recovered from the client's + * decoder). Not a bare array of awards, and not one envelope wrapping a list. Only the + * success form has been observed; a failed entry's null `Value` is an assumption. + */ +export const AwardRoomKeyResultList = z.array( + z.object({ + Value: z + .object({ + RoomKeyId: z.int(), + AccountId: z.int().describe('Who now holds the key — the caller'), + }) + .nullable() + .describe('The award, or null on a failure'), + Success: z.boolean(), + Error: z.string().nullable().describe('Null on success; the failure message otherwise'), + error_id: z.null().describe('Always null. Present as a key, and lowercase'), + }) +) + +/** + * `POST /api/roomkeys/v1/owns/bulk` — a BARE JSON ARRAY of (player, key) pairs to check. + * Unlike the award's, this `AccountId` is READ: it names whose keys are being asked about. + */ +export const OwnsRoomKeysRequest = z.array( + z.object({ + AccountId: z.int().describe('The player being asked about'), + RoomKeyId: z.int().describe('The key — a `room_key` id'), + }) +) + +/** `POST /api/roomkeys/v1/owns/bulk` — a bare array, one answer per pair in the order asked. */ +export const OwnsRoomKeysResponse = z.array( + z.object({ + AccountId: z.int(), + RoomKeyId: z.int(), + DoesPlayerOwnRoomKey: z.boolean(), + }) +) + /** * One purchase offer on a room currency — a way to BUY that currency, priced in another * ("5 SuperTokens for 500 Rec Center Tokens"). The client's own model, member for member and diff --git a/apps/econ/src/room-key-db.ts b/apps/econ/src/room-key-db.ts index 87f009c..3d1cc47 100644 --- a/apps/econ/src/room-key-db.ts +++ b/apps/econ/src/room-key-db.ts @@ -2,15 +2,19 @@ * Room keys — a room's own admission tickets. * * A creator lists a key for their room: a named, priced thing a player can hold to get - * through a door the room locks. This module is the LISTING — its name, its price, which room - * it opens. Nothing sells a key yet, so who holds one is not recorded anywhere. The endpoints: + * through a door the room locks. `room_key` is the LISTING — its name, its price, which room + * it opens — and `room_key_player` is who HOLDS one. Nothing sells a key yet; a key reaches + * a player by being awarded. The endpoints: * - `POST /api/roomkeys/v1/create` lists one * - `GET /api/roomkeys/v1/room?roomId=` lists a room's + * - `POST /api/roomkeys/v1/awardbulk` awards the caller some + * - `POST /api/roomkeys/v1/owns/bulk` says who holds which * - * This worker (`econ`) owns the table and its migrations — see apps/econ/migrations/0024. + * This worker (`econ`) owns the tables and their migrations — see apps/econ/migrations/0024 + * and 0025. */ -/** Schema DDL — also builds the table in tests. Mirrors migration 0024. */ +/** Schema DDL — also builds the tables in tests. Mirrors migrations 0024 and 0025. */ export const ROOM_KEY_SCHEMA_DDL: string[] = [ // `room_key_id` is an AUTOINCREMENT rather than a GUID, unlike a room currency's: the // client's own model names a key by a numeric `RoomKeyId` (see the `notify` worker's @@ -37,6 +41,15 @@ export const ROOM_KEY_SCHEMA_DDL: string[] = [ )`, // Every read is "this room's keys" — the whole access pattern. `CREATE INDEX IF NOT EXISTS idx_room_key_room ON room_key (room_id)`, + // Who holds a key: one row per (key, player). A key is held or it isn't — there is no + // quantity — so the pair is the primary key and a repeat award changes nothing. + `CREATE TABLE IF NOT EXISTS room_key_player ( + room_key_id INTEGER NOT NULL, + account_id INTEGER NOT NULL, + awarded_at TEXT NOT NULL, + PRIMARY KEY (room_key_id, account_id) + )`, + `CREATE INDEX IF NOT EXISTS idx_room_key_player_account ON room_key_player (account_id)`, ] /** @@ -146,3 +159,48 @@ export async function getRoomKeys(db: D1Database, roomId: number): Promise { + const [, held] = await db.batch<{ awarded_at: string }>([ + db + .prepare( + `INSERT OR IGNORE INTO room_key_player (room_key_id, account_id, awarded_at) + SELECT room_key_id, ?2, ?3 FROM room_key WHERE room_key_id = ?1` + ) + .bind(roomKeyId, accountId, new Date().toISOString()), + db + .prepare('SELECT awarded_at FROM room_key_player WHERE room_key_id = ?1 AND account_id = ?2') + .bind(roomKeyId, accountId), + ]) + return held.results[0]?.awarded_at ?? null +} + +/** A (player, key) pair — one question put to `POST /api/roomkeys/v1/owns/bulk`. */ +export interface RoomKeyHolding { + AccountId: number + RoomKeyId: number +} + +/** + * Whether each player holds each key — one answer per pair, in the order asked. A pair + * naming an unknown key or player is simply not held. + */ +export async function ownsRoomKeys(db: D1Database, pairs: RoomKeyHolding[]): Promise { + if (pairs.length === 0) return [] + const held = db.prepare( + 'SELECT 1 AS held FROM room_key_player WHERE room_key_id = ?1 AND account_id = ?2' + ) + const results = await db.batch<{ held: number }>( + pairs.map((pair) => held.bind(pair.RoomKeyId, pair.AccountId)) + ) + return results.map((r) => r.results.length > 0) +} diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 3784c9b..1cab078 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -2125,6 +2125,109 @@ describe('econ endpoints', () => { expect(free.Price).toBe(0) }) + test('POST awardbulk records the CALLER as holding each key, reporting each entry', async () => { + const award = async (payload: unknown, headers?: Record) => + exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/awardbulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(payload), + }) + const held = async () => + ( + await env.DB.prepare( + 'SELECT room_key_id, account_id, awarded_at FROM room_key_player ORDER BY room_key_id' + ).all<{ room_key_id: number; account_id: number; awarded_at: string }>() + ).results + + const key = await keyOf(await create({ ...body, Name: 'awarded' }, await bearer('1'))) + const id = key.RoomKeyId + + // Auth-gated. + expect((await award([{ RoomKeyId: id, AccountId: 42 }])).status).toBe(401) + expect(await held()).toEqual([]) + + // The body exactly as the client posts it. The token's player gets the key — + // the body's `AccountId` is ignored, even when it names someone else. + const res = await award( + [ + { RoomKeyId: id, AccountId: 205 }, + { RoomKeyId: 999999, AccountId: 205 }, + { AccountId: 205 }, + ], + await bearer('42') + ) + expect(res.status).toBe(200) + // An array of envelopes, one per entry — `error_id` lowercase beside the rest. + const awarded = { + Value: { RoomKeyId: id, AccountId: 42 }, + Success: true, + Error: null, + error_id: null, + } + expect(await res.json()).toEqual([ + awarded, + { Value: null, Success: false, Error: 'No such room key', error_id: null }, + { Value: null, Success: false, Error: 'Invalid room key', error_id: null }, + ]) + const rows = await held() + expect(rows).toEqual([{ room_key_id: id, account_id: 42, awarded_at: expect.any(String) }]) + + // Held once: a repeat award succeeds and leaves the row as it was. + const again = await award([{ RoomKeyId: id, AccountId: 42 }], await bearer('42')) + expect(await again.json()).toEqual([awarded]) + expect(await held()).toEqual(rows) + + // Another player holds their own copy; a body that is no list has no entries. + await award([{ RoomKeyId: id }], await bearer('43')) + expect((await held()).map((r) => r.account_id).sort()).toEqual([42, 43]) + expect(await (await award({ RoomKeyId: id }, await bearer('42'))).json()).toEqual([]) + + await env.DB.prepare('DELETE FROM room_key_player').run() + await env.DB.prepare('DELETE FROM room_key').run() + }) + + test('POST owns/bulk answers each (player, key) pair, reading the body’s AccountId', async () => { + const post = async (path: string, payload: unknown, headers?: Record) => + exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(payload), + }) + + const key = await keyOf(await create({ ...body, Name: 'held' }, await bearer('1'))) + const id = key.RoomKeyId + await post('awardbulk', [{ RoomKeyId: id, AccountId: 42 }], await bearer('42')) + + // Auth-gated. + expect((await post('owns/bulk', [{ AccountId: 42, RoomKeyId: id }])).status).toBe(401) + + // Asked by 43 about 42 and others: the body's player is the one checked. A bare + // array back, in the order posted; the malformed entry is skipped. + const res = await post( + 'owns/bulk', + [ + { AccountId: 42, RoomKeyId: id }, + { AccountId: 43, RoomKeyId: id }, + { AccountId: 42, RoomKeyId: 999999 }, + { AccountId: 42 }, + ], + await bearer('43') + ) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([ + { AccountId: 42, RoomKeyId: id, DoesPlayerOwnRoomKey: true }, + { AccountId: 43, RoomKeyId: id, DoesPlayerOwnRoomKey: false }, + { AccountId: 42, RoomKeyId: 999999, DoesPlayerOwnRoomKey: false }, + ]) + + // Nothing to ask is nothing to answer. + expect(await (await post('owns/bulk', [], await bearer('43'))).json()).toEqual([]) + expect(await (await post('owns/bulk', {}, await bearer('43'))).json()).toEqual([]) + + await env.DB.prepare('DELETE FROM room_key_player').run() + await env.DB.prepare('DELETE FROM room_key').run() + }) + test('POST create refuses the eleventh key in a room', async () => { // MaxKeysPerRoom is 10 (apps/api/static/api-config-v2.json). Fill up to it… while ((await keysOf(2511)).length < 10) { @@ -5962,7 +6065,9 @@ describe('econ endpoints', () => { 'POST /api/roomcurrencies/v1/createCurrency', 'POST /api/roomcurrencies/v1/createPurchaseOffer', 'POST /api/roomcurrencies/v1/updateCurrency', + 'POST /api/roomkeys/v1/awardbulk', 'POST /api/roomkeys/v1/create', + 'POST /api/roomkeys/v1/owns/bulk', 'POST /api/storefronts/v2/buyItem', 'POST /api/storefronts/v3/buyInvention', 'POST /api/ugcPurchasables/v1/items/bulk',