From 740e9efa099bf9696c2c3c45523f31c7a72902e2 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 31 Aug 2026 12:58:07 -0400 Subject: [PATCH] [api] delete invention --- apps/api/src/inventions-db.ts | 49 ++++++++ apps/api/src/openapi.ts | 18 +++ apps/api/src/routes/avatar.ts | 64 ++++++++++ apps/api/src/test/integration/api.test.ts | 142 ++++++++++++++++++++++ 4 files changed, 273 insertions(+) diff --git a/apps/api/src/inventions-db.ts b/apps/api/src/inventions-db.ts index c18a69d..75df0c1 100644 --- a/apps/api/src/inventions-db.ts +++ b/apps/api/src/inventions-db.ts @@ -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 { + 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[] diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index fbdfe6a..77fdff6 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -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(), diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index cf5016c..4d97966 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -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({ 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 else’s 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 else’s 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 | 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() + ) + } + ) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 6b59e51..b619909 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -2365,6 +2365,147 @@ describe('public endpoints', () => { }) }) + test('POST /api/inventions/v2/delete removes the creator’s 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 buyer’s 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 caller’s 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',