[econ] purchase offer delete

This commit is contained in:
Devin Zuczek
2026-10-08 13:03:26 -04:00
parent c6e035e4e1
commit 7ec295ef33
4 changed files with 235 additions and 4 deletions
+63 -2
View File
@@ -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<App>, 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<App>,
@@ -4211,6 +4214,64 @@ const app = new Hono<App>({ 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<string, unknown>
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(
+10 -2
View File
@@ -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(),
+44
View File
@@ -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<FoundPurchaseOffer | null> {
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
+118
View File
@@ -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<string, string>, 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',