[api] delete invention

This commit is contained in:
Devin Zuczek
2026-08-31 12:58:07 -04:00
parent 5928e07509
commit 740e9efa09
4 changed files with 273 additions and 0 deletions
+49
View File
@@ -973,6 +973,55 @@ export async function setInventionPrice(
return updated
}
/**
* Delete an invention (`v2/delete`), returning the record that was removed, or null
* when there's no such row. The whole invention lives in the one JSON blob, so its
* versions, tags and referenced-invention lists go with it in a single DELETE.
*
* Two things are deliberately LEFT behind.
*
* The data blob in R2 stays: it is named by the file the creator uploaded through the
* `storage` worker, and nothing here knows whether another record still points at that
* name (a converted invention carries the same lineage, and a save that reuses a
* filename reuses the object). An orphan blob costs storage; a missing one breaks
* whatever still references it.
*
* The `inventory_invention` rows stay too — deleting a creator's invention must not
* rewrite what other players bought. They already fall out of every list on their own:
* `getMyInventions` resolves owned ids against this table and an id with no row left
* simply drops out, and `ownsAllInventions` reads a missing row as not-owned. Purging
* them would also erase the acquisition history that ranks the "top today" feed.
*/
export async function deleteInvention(
db: D1Database,
inventionId: number
): Promise<SavedInvention | null> {
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
await db.prepare('DELETE FROM invention WHERE id = ?1').bind(inventionId).run()
return invention
}
/**
* What `v2/delete` answers: the same `{ Value, Success, Error, error_id }` envelope the
* other newer-client invention routes use, with `Value` always NULL — the invention is
* gone, so there is nothing for the client to redraw from and it reads only `Success`
* (and `Error`, the one string that reaches a human). This is why the delete does not
* borrow {@link InventionSaveV9Result}: that envelope's `Value` carries an invention the
* client dereferences, and a delete has none to give.
*/
export interface InventionDeleteResult {
Value: null
Success: boolean
Error: string | null
error_id: string | null
}
/** The delete envelope: a refusal when given a message, success when given null. */
export function inventionDeleteResult(error: string | null = null): InventionDeleteResult {
return { Value: null, Success: error === null, Error: error, error_id: null }
}
/** The tag filter chips the client offers when browsing inventions. */
export interface InventionTagFilters {
PinnedFilters: string[]
+18
View File
@@ -666,6 +666,24 @@ export const PublishInventionRequest = z.object({
.describe('Price in tokens; null leaves it as it is, and a negative one is ignored'),
})
/** `POST /api/inventions/v2/delete` JSON body — the id and nothing else. */
export const DeleteInventionRequest = z.object({
InventionId: z.int().describe('The invention to delete; the caller must have created it'),
})
/**
* What `v2/delete` answers — the same `{ Value, Success, Error, error_id }` envelope the
* other newer-client invention routes use, with `Value` always null. The invention is
* gone, so there is nothing for the client to redraw from: it reads `Success`, and
* `Error` when that is false.
*/
export const InventionDeleteResult = z.object({
Value: z.null().describe('Always null — the invention no longer exists'),
Success: z.boolean(),
Error: z.string().nullable().describe('The refusal message; null on success'),
error_id: z.string().nullable().describe('Always null'),
})
/** `POST /api/inventions/v1/updateprice` JSON body. */
export const UpdatePriceRequest = z.object({
InventionId: z.int(),
+64
View File
@@ -27,6 +27,7 @@ import {
import { authedId, unauthorized } from '../http'
import {
createInvention,
deleteInvention,
getFeaturedInventions,
getInventionById,
getInventionsByIds,
@@ -37,6 +38,7 @@ import {
getMyInventions,
getTopInventions,
INVENTION_TAG_RESULT,
inventionDeleteResult,
inventionSaveV9Failure,
normalizeInventionTags,
ownsAllInventions,
@@ -59,12 +61,14 @@ import {
CustomAvatarItemReportRequest,
CustomAvatarItemResponse,
CustomAvatarItemsPage,
DeleteInventionRequest,
ErrorResponse,
form,
GeneratedGift,
GenerateGiftRequest,
idParam,
intQuery,
InventionDeleteResult,
InventionDetails,
InventionDto,
InventionPersonalDetails,
@@ -2030,3 +2034,63 @@ export const avatarRoutes = new Hono<App>({ strict: false })
return c.json(toSaveResultV9(published, published.Tags ?? []))
}
)
// Delete an invention. The newer client's shape: a POST with a PascalCase body
// carrying nothing but the id. Auth-gated, creator only — the only thing that may
// remove an invention is the account that made it, not a co-owner and not a buyer.
//
// The record and everything inside it (versions, tags, referenced-invention lists)
// go in one DELETE; the data blob in R2 and the `inventory_invention` rows of
// players who bought it are left alone. See `deleteInvention` for why.
.post(
'/api/inventions/v2/delete',
describeRoute({
tags: ['Inventions'],
summary: 'Delete an invention',
description:
'Creator only — a buyer or a co-owner cannot delete someone elses invention. ' +
'The record goes entirely: its versions, tags and referenced-invention lists live ' +
'in the same row.\n\n' +
'What survives is deliberate. The data blob stays in storage, because nothing ' +
'here knows whether another record still points at that filename. The ownership ' +
'rows of players who bought it stay too — a delete must not rewrite what someone ' +
'else paid for — and they fall out of every list on their own, since an owned id ' +
'with no invention row behind it is skipped.\n\n' +
'Answers the `{ Value, Success, Error, error_id }` envelope the other v2+ ' +
'invention routes use, with `Value` NULL: the invention is gone, so there is ' +
'nothing to redraw from and the client reads only `Success`. Refusals — an ' +
'unknown invention and someone elses alike — are `Success: false` with a ' +
'message, not a bare error body that client cannot parse.',
security: AUTHED,
requestBody: jsonBody(DeleteInventionRequest, 'The invention to delete'),
responses: {
200: json(InventionDeleteResult, 'The delete envelope, `Value` null either way'),
401: json(InventionDeleteResult, 'The same envelope, refused — not an empty body'),
},
}),
async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json(inventionDeleteResult('Invalid request body'))
// The id rides in the body, as it does on `v2/metadata` and `v4/publish`.
const gate = await creatorsInventionResult(
c,
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
)
// As on those two: only a missing token is a transport failure. An unknown
// invention or someone else's is a domain answer the client reads out of the
// envelope, where the message reaches a human.
if ('rejection' in gate) {
return gate.status === 401
? c.json(inventionDeleteResult(gate.rejection), 401)
: c.json(inventionDeleteResult(gate.rejection))
}
// The gate already loaded the row, so a null here is a race — someone deleted it
// between the two reads — and lands where the client would put it anyway: gone.
const deleted = await deleteInvention(c.env.DB, gate.invention.InventionId)
return c.json(
deleted === null ? inventionDeleteResult('No such invention') : inventionDeleteResult()
)
}
)
+142
View File
@@ -2365,6 +2365,147 @@ describe('public endpoints', () => {
})
})
test('POST /api/inventions/v2/delete removes the creators invention', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5180')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Delete Me',
inventionDataFilename: 'delete-me.inv',
tagsRequest: { AutoTags: ['small'], CustomTags: null },
}),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
expect(inventionId).toBeGreaterThan(0)
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5180')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
expect(res.status).toBe(200)
// `Value` is null even on success — there is no invention left to redraw from.
expect(await res.json()).toEqual({ Value: null, Success: true, Error: null, error_id: null })
// Gone from the read and from the creator's shelf.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect(one.status).toBe(404)
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer('5180'),
})
expect(((await mine.json()) as SavedInvention[]).map((i) => i.InventionId)).not.toContain(
inventionId
)
// And the row itself, tags and all, rather than a hidden record still taking the id.
const row = await env.DB.prepare('SELECT COUNT(*) AS n FROM invention WHERE id = ?1')
.bind(inventionId)
.first<{ n: number }>()
expect(row?.n).toBe(0)
// Deleting it twice is a refusal, not a second success: the id resolves to nothing.
const again = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5180')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
expect(await again.json()).toEqual({
Value: null,
Success: false,
Error: 'No such invention',
error_id: null,
})
})
test('POST /api/inventions/v2/delete refuses anyone but the creator, in-band', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5181')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Not Yours To Bin', inventionDataFilename: 'not-yours.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
// 5182 BOUGHT it — owning a copy is still not the right to delete it.
await grantInvention(env.DB, 5182, inventionId as number)
const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5182')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
expect(theirs.status).toBe(200)
expect(await theirs.json()).toEqual({
Value: null,
Success: false,
Error: 'Not your invention',
error_id: null,
})
const missing = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5181')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: 987_655 }),
})
expect(missing.status).toBe(200)
expect(await missing.json()).toEqual({
Value: null,
Success: false,
Error: 'No such invention',
error_id: null,
})
// A missing token is the one refusal that stays a transport failure — and it still
// answers the envelope rather than a bare error body.
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
expect(anon.status).toBe(401)
expect(await anon.json()).toMatchObject({ Value: null, Success: false })
// Still there throughout.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect(one.status).toBe(200)
})
test('POST /api/inventions/v2/delete leaves a buyers ownership row behind', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5183')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Sold Then Binned', inventionDataFilename: 'sold.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention
.InventionId as number
await grantInvention(env.DB, 5184, inventionId)
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5183')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
expect((await res.json()) as { Success: boolean }).toMatchObject({ Success: true })
// The purchase record is not rewritten by someone else's delete...
const owned = await env.DB.prepare(
'SELECT COUNT(*) AS n FROM inventory_invention WHERE invention_id = ?1'
)
.bind(inventionId)
.first<{ n: number }>()
expect(owned?.n).toBe(1)
// ...but with no invention row behind it, it drops out of the buyer's shelf anyway.
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer('5184'),
})
expect(((await mine.json()) as SavedInvention[]).map((i) => i.InventionId)).not.toContain(
inventionId
)
})
test('GET /api/inventions/v2/mine lists bought inventions alongside the callers own', async () => {
// Account 6100 creates one; 6101 buys it (the econ worker's buyInvention writes
// exactly this row) and also creates one of their own.
@@ -6874,6 +7015,7 @@ describe('openapi', () => {
'POST /api/inventions/v1/settags',
'POST /api/inventions/v1/update',
'POST /api/inventions/v1/updateprice',
'POST /api/inventions/v2/delete',
'POST /api/inventions/v4/publish',
'POST /api/inventions/v6/save',
'POST /api/inventions/v9/save',