diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index f044f35..c3d3010 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -106,6 +106,7 @@ import { CreateRoomCurrencyRequest, CreateRoomKeyRequest, CustomAvatarItemsResponse, + DeletePurchaseOfferRequest, EquipmentUpdateRequest, ErrorResponse, form, @@ -170,6 +171,7 @@ import { awardRoomCurrency, createPurchaseOffer, createRoomCurrency, + deletePurchaseOffer, findPurchaseOffer, getPurchaseOffers, getRoomBalance, @@ -348,6 +350,7 @@ const HUB_INSTANCE = 'global' 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 DELETE_OFFER_FAILED = 'Failed to delete 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 @@ -415,8 +418,8 @@ function roomConsumableEnvelope(c: Context, value: RoomConsumable | null, e } /** - * The create-offer envelope — the same `{ Value, Success, Error, error_id }` shape again, - * carrying one purchase offer. + * The create- and delete-offer envelope — the same `{ Value, Success, Error, error_id }` + * shape again, carrying one purchase offer. */ function purchaseOfferEnvelope( c: Context, @@ -4211,6 +4214,64 @@ const app = new Hono({ strict: false }) } ) + // Take a purchase offer down. Auth-gated (401) and gated to the creator or a co-owner of + // the room that minted the currency the offer sells (403). The body names ONLY the offer, + // so the currency — and through it the room — is found by walking the shops for the id; + // nothing about the caller's whereabouts is consulted. + .post( + '/api/roomcurrencies/v1/deletePurchaseOffer', + describeRoute({ + tags: ['Econ'], + summary: 'Remove a purchase offer from a room currency', + description: [ + 'Removes the offer named by `PurchaseOfferId` (its `CurrencyPurchaseOfferId`) from', + 'whichever currency’s shop lists it. Form-encoded (`PurchaseOfferId=…`). The body', + 'names nothing else: the currency, and the room that gates the delete, are found', + 'from the offer.', + '', + 'Gated to the creator or a co-owner of the room that minted the currency. A valid', + 'token from anyone else is a 403. Answers the same', + '`{ Value, Success, Error, error_id }` envelope the create answers in, with the', + 'offer as it stood when removed in `Value`, or a 200 carrying `Success: false` when', + 'no shop lists that id. What the client does with `Value` here is an assumption —', + 'only the create’s reply has been observed.', + ].join('\n'), + security: AUTHED, + requestBody: form(DeletePurchaseOfferRequest, 'The offer to remove'), + responses: { + 200: json( + RoomCurrencyPurchaseOfferEnvelope, + 'The offer as removed, or a rejection with `Success: false`' + ), + 401: UNAUTHORIZED_RESPONSE, + 403: { description: 'Not the creator or a co-owner of the minting room (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 purchaseOfferId = ( + typeof body.PurchaseOfferId === 'string' ? body.PurchaseOfferId : '' + ).trim() + if (purchaseOfferId === '') return purchaseOfferEnvelope(c, null, DELETE_OFFER_FAILED) + + // Found BEFORE it is removed, because the find is what says which room gates the + // delete: an unknown id is a refusal, and a known one is somebody's to refuse. + const found = await findPurchaseOffer(c.env.DB, purchaseOfferId) + if (!found) return purchaseOfferEnvelope(c, null, DELETE_OFFER_FAILED) + + const canManage = await canManageRoomById(c.env.DB, found.RoomId, accountId) + if (canManage === null) return purchaseOfferEnvelope(c, null, DELETE_OFFER_FAILED) + if (!canManage) return c.body(null, 403) + + const removed = await deletePurchaseOffer(c.env.DB, purchaseOfferId) + if (!removed) return purchaseOfferEnvelope(c, null, DELETE_OFFER_FAILED) + return purchaseOfferEnvelope(c, removed.Offer) + } + ) + // Award room currency to players, several awards per call. Auth-gated (401); everything // else is reported PER ENTRY, including whether the caller may award that currency at all. .post( diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index 628caf6..d5b662c 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -1071,9 +1071,17 @@ export const CreatePurchaseOfferRequest = z.object({ Order: z.string().optional().describe('Where it sits in the shop; defaults to 0'), }) +/** `POST /api/roomcurrencies/v1/deletePurchaseOffer` — form-encoded. */ +export const DeletePurchaseOfferRequest = z.object({ + PurchaseOfferId: z + .string() + .describe('The offer to take down, by its `CurrencyPurchaseOfferId` (any letter case)'), +}) + /** - * The envelope the create-offer endpoint answers in — the same - * `{ Value, Success, Error, error_id }` the currency writes use, with the offer in `Value`. + * The envelope the create- and delete-offer endpoints answer in — the same + * `{ Value, Success, Error, error_id }` the currency writes use, with the offer (as created, + * or as it stood when removed) in `Value`. */ export const RoomCurrencyPurchaseOfferEnvelope = z.object({ Value: RoomCurrencyPurchaseOfferDto.nullable(), diff --git a/apps/econ/src/room-currency-db.ts b/apps/econ/src/room-currency-db.ts index b729a82..d9e0cd2 100644 --- a/apps/econ/src/room-currency-db.ts +++ b/apps/econ/src/room-currency-db.ts @@ -14,6 +14,8 @@ * - `GET /api/roomcurrencies/v1/currencies?roomId=` lists a room's * - `POST /api/roomcurrencies/v1/awardCurrency/bulk` pays players * - the purchase offers below are the shop that sells the currency for other money + * (`POST /api/roomcurrencies/v1/createPurchaseOffer` lists one, `…/deletePurchaseOffer` + * takes it down) * - `POST /api/roomCurrencies/v2/purchase` buys from that shop, with tokens * - `POST /api/storefronts/v1/PurchaseRoomKeyWithCurrency` spends it, on a room key * @@ -419,6 +421,48 @@ export async function findPurchaseOffer( return offer ? { Offer: offer, RoomId: row.room_id } : null } +/** + * Take a purchase offer down by its `CurrencyPurchaseOfferId`, returning the offer that was + * removed — beside its room, like {@link findPurchaseOffer} — or null when no shop lists it. + * + * The id is all the delete names, and the offer lives inside its currency's `purchase_offers` + * JSON, so this is a find (which says which row, and which room gates the delete) and then a + * rewrite of that ONE row's array without the offer. The rewrite is done in SQL — + * `json_group_array` over `json_each`, keeping every entry whose id is not this one — rather + * than read-modify-written here, so an offer being added to the same shop at the same moment + * is not lost under a stale copy of the list; `createPurchaseOffer` appends in SQL for the same + * reason. Filtered by id, not by position, so a concurrent append cannot shift the wrong entry + * under the delete. The match is case-insensitive like the find: the client has been seen + * sending ids in either case. + * + * A shop emptied by its last delete is left as `[]`, not reset to null: both read as "no + * offers", and nothing distinguishes a shop never built from one cleared out. + */ +export async function deletePurchaseOffer( + db: D1Database, + purchaseOfferId: string +): Promise { + const found = await findPurchaseOffer(db, purchaseOfferId) + if (!found) return null + + const { meta } = await db + .prepare( + `UPDATE room_currency + SET purchase_offers = ( + SELECT json_group_array(json(o.value)) + FROM json_each(room_currency.purchase_offers) AS o + WHERE lower(json_extract(o.value, '$.CurrencyPurchaseOfferId')) <> lower(?2) + ) + WHERE currency_id = ?1 AND json_valid(purchase_offers)` + ) + .bind(found.Offer.CurrencyId, purchaseOfferId) + .run() + + // The find said the row was there; a rewrite that touched nothing means it went away + // between the two (the currency deleted, say), and there is no offer to report removed. + return meta.changes > 0 ? found : null +} + /** One currency's shop — the group the batch read answers in, one per currency asked for. */ export interface RoomCurrencyPurchaseOffers { CurrencyId: string diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index b90e4d6..74291d3 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -2079,6 +2079,123 @@ describe('econ endpoints', () => { ]) }) + test('POST deletePurchaseOffer takes one offer out of its shop, gated to the room', async () => { + type Offer = { + CurrencyPurchaseOfferId: string + CurrencyId: string + Order: number + Name: string + CurrencyAmount: number + Price: number + ModifiedAt: string + } + type Envelope = { + Value: Offer | null + Success: boolean + Error: string | null + error_id: null + } + const post = async (path: string, fields: Record, sub: string | null) => + exports.default.fetch(`${ORIGIN}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(sub === null ? {} : await bearer(sub)), + }, + body: new URLSearchParams(fields), + }) + const addOffer = async (currencyId: string, name: string, order: number) => + ( + (await ( + await post( + '/api/roomcurrencies/v1/createPurchaseOffer', + { + CurrencyId: currencyId, + Name: name, + Amount: '5', + Price: '50', + Order: String(order), + }, + '1' + ) + ).json()) as Envelope + ).Value! + const remove = (offerId: string, sub: string | null) => + post('/api/roomcurrencies/v1/deletePurchaseOffer', { PurchaseOfferId: offerId }, sub) + const shopOf = async (currencyId: string) => + ( + (await ( + await exports.default.fetch( + `${ORIGIN}/api/roomcurrencies/v1/getPurchaseOffersBatch?ids=${currencyId}` + ) + ).json()) as Array<{ PurchaseOffers: Offer[] }> + )[0].PurchaseOffers.map((o) => o.Name) + const refused = { + Value: null, + Success: false, + Error: 'Failed to delete purchase offer', + error_id: null, + } + + // Two shops in room 2511, so the delete has to find the right one from the id alone. + const shopped = (await envOf(await create({ ...body, Name: 'Shopped' }, await bearer('1')))) + .Value! + const other = (await envOf(await create({ ...body, Name: 'Other' }, await bearer('1')))) + .Value! + const first = await addOffer(shopped.CurrencyId, 'First', 0) + const second = await addOffer(shopped.CurrencyId, 'Second', 1) + const third = await addOffer(shopped.CurrencyId, 'Third', 2) + const elsewhere = await addOffer(other.CurrencyId, 'Elsewhere', 0) + + // No token → 401; a valid token with no standing in the room → 403, and the offer + // stays. An id no shop lists, or none at all, is a refusal in the envelope. + expect((await remove(second.CurrencyPurchaseOfferId, null)).status).toBe(401) + expect((await remove(second.CurrencyPurchaseOfferId, '999')).status).toBe(403) + expect(await shopOf(shopped.CurrencyId)).toEqual(['First', 'Second', 'Third']) + for (const id of [crypto.randomUUID(), '']) { + const res = await remove(id, '1') + expect(res.status).toBe(200) + expect(await res.json()).toEqual(refused) + } + + // The co-owner removes the middle one: the answer is the offer as it stood, the + // shop keeps its neighbours in order, and the other currency's shop is untouched. + const res = await remove(second.CurrencyPurchaseOfferId, '2') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + Value: second, + Success: true, + Error: null, + error_id: null, + }) + expect(await shopOf(shopped.CurrencyId)).toEqual(['First', 'Third']) + expect(await shopOf(other.CurrencyId)).toEqual(['Elsewhere']) + // What is left is still what the create wrote, not a re-encoding of it. + expect( + (await ( + await exports.default.fetch( + `${ORIGIN}/api/roomcurrencies/v1/getPurchaseOffersBatch?ids=${shopped.CurrencyId}` + ) + ).json()) as Array<{ PurchaseOffers: Offer[] }> + ).toEqual([{ CurrencyId: shopped.CurrencyId, PurchaseOffers: [first, third] }]) + + // Gone means gone: a second delete is the unknown-id refusal. The id matches in any + // letter case, like the purchase's lookup. + expect(await (await remove(second.CurrencyPurchaseOfferId, '1')).json()).toEqual(refused) + expect( + ( + (await ( + await remove(third.CurrencyPurchaseOfferId.toUpperCase(), '1') + ).json()) as Envelope + ).Value?.Name + ).toBe('Third') + // Emptying a shop leaves it an empty shop, still listed. + await remove(first.CurrencyPurchaseOfferId, '1') + expect(await shopOf(shopped.CurrencyId)).toEqual([]) + expect(await shopOf(other.CurrencyId)).toEqual(['Elsewhere']) + expect(elsewhere.Name).toBe('Elsewhere') + }) + test('POST createCurrency pushes RoomCurrencyCreated to the room and the creator', async () => { // Two players standing in 2511, one somewhere else, and one whose presence lapsed. const now = Math.floor(Date.now() / 1000) @@ -7048,6 +7165,7 @@ describe('econ endpoints', () => { 'POST /api/roomcurrencies/v1/awardCurrency/bulk', 'POST /api/roomcurrencies/v1/createCurrency', 'POST /api/roomcurrencies/v1/createPurchaseOffer', + 'POST /api/roomcurrencies/v1/deletePurchaseOffer', 'POST /api/roomcurrencies/v1/updateCurrency', 'POST /api/roomkeys/v1/awardbulk', 'POST /api/roomkeys/v1/create',