From bbdc061d057425e159d33d56b70ae148ba2796d2 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Fri, 25 Sep 2026 13:54:03 -0400 Subject: [PATCH] [econ] room key wip --- apps/api/src/test/integration/api.test.ts | 7 + apps/api/static/api-config-v2.json | 3 + apps/econ/README.md | 3 +- apps/econ/migrations/0024_room_key.sql | 36 ++++ apps/econ/src/econ.app.ts | 190 ++++++++++++++++++- apps/econ/src/openapi.ts | 51 ++++++ apps/econ/src/room-key-db.ts | 148 +++++++++++++++ apps/econ/src/test/integration/api.test.ts | 202 ++++++++++++++++++++- apps/notify/src/notification-payloads.ts | 10 +- 9 files changed, 639 insertions(+), 11 deletions(-) create mode 100644 apps/econ/migrations/0024_room_key.sql create mode 100644 apps/econ/src/room-key-db.ts diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 01c3583..a50aae1 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -935,6 +935,13 @@ describe('public endpoints', () => { ) }) + test('the config carries the room key limit', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/config/v2`) + expect(res.status).toBe(200) + const config = (await res.json()) as { RoomKeyConfig: { MaxKeysPerRoom: number } } + expect(config.RoomKeyConfig).toEqual({ MaxKeysPerRoom: 10 }) + }) + test('the level rewards match the published reward table', async () => { // Rec Room's published level-reward table, spot-checked at the points where it turns: // consumables early, then clothing at a rising star rating (2★ = 10, 3★ = 20, 4★ = 30, diff --git a/apps/api/static/api-config-v2.json b/apps/api/static/api-config-v2.json index 02df470..78dd9b1 100644 --- a/apps/api/static/api-config-v2.json +++ b/apps/api/static/api-config-v2.json @@ -382,5 +382,8 @@ "CloudRegion": "us", "CrcCheckEnabled": false, "EnableServerTracingAfterDisconnect": false + }, + "RoomKeyConfig": { + "MaxKeysPerRoom": 10 } } diff --git a/apps/econ/README.md b/apps/econ/README.md index b4106f6..f25eeb7 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -49,7 +49,8 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali | GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) | | POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward → 5 XP + gift box | | GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) | -| GET | `/api/roomkeys/v1/room` | | Room keys for a room (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) | | 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/migrations/0024_room_key.sql b/apps/econ/migrations/0024_room_key.sql new file mode 100644 index 0000000..0d8f48b --- /dev/null +++ b/apps/econ/migrations/0024_room_key.sql @@ -0,0 +1,36 @@ +-- Room keys — a room's own admission tickets. +-- +-- A creator lists a key for their room (`POST /api/roomkeys/v1/create`, form-encoded +-- `Type=Key&RoomId=1162&Name=my%20key&Description=…&Price=50`): a named, priced thing a +-- player can hold to get through a door the room locks. This is the LISTING — its name, its +-- price, which room it opens. Nothing SELLS a key yet, so there is no table of who holds one. +-- Owned by the `econ` worker; generated from src/room-key-db.ts (ROOM_KEY_SCHEMA_DDL) — keep +-- in sync. +-- +-- `room_key_id` is an AUTOINCREMENT rather than a GUID, unlike a room currency's or a +-- consumable's: the client's own model (`LocalRoomKeyCreated`'s payload, which names a key by +-- a numeric `RoomKeyId`) says so. `replication_id` is the GUID beside it that the same +-- payload carries; it is minted here. +-- +-- `key_type` is the body's `Type`, stored as the enum NAME it arrives as (`Key`) and served +-- back as its ordinal (`Type: 0`); only `Key` has been observed. +-- +-- `price` is the body's `Price`, and `purchase_currency_id` the `room_currency` it is charged +-- in — nullable, `Guid?` in the client's model, and the create body names none, so it is null +-- until an edit can set it. `image_name` is '' in the row and served as a null `ImageName` +-- (what the client reads) until a key can carry art. +CREATE TABLE IF NOT EXISTS room_key ( + room_key_id INTEGER PRIMARY KEY AUTOINCREMENT, + replication_id TEXT NOT NULL, + room_id INTEGER NOT NULL, + key_type TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + price INTEGER NOT NULL, + purchase_currency_id TEXT, + image_name TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL + ); + +-- 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); diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 6fe5295..7ff90c7 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -100,6 +100,7 @@ import { ConsumeGiftRequest, CreatePurchaseOfferRequest, CreateRoomCurrencyRequest, + CreateRoomKeyRequest, CustomAvatarItemsResponse, EquipmentUpdateRequest, ErrorResponse, @@ -126,6 +127,8 @@ import { RoomCurrencyPurchaseOfferEnvelope, RoomCurrencyPurchaseOffersDto, RoomEconConfig, + RoomKeyDto, + RoomKeyEnvelope, RRPlusSignUpBonus, SaveOutfitRequest, SaveOutfitV4Response, @@ -154,6 +157,7 @@ import { getRoomCurrency, updateRoomCurrency, } from './room-currency-db' +import { createRoomKey, getRoomKeys } from './room-key-db' import type { Context } from 'hono' import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain' @@ -161,6 +165,7 @@ import type { CustomAvatarItem } from '../../api/src/custom-avatar-items-db' import type { SavedInvention } from '../../api/src/inventions-db' import type { BalanceResponsePayload, + LocalRoomKeyPayload, PurchaseBalanceModificationPayload, RoomCurrencyPayload, } from '../../notify/src/notification-payloads' @@ -177,6 +182,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' // 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 @@ -270,6 +276,19 @@ const CREATE_CURRENCY_FAILED = 'Failed to create currency' const UPDATE_CURRENCY_FAILED = 'Failed to update currency' const CREATE_OFFER_FAILED = 'Failed to create purchase offer' const SAVE_CONSUMABLE_FAILED = 'Failed to save consumable' +/** + * `Status` on the room-key create's `{ Status, RoomKey }` reply. 0 is the observed success + * code; the refusal code has not been observed, so 1 is an assumption. + */ +const ROOM_KEY_STATUS_OK = 0 +const ROOM_KEY_STATUS_FAILED = 1 + +/** + * The most keys one room may list — the `RoomKeyConfig.MaxKeysPerRoom` the `api` worker's + * config (`apps/api/static/api-config-v2.json`) tells the client, restated here so the server + * refuses what the client already knows not to ask for. + */ +const MAX_KEYS_PER_ROOM = 10 /** * The envelope the create endpoint answers in: `{ Value, Success, Error, error_id }`. @@ -288,6 +307,19 @@ function roomCurrencyEnvelope(c: Context, value: RoomCurrency | null, error }) } +/** + * The room-key create's envelope: `{ Status, RoomKey }`, as the live client was observed + * reading it — NOT the `{ Value, Success, Error, error_id }` the room-currency and consumable + * writes beside it answer in. `Status` 0 carries the key; a refusal is a non-zero `Status` + * with a null `RoomKey` (the refusal code is an assumption — only success has been observed). + */ +function roomKeyEnvelope(c: Context, key: RoomKey | null) { + return c.json({ + Status: key === null ? ROOM_KEY_STATUS_FAILED : ROOM_KEY_STATUS_OK, + RoomKey: key, + }) +} + /** * The consumable write's envelope — the same `{ Value, Success, Error, error_id }` shape the * room-currency writes answer in, carrying one listing. @@ -387,6 +419,56 @@ async function pushRoomCurrencyChange( ) } +/** + * Push `LocalRoomKeyCreated` (120) for a new room key to everyone standing in the room, plus + * whoever listed it — the same audience, for the same reason, as a room currency's push: a + * key is something every client in the room may offer for sale, and the settings UI that + * lists one opens from outside the room, so its author is added separately. + * + * The frame is typed as the hub's {@link LocalRoomKeyPayload} — the client's own decoder — + * and is the stored record verbatim, so the HTTP response and the frame cannot drift apart. + * Best-effort: the key has already committed, and a hub hiccup must not fail the request. + */ +async function pushRoomKeyCreated( + c: Context, + key: RoomKey, + createdByAccountId: number +): Promise { + const frame: LocalRoomKeyPayload = key + + let occupants: number[] = [] + try { + occupants = await getPlayerIdsInRoom(c.env.DB, key.RoomId) + } catch (err) { + logger.error('failed to read room presence for a room-key push', { + roomId: key.RoomId, + error: err instanceof Error ? err.message : String(err), + }) + } + + const playerIds = new Set(occupants) + playerIds.add(createdByAccountId) + + await Promise.all( + [...playerIds].map(async (playerId) => { + try { + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( + playerId, + NotificationType.LocalRoomKeyCreated, + { ...frame } + ) + } catch (err) { + logger.error('failed to push a room-key notification', { + playerId, + roomId: key.RoomId, + roomKeyId: key.RoomKeyId, + error: err instanceof Error ? err.message : String(err), + }) + } + }) + ) +} + /** * Push a ConsumableMappingRemoved notification to a player after they consume a * consumable, mirroring the reference's @@ -5426,13 +5508,113 @@ const app = new Hono({ strict: false }) } ) - // The player's room keys. Returns "[]". + // The player's room keys. Returns "[]": nothing sells a key yet, so nobody holds one. .get('/api/roomkeys/v1/mine', listRoute('The player’s room keys', 'Empty for now'), (c) => c.json([]) ) - // Room keys for a given room (client calls this on the econ host). [] with no DB. - .get('/api/roomkeys/v1/room', listRoute('Room keys for a room', 'Empty for now'), (c) => - c.json([]) + // The keys a room has listed. Public, like the room's currencies: a key is offered to + // whoever walks up to the door it opens. A room with none, an unknown room and a missing + // `roomId` are all the same empty list. + .get( + '/api/roomkeys/v1/room', + describeRoute({ + tags: ['Econ'], + summary: 'A room’s keys', + description: [ + 'Every key the room named by `roomId` has listed, oldest first.', + '', + 'Public, like `GET /api/roomcurrencies/v1/currencies`. A room with none, an unknown', + 'room and a missing `roomId` are all the same empty list.', + ].join('\n'), + parameters: [ + { + name: 'roomId', + in: 'query', + required: false, + description: 'The room whose keys to list', + schema: { type: 'string' }, + }, + ], + responses: { 200: json(RoomKeyDto.array(), 'The room’s keys, oldest first') }, + }), + async (c) => { + const roomId = Number.parseInt(c.req.query('roomId') ?? '', 10) + if (!Number.isInteger(roomId)) return c.json([]) + return c.json(await getRoomKeys(c.env.DB, roomId)) + } + ) + + // List a key for a room. Auth-gated (401) and gated to the room's creator or a co-owner + // (403) — the same owner-level check the room-currency writes apply. + .post( + '/api/roomkeys/v1/create', + describeRoute({ + tags: ['Econ'], + summary: 'Create a room key', + description: [ + 'Lists a key for one room — its name, its description and its price. Form-encoded', + '(`Type=Key&RoomId=1162&Name=my%20key&Description=…&Price=50`).', + '', + 'Gated to the room’s CREATOR or a CO-OWNER, like minting a currency. A valid token', + 'from anyone else is a 403.', + '', + '`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.', + '', + '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', + 'the auth gates answer with an HTTP status of their own.', + '', + 'Pushes `LocalRoomKeyCreated` (120), carrying the same object, to everyone in the', + 'room and to the caller.', + ].join('\n'), + security: AUTHED, + requestBody: form(CreateRoomKeyRequest, 'The key to list'), + responses: { + 200: json(RoomKeyEnvelope, 'The key as created, or a refusal with a non-zero `Status`'), + 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 => (typeof v === 'string' ? v : '') + const int = (v: unknown, fallback: number): number => { + const parsed = Number.parseInt(str(v), 10) + return Number.isInteger(parsed) ? parsed : fallback + } + + const roomId = int(body.RoomId, Number.NaN) + if (!Number.isInteger(roomId)) return roomKeyEnvelope(c, null) + const name = str(body.Name).trim() + if (name === '') return roomKeyEnvelope(c, null) + + const canManage = await canManageRoomById(c.env.DB, roomId, accountId) + if (canManage === null) return roomKeyEnvelope(c, null) + if (!canManage) return c.body(null, 403) + + if ((await getRoomKeys(c.env.DB, roomId)).length >= MAX_KEYS_PER_ROOM) { + return roomKeyEnvelope(c, null) + } + + const key = await createRoomKey(c.env.DB, { + RoomId: roomId, + 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)), + }) + + await pushRoomKeyCreated(c, key, accountId) + return roomKeyEnvelope(c, key) + } ) // The Rec Room Plus sign-up bonus: which bonus is running and the token price window diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index dd5624e..a4d116a 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -624,6 +624,57 @@ export const CreateRoomCurrencyRequest = z.object({ Color: z.string().optional().describe('Coin colour index; defaults to 0'), }) +/** + * A room key — a named, priced admission ticket a room lists. The shape is the `RoomKey` the + * live client was observed reading out of the create response, member for member and in its + * order; the `LocalRoomKeyCreated` frame (the `notify` worker's `LocalRoomKeyPayload`) carries + * the same object. + * + * `RoomKeyId` is a NUMBER, unlike a room currency's or consumable's GUID — the client's model + * says so — with the `ReplicationId` GUID beside it. The create body's `Type` arrives as an + * enum NAME (`Key`) and is served as the ORDINAL (0). `ImageName` is null, not '', until a key + * can carry art. + */ +export const RoomKeyDto = z.object({ + RoomKeyId: z.int().describe('Autoincrement — how the client names this key afterwards'), + ReplicationId: z.string().describe('GUID, minted alongside the id'), + RoomId: z.int(), + Name: z.string(), + Description: z.string(), + Price: z.int(), + PurchaseCurrencyId: z + .string() + .nullable() + .describe('A `room_currency` id, or null — the create body names none'), + CreatedAt: z.string().describe('ISO-8601 UTC'), + ImageName: z.string().nullable().describe('Null — a key cannot carry art yet'), + Type: z.int().describe('The key type’s ordinal: 0 `Key`, the only one seen'), +}) + +/** + * The envelope `POST /api/roomkeys/v1/create` answers in: `{ Status, RoomKey }`, observed from + * the live client — NOT the `{ Value, Success, Error, error_id }` the room-currency and + * consumable writes use, though they live in the same worker. `Status` is 0 on success. A + * refusal answers a non-zero `Status` with a null `RoomKey`; only the success shape has been + * observed, so the refusal code (1) is an assumption. + */ +export const RoomKeyEnvelope = z.object({ + Status: z.int().describe('0 on success; 1 on a refusal (the refusal code is an assumption)'), + RoomKey: RoomKeyDto.nullable().describe('The key, or null on a refusal'), +}) + +/** `POST /api/roomkeys/v1/create` — form-encoded. */ +export const CreateRoomKeyRequest = z.object({ + Type: z + .string() + .optional() + .describe('The key type, as an enum NAME. Only `Key` has been seen; defaults to it'), + 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'), +}) + /** * 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 new file mode 100644 index 0000000..87f009c --- /dev/null +++ b/apps/econ/src/room-key-db.ts @@ -0,0 +1,148 @@ +/** + * 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: + * - `POST /api/roomkeys/v1/create` lists one + * - `GET /api/roomkeys/v1/room?roomId=` lists a room's + * + * This worker (`econ`) owns the table and its migrations — see apps/econ/migrations/0024. + */ + +/** Schema DDL — also builds the table in tests. Mirrors migration 0024. */ +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 + // `LocalRoomKeyPayload`). `replication_id` is the GUID beside it, minted here. + // + // `key_type` is the body's `Type`, stored as the enum NAME it arrives as (`Key`) and served + // 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 + // `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, + replication_id TEXT NOT NULL, + room_id INTEGER NOT NULL, + key_type TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + price INTEGER NOT NULL, + purchase_currency_id TEXT, + image_name TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL + )`, + // 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)`, +] + +/** + * The `Type` a key is served with: the ordinal of the client's key-type enum, keyed by the + * NAME the create body posts (`Type=Key`). Only `Key` has been observed; an unrecognised name + * is stored as posted and served as 0. + */ +export const ROOM_KEY_TYPE_ORDINALS: Readonly> = { Key: 0 } + +/** + * A room key as the client reads it — the `RoomKey` inside the create response's + * `{ Status, RoomKey }`, member for member and in its order (observed from the live client), + * and the `notify` worker's `LocalRoomKeyPayload`. The create endpoint answers this object and + * the `LocalRoomKeyCreated` frame carries it, which is why `econ.app.ts` assigns one of these + * straight to a `LocalRoomKeyPayload`: that assignment is what stops the two drifting apart. + * + * `Type` is the body's enum NAME served back as its ORDINAL (`Key` → 0), and `ImageName` is + * NULL rather than '' until a key can carry art — both as the client sends and reads them. + */ +export interface RoomKey { + RoomKeyId: number + ReplicationId: string + RoomId: number + Name: string + Description: string + Price: number + /** A `room_currency` id, or null — the create body names none. */ + PurchaseCurrencyId: string | null + /** ISO-8601 UTC. */ + CreatedAt: string + /** Null until a key can carry art. */ + ImageName: string | null + /** The key type's ordinal — 0 `Key`. See {@link ROOM_KEY_TYPE_ORDINALS}. */ + Type: number +} + +/** What the create endpoint supplies; the ids and the timestamp are minted here. */ +export interface NewRoomKey { + RoomId: number + /** The body's `Type`, an enum name (`Key`). Stored verbatim, served as its ordinal. */ + Type: string + Name: string + Description: string + Price: number +} + +interface RoomKeyRow { + room_key_id: number + replication_id: string + room_id: number + key_type: string + name: string + description: string + price: number + purchase_currency_id: string | null + image_name: string + created_at: string +} + +const SELECT_COLUMNS = `room_key_id, replication_id, room_id, key_type, name, description, price, purchase_currency_id, image_name, created_at` + +const toRoomKey = (row: RoomKeyRow): RoomKey => ({ + RoomKeyId: row.room_key_id, + ReplicationId: row.replication_id, + RoomId: row.room_id, + Name: row.name, + Description: row.description, + Price: row.price, + PurchaseCurrencyId: row.purchase_currency_id, + CreatedAt: row.created_at, + ImageName: row.image_name === '' ? null : row.image_name, + Type: ROOM_KEY_TYPE_ORDINALS[row.key_type] ?? 0, +}) + +/** List a room key, returning it as the client reads it back. */ +export async function createRoomKey(db: D1Database, key: NewRoomKey): Promise { + const row = await db + .prepare( + `INSERT INTO room_key + (replication_id, room_id, key_type, name, description, price, purchase_currency_id, image_name, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, '', ?7) + RETURNING ${SELECT_COLUMNS}` + ) + .bind( + crypto.randomUUID(), + key.RoomId, + key.Type, + key.Name, + key.Description, + key.Price, + new Date().toISOString() + ) + .first() + + return toRoomKey(row!) +} + +/** + * 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. + */ +export async function getRoomKeys(db: D1Database, roomId: number): Promise { + const { results } = await db + .prepare(`SELECT ${SELECT_COLUMNS} FROM room_key WHERE room_id = ?1 ORDER BY room_key_id`) + .bind(roomId) + .all() + + return results.map(toRoomKey) +} diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 5a1c1fd..89c4b9d 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -90,6 +90,7 @@ import { INVENTORY_SCHEMA_DDL } from '../../inventory-db' import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db' import { ROOM_CONSUMABLE_SCHEMA_DDL, ROOM_INVENTORY_SCHEMA_DDL } from '../../room-consumable-db' import { ROOM_BALANCE_SCHEMA_DDL, ROOM_CURRENCY_SCHEMA_DDL } from '../../room-currency-db' +import { ROOM_KEY_SCHEMA_DDL } from '../../room-key-db' import type { CatalogLoadRow, CatalogRow, CatalogValue, StoreListing } from '../../catalog-db' import type { Env } from '../../context' @@ -196,6 +197,7 @@ beforeAll(async () => { for (const stmt of ROOM_BALANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of ROOM_CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of ROOM_INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of ROOM_KEY_SCHEMA_DDL) await env.DB.prepare(stmt).run() // Presence (owned by the `match` worker) — a new room currency is pushed to everyone // standing in the room, which is read from here. for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() @@ -1922,10 +1924,201 @@ describe('econ endpoints', () => { }) }) - test('GET /api/roomkeys/v1/room returns []', async () => { - const res = await exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/room?roomId=1`) - expect(res.status).toBe(200) - expect(await res.json()).toEqual([]) + // Room 2511 is seeded as account 1's, with account 2 as co-owner. + describe('room keys', () => { + type RoomKey = { + RoomKeyId: number + ReplicationId: string + RoomId: number + Name: string + Description: string + Price: number + PurchaseCurrencyId: string | null + CreatedAt: string + ImageName: string | null + Type: number + } + + const create = async (fields: Record, headers?: Record) => + exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers }, + body: new URLSearchParams(fields), + }) + + const drainFrames = async (): Promise< + Array<{ + accountId: number + notificationType: string | number + payload: Record + }> + > => + ( + env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') as unknown as { + drainFrames(): Promise< + Array<{ + accountId: number + notificationType: string | number + payload: Record + }> + > + } + ).drainFrames() + + // The create's reply, as the live client reads it — NOT the `{ Value, Success, Error, + // error_id }` envelope the currency and consumable writes answer in. + const envOf = async (res: Response) => + (await res.json()) as { + Status: number + RoomKey: RoomKey | null + } + const keyOf = async (res: Response) => (await envOf(res)).RoomKey! + + const keysOf = async (roomId: number) => + (await ( + await exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/room?roomId=${roomId}`) + ).json()) as RoomKey[] + + // The body exactly as the client posts it. + const body = { + Type: 'Key', + RoomId: '2511', + Name: 'my key', + Description: 'testsdfsdfsdf', + Price: '50', + } + + test('GET /api/roomkeys/v1/room is public and empty for a room with none', async () => { + expect(await keysOf(2511)).toEqual([]) + expect(await keysOf(99999)).toEqual([]) + const res = await exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/room`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) + + test('POST create is gated to the room’s creator or a co-owner', async () => { + expect((await create(body)).status).toBe(401) + expect((await create(body, await bearer('999'))).status).toBe(403) + + for (const bad of [ + { ...body, RoomId: '99999' }, + { ...body, RoomId: 'nope' }, + { ...body, Name: ' ' }, + ]) { + const res = await create(bad, await bearer('1')) + expect(res.status).toBe(200) + expect(await envOf(res)).toEqual({ Status: 1, RoomKey: null }) + } + expect(await keysOf(2511)).toEqual([]) + }) + + test('POST create lists the key and the room read serves it back', async () => { + const res = await create(body, await bearer('1')) + expect(res.status).toBe(200) + const envelope = await envOf(res) + expect(Object.keys(envelope)).toEqual(['Status', 'RoomKey']) + expect(envelope.Status).toBe(0) + const created = envelope.RoomKey! + // The key as the live client reads it, member for member and in its order. + expect(Object.keys(created)).toEqual([ + 'RoomKeyId', + 'ReplicationId', + 'RoomId', + 'Name', + 'Description', + 'Price', + 'PurchaseCurrencyId', + 'CreatedAt', + 'ImageName', + 'Type', + ]) + expect(created).toMatchObject({ + RoomId: 2511, + Name: 'my key', + Description: 'testsdfsdfsdf', + Price: 50, + PurchaseCurrencyId: null, + // Null, not '', until a key carries art. + ImageName: null, + // The posted enum NAME (`Key`) comes back as its ordinal. + Type: 0, + }) + // A NUMERIC id, with a GUID beside it. + expect(Number.isInteger(created.RoomKeyId)).toBe(true) + expect(created.ReplicationId).toMatch(/^[0-9a-f-]{36}$/) + expect(new Date(created.CreatedAt).toISOString()).toBe(created.CreatedAt) + + // The body's Type is stored as the name it arrived as. + const row = await env.DB.prepare('SELECT key_type FROM room_key WHERE room_key_id = ?1') + .bind(created.RoomKeyId) + .first<{ key_type: string }>() + expect(row?.key_type).toBe('Key') + + expect(await keysOf(2511)).toEqual([created]) + + // A co-owner may list one too, and the list is oldest first. + const second = await keyOf(await create({ ...body, Name: 'second' }, await bearer('2'))) + expect(second.RoomKeyId).toBeGreaterThan(created.RoomKeyId) + expect((await keysOf(2511)).map((k) => k.Name)).toEqual(['my key', 'second']) + }) + + test('POST create pushes LocalRoomKeyCreated to the room and the creator', async () => { + const now = Math.floor(Date.now() / 1000) + const putInRoom = async (accountId: number, roomId: number) => + env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)') + .bind( + JSON.stringify({ + accountId, + roomInstance: { roomInstanceId: 1000000 + roomId, roomId, subRoomId: roomId }, + expiresAt: now + 900, + }) + ) + .run() + await putInRoom(320, 2511) + await putInRoom(321, 1) + await drainFrames() + + const created = await keyOf(await create({ ...body, Name: 'pushed' }, await bearer('1'))) + const frames = await drainFrames() + expect(frames.map((f) => f.accountId).sort((a, b) => a - b)).toEqual([1, 320]) + expect(frames.every((f) => f.notificationType === NotificationType.LocalRoomKeyCreated)).toBe( + true + ) + // The frame IS the create response. + expect(frames[0].payload).toEqual(created) + + for (const id of [320, 321]) { + await env.DB.prepare('DELETE FROM presence WHERE account_id = ?1').bind(id).run() + } + }) + + test('POST create masks a name players will see, and defaults the rest', async () => { + const res = await create({ RoomId: '2511', Name: 'shit key' }, await bearer('1')) + const created = await keyOf(res) + expect(created.Name).toBe('**** key') + expect(created).toMatchObject({ Description: '', Price: 0, Type: 0 }) + const row = await env.DB.prepare('SELECT key_type FROM room_key WHERE room_key_id = ?1') + .bind(created.RoomKeyId) + .first<{ key_type: string }>() + expect(row?.key_type).toBe('Key') + // A negative price is floored at nothing. + const free = await keyOf(await create({ ...body, Price: '-5' }, await bearer('1'))) + expect(free.Price).toBe(0) + }) + + 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) { + const res = await create({ ...body, Name: 'filler' }, await bearer('1')) + expect((await envOf(res)).Status).toBe(0) + } + // …and the next is refused, leaving the room at ten. + const res = await create({ ...body, Name: 'one too many' }, await bearer('1')) + expect(res.status).toBe(200) + expect(await envOf(res)).toEqual({ Status: 1, RoomKey: null }) + expect((await keysOf(2511)).length).toBe(10) + await env.DB.prepare('DELETE FROM room_key').run() + }) }) test('GET /api/roomconsumables/v1/roomConsumable/room/:id/me returns []', async () => { @@ -5745,6 +5938,7 @@ describe('econ endpoints', () => { 'POST /api/roomcurrencies/v1/createCurrency', 'POST /api/roomcurrencies/v1/createPurchaseOffer', 'POST /api/roomcurrencies/v1/updateCurrency', + 'POST /api/roomkeys/v1/create', 'POST /api/storefronts/v2/buyItem', 'POST /api/storefronts/v3/buyInvention', 'POST /api/ugcPurchasables/v1/items/bulk', diff --git a/apps/notify/src/notification-payloads.ts b/apps/notify/src/notification-payloads.ts index 6a12314..4c6ae8d 100644 --- a/apps/notify/src/notification-payloads.ts +++ b/apps/notify/src/notification-payloads.ts @@ -405,7 +405,10 @@ export interface RoomCurrencyDeletedPayload { CurrencyId: string } -/** `LocalRoomKeyCreated` (120). */ +/** + * `LocalRoomKeyCreated` (120) — the same `RoomKey` object the create endpoint answers inside + * its `{ Status, RoomKey }` envelope; `econ` sends the one record on both. + */ export interface LocalRoomKeyPayload { RoomKeyId: number ReplicationId: string @@ -416,7 +419,10 @@ export interface LocalRoomKeyPayload { PurchaseCurrencyId: string | null /** ISO-8601. */ CreatedAt: string - ImageName: string + /** Null until a key carries art. */ + ImageName: string | null + /** The key type's ordinal — 0 `Key`. */ + Type: number } /** `LocalRoomKeyDeleted` (121). */