From da27b7e797597b9c68078530053a61910adcd3cf Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 12 Aug 2026 12:07:46 -0400 Subject: [PATCH] [invention] fix inventions of inventions --- apps/api/src/inventions-db.ts | 42 +++++++++++ apps/api/src/routes/avatar.ts | 92 ++++++++++++++++++----- apps/api/src/test/integration/api.test.ts | 87 ++++++++++++++++++++- 3 files changed, 200 insertions(+), 21 deletions(-) diff --git a/apps/api/src/inventions-db.ts b/apps/api/src/inventions-db.ts index 75e2037..cd695a4 100644 --- a/apps/api/src/inventions-db.ts +++ b/apps/api/src/inventions-db.ts @@ -301,6 +301,48 @@ export async function getMyInventions(db: D1Database, playerId: number): Promise ) } +/** + * Whether a player owns EVERY invention in a list — the `v1/fulllineageowner` check, + * which the client runs when saving an invention BUILT OUT OF other inventions: it is + * asking whether this player may use each piece. An invention is the player's if they + * created it (`CreatorPlayerId`) or acquired it (a row in `inventory_invention`); an id + * with no invention row is not owned, so a deleted or made-up id makes the whole answer + * false. + * + * Ownership is the whole test — price and `GeneralPermission` deliberately don't enter + * into it. A free invention still has to be picked up before it can be used, and econ's + * buyInvention writes the same inventory row for a 0-token acquisition as for a paid + * one, so "acquired" already covers "free". Reading permission here as a second way to + * qualify would let a player build on an invention they never took. + * + * The lineage is whatever the CLIENT asks about: it sends the invention plus every + * invention nested inside it as repeated `id`s, so this checks exactly the ids given + * and does not walk `ReferencedInventions` itself. Walking it here would answer a + * different question than the one asked — the client knows which pieces the thing it + * is holding is actually made of, and stale references on an old record don't. + * + * An empty list is owned: no invention in it is unowned. The client never asks that, + * but false would read as "you don't own something" with nothing to name. + */ +export async function ownsAllInventions( + db: D1Database, + playerId: number, + inventionIds: number[] +): Promise { + if (inventionIds.length === 0) return true + + // The client repeats an id when the same invention is nested more than once. + const unique = [...new Set(inventionIds)] + const [inventions, ownedIds] = await Promise.all([ + getInventionsByIds(db, unique), + getOwnedInventionIds(db, playerId), + ]) + + const bought = new Set(ownedIds) + const creators = new Map(inventions.map((i) => [i.InventionId, i.CreatorPlayerId])) + return unique.every((id) => creators.get(id) === playerId || (creators.has(id) && bought.has(id))) +} + /** * Invention search — the browse/search list the client shows when picking an * invention to spawn. Only published, non-hidden inventions are visible here (a diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index a0cfda9..a5f77cd 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -19,6 +19,7 @@ import { getInventionVersion, getMyInventions, getTopInventions, + ownsAllInventions, parsePermissionLevel, publishInvention, searchInventions, @@ -82,6 +83,20 @@ async function creatorsInvention( return { invention } } +/** + * The `?id=1&id=2` list the invention batch endpoints take. `id` repeats, and each + * value may itself be a comma-separated list; anything non-numeric is dropped. + */ +function inventionIdQuery(c: Context): number[] { + return ( + c.req + .queries('id') + ?.flatMap((raw) => raw.split(',')) + .map((raw) => Number.parseInt(raw.trim(), 10)) + .filter((id) => !Number.isNaN(id)) ?? [] + ) +} + // ---- Avatar gifts ---------------------------------------------------------- // The avatar read endpoints (`v4/items`, `v2`, `v2/set`, `v3/saved`, `v2/gifts`) and // gift-box consume live in the `econ` worker, which the client calls on the econ host @@ -276,12 +291,8 @@ export const avatarRoutes = new Hono({ strict: false }) responses: { 200: json(InventionDto.array(), 'The inventions the caller may see') }, }), async (c) => { - const ids = c.req - .queries('id') - ?.flatMap((raw) => raw.split(',')) - .map((raw) => Number.parseInt(raw.trim(), 10)) - .filter((id) => !Number.isNaN(id)) - if (ids === undefined || ids.length === 0) return c.json([]) + const ids = inventionIdQuery(c) + if (ids.length === 0) return c.json([]) const playerId = await authedId(c) const inventions = await getInventionsByIds(c.env.DB, ids) @@ -293,6 +304,40 @@ export const avatarRoutes = new Hono({ strict: false }) } ) + // Whether the caller owns every invention in a lineage (`?id=101&id=102&id=103`) — + // the invention plus everything nested inside it, as the client enumerates it. One + // bare `true`/`false` for the whole set, not a verdict per id. Auth-gated: the + // question is about the caller. + .get( + '/api/inventions/v1/fulllineageowner', + describeRoute({ + tags: ['Inventions'], + summary: 'Does the caller own this whole lineage?', + description: + 'Asked when saving an invention built out of other inventions: may this player use ' + + 'every piece? The client sends the whole lineage as repeated `id`s, and this ' + + 'answers a single bare `true`/`false` for the set — false as soon as one is not the ' + + 'caller’s. An invention is theirs if they created it or acquired it; an id with no ' + + 'invention behind it is not owned. Price and permission don’t enter into it — a ' + + 'free invention still has to be picked up, and that writes the same inventory row ' + + 'a paid one does.\n\n' + + 'Only the ids asked about are checked — this does not walk `ReferencedInventions` ' + + 'to widen the lineage, since the client knows what the thing it is holding is ' + + 'actually made of. No ids at all is `true`: nothing in an empty lineage is unowned.', + security: AUTHED, + parameters: [intQuery('id', 'Repeatable; each value may be a comma-separated list of ids')], + responses: { + 200: json(BareBoolean, 'Whether the caller owns every invention asked about'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const playerId = await authedId(c) + if (playerId === null) return unauthorized(c) + return c.json(await ownsAllInventions(c.env.DB, playerId, inventionIdQuery(c))) + } + ) + // A room's inventions (`?id=76`) — published inventions created in that room, // newest first. Paginated via skip/take (take defaults to 100). Bare array. .get( @@ -377,24 +422,26 @@ export const avatarRoutes = new Hono({ strict: false }) } ) - // Edit an invention's metadata. A GET that writes — that's what the client sends - // (`?inventionId=1&description=my+description`), with the fields to change as - // query params. Absent params keep their stored value; `permission` sets what - // other players may do with it (a name like `useonly` or the raw number). An - // empty `description` clears it, but an empty `name`/`imageName` is ignored - // rather than blanking the invention. Publishing and pricing are separate - // endpoints. Auth-gated, creator only; answers the save envelope. - .get( + // Edit an invention's metadata. The fields to change ride as QUERY PARAMS on both + // verbs (`?inventionId=1&description=my+description`) — the client sends this as a + // GET that writes in some places and as a bodyless POST in others (the permission + // picker posts `?inventionId=84&permission=Publish`), so both are registered and + // neither reads a body. Absent params keep their stored value; `permission` sets + // what other players may do with it. An empty `description` clears it, but an empty + // `name`/`imageName` is ignored rather than blanking the invention. Publishing and + // pricing are separate endpoints. Auth-gated, creator only; answers the save envelope. + .on( + ['GET', 'POST'], '/api/inventions/v1/update', describeRoute({ tags: ['Inventions'], summary: 'Edit an invention’s metadata', description: - 'A GET that writes — that is what the client sends, with the fields to change as ' + - 'query params. Absent params keep their stored value. An empty `description` ' + - 'clears it, but an empty `name`/`imageName` is ignored rather than blanking the ' + - 'invention. A supplied name/description must satisfy the same rules `v6/save` ' + - 'enforces. Publishing and pricing are separate endpoints.', + 'GET or POST — the client sends both, and the fields to change ride as query ' + + 'params either way; no body is read. Absent params keep their stored value. An ' + + 'empty `description` clears it, but an empty `name`/`imageName` is ignored rather ' + + 'than blanking the invention. A supplied name/description must satisfy the same ' + + 'rules `v6/save` enforces. Publishing and pricing are separate endpoints.', security: AUTHED, parameters: [ intQuery('inventionId', 'Invention id; required'), @@ -402,7 +449,12 @@ export const avatarRoutes = new Hono({ strict: false }) stringQuery('description', 'Max 512 chars; present-but-empty clears it'), stringQuery('imageName', 'New thumbnail; empty is ignored'), stringQuery('allowTrial', '`true`/`1` to allow trials'), - stringQuery('permission', 'A name like `useonly`, or the raw permission number'), + stringQuery( + 'permission', + 'What other players get (`GeneralPermission`). The picker sends `UseOnly`, ' + + '`EditAndSave` or `Publish`; any ladder name (case- and underscore-insensitive) ' + + 'or the raw number is accepted' + ), ], responses: { 200: json(InventionSaveResult, 'The updated invention, in the save envelope'), diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index f391f15..098538e 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -940,6 +940,52 @@ describe('public endpoints', () => { expect(await batch('')).toEqual([]) }) + test('GET /api/inventions/v1/fulllineageowner answers for the whole set of ids', async () => { + const save = async (sub: string, name: string): Promise => { + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { + method: 'POST', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, inventionDataFilename: 'a.inv' }), + }) + expect(res.status).toBe(200) + return ((await res.json()) as InventionSaveResult).Invention + } + const owns = async (query: string, sub: string): Promise => { + const res = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1/fulllineageowner?${query}`, + { headers: await bearer(sub) } + ) + expect(res.status).toBe(200) + return await res.json() + } + + // 7301 makes two; 7302 makes one and buys one of 7301's. + const own = await save('7301', 'Lineage Root') + const nested = await save('7301', 'Lineage Nested') + const others = await save('7302', 'Someone Elses') + await grantInvention(env.DB, 7302, nested.InventionId) + + // The creator owns their own lineage; one invention that isn't theirs sinks it. + expect(await owns(`id=${own.InventionId}&id=${nested.InventionId}`, '7301')).toBe(true) + expect( + await owns(`id=${own.InventionId}&id=${nested.InventionId}&id=${others.InventionId}`, '7301') + ).toBe(false) + + // Bought counts as owned, and comma-separated ids parse like the batch endpoint. + expect(await owns(`id=${nested.InventionId},${others.InventionId}`, '7302')).toBe(true) + expect(await owns(`id=${own.InventionId}`, '7302')).toBe(false) + + // An id with no invention behind it is not owned, whoever asks. + expect(await owns(`id=${own.InventionId}&id=999999`, '7301')).toBe(false) + // No ids at all: nothing in an empty lineage is unowned. + expect(await owns('', '7301')).toBe(true) + }) + + test('GET /api/inventions/v1/fulllineageowner 401s without a bearer token', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fulllineageowner?id=1`) + expect(res.status).toBe(401) + }) + test('GET /api/inventions/v1/room lists a room’s published inventions', async () => { // Two inventions created in room 76, one of them still a draft. const create = async (name: string, room: number): Promise => { @@ -1151,6 +1197,42 @@ describe('public endpoints', () => { expect(anon.status).toBe(401) }) + test('POST /api/inventions/v1/update takes the permission picker’s query params', async () => { + const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { + method: 'POST', + headers: { ...(await bearer('3232')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Posted Lamp', inventionDataFilename: 'a.inv' }), + }) + const { Invention } = (await save.json()) as InventionSaveResult + const post = async (query: string, sub = '3232'): Promise => + exports.default.fetch( + `${ORIGIN}/api/inventions/v1/update?inventionId=${Invention.InventionId}&${query}`, + { method: 'POST', headers: await bearer(sub) } + ) + + // The picker posts the permission by CamelCase name, with no body at all. + const permission = async (name: string): Promise => { + const res = await post(`permission=${name}`) + expect(res.status).toBe(200) + return ((await res.json()) as InventionSaveResult).Invention.GeneralPermission + } + expect(await permission('UseOnly')).toBe(20) + expect(await permission('EditAndSave')).toBe(40) + expect(await permission('Publish')).toBe(60) + + // Setting the permission is not publishing — that stays v3/publish's job. + const still = await post('permission=Publish') + expect(((await still.json()) as InventionSaveResult).Invention.IsPublished).toBe(false) + + // Same gate as the GET: creator only, and a token is required. + expect((await post('permission=UseOnly', '9999')).status).toBe(403) + const anonPost = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1/update?inventionId=${Invention.InventionId}&permission=Publish`, + { method: 'POST' } + ) + expect(anonPost.status).toBe(401) + }) + test('GET /api/inventions/v3/publish publishes + prices; search then lists it', async () => { const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { method: 'POST', @@ -3166,7 +3248,8 @@ describe('openapi', () => { // Every route the worker serves is described. This is the drift guard: adding a // route without a describeRoute() block fails here rather than silently shipping // an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`; the - // `.on(['GET','POST'], …)` relationship routes contribute both methods. + // `.on(['GET','POST'], …)` routes (the relationship mutations, invention update) + // contribute both methods. const documented = new Set( Object.entries(spec.paths).flatMap(([path, ops]) => Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`) @@ -3203,6 +3286,7 @@ describe('openapi', () => { 'GET /api/inventions/v1', 'GET /api/inventions/v1/details', 'GET /api/inventions/v1/featured', + 'GET /api/inventions/v1/fulllineageowner', 'GET /api/inventions/v1/personaldetails/{inventionId}', 'GET /api/inventions/v1/room', 'GET /api/inventions/v1/tagfilters', @@ -3256,6 +3340,7 @@ describe('openapi', () => { 'POST /api/images/v1/cheer', 'POST /api/images/v4/uploadsaved', 'POST /api/inventions/v1/settags', + 'POST /api/inventions/v1/update', 'POST /api/inventions/v1/updateprice', 'POST /api/inventions/v6/save', 'POST /api/messages/v1/sendMultiple',