diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index c694e80..c05009b 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -668,6 +668,43 @@ export const UpdateInventionMetadataRequest = z.object({ .describe('Replaces both lists wholesale, as `v1/settags` does; null leaves them alone'), }) +/** + * `POST /api/inventions/v2/update` JSON body — the newer client's counterpart to + * `v1/update`'s query string. PascalCase, and nullable the way `v2/metadata`'s is: a null + * field keeps what the invention already has, so this is a patch rather than a replace. + * + * Only `InventionId` and `Permission` are confirmed from a live capture (the permission + * picker sends exactly those two). The rest are accepted defensively — they are the fields + * `v1/update` took that `v2/metadata` does not — so that a build sending one gets it + * applied instead of silently dropped. + */ +export const UpdateInventionRequest = z.object({ + InventionId: z.int(), + Permission: z + .int() + .nullable() + .optional() + .describe( + 'The `GeneralPermission` other players get, as a raw ladder number: Unassigned 0, ' + + 'LimitedOneUseOnly 10, DisallowKeyLock 15, UseOnly 20, EditAndSave 40, Publish 60, ' + + 'Charge 80, Unlimited 100. Unlike `v4/publish`, null LEAVES IT ALONE rather than ' + + 'falling back to UseOnly — an edit that names no permission is not a demotion' + ), + AllowTrial: z + .boolean() + .nullable() + .optional() + .describe('Whether other players may trial it; null leaves it alone'), + Name: z + .string() + .nullable() + .optional() + .describe('3–24 chars, letters/digits/spaces/dashes/colons; null leaves it alone'), + Description: z.string().nullable().optional().describe('Max 512 chars; empty clears it'), + LongDescription: z.string().nullable().optional().describe('Empty clears it'), + ImageName: z.string().nullable().optional().describe('New thumbnail; empty clears it'), +}) + /** * `POST /api/inventions/v4/publish` JSON body — PascalCase, and nullable the way * `v2/metadata`'s is: a null field keeps what the invention already has. diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index f476d0b..c2cea1a 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -104,6 +104,7 @@ import { UNAUTHORIZED_RESPONSE, UpdateCustomAvatarItemRequest, UpdateInventionMetadataRequest, + UpdateInventionRequest, UpdatePriceRequest, } from '../openapi' import { createReport } from '../reports-db' @@ -2055,6 +2056,112 @@ export const avatarRoutes = new Hono({ strict: false }) } ) + // Edit an invention, as the newer client sends it: the `v1/update` write with a + // PascalCase body instead of a query string. What the permission picker posts — + // `{ InventionId, Permission }` — and so the endpoint that changes what other players + // may DO with an invention, where `v2/metadata` beside it changes what it SAYS. + // + // Creator only, which is the whole point of the gate: `GeneralPermission` is what lets + // another player edit, re-publish or charge for someone's build, so a write that took + // the caller's word for the id would let anyone hand themselves rights over any + // invention in the game. + .post( + '/api/inventions/v2/update', + describeRoute({ + tags: ['Inventions'], + summary: 'Edit an invention (v2)', + description: + 'The newer client’s form of `v1/update`: a PascalCase JSON body in place of the ' + + 'query string. A patch, not a replace — every field but `InventionId` is nullable ' + + 'and a null one is left as it is, since the client sends the whole shape on every ' + + 'edit.\n\n' + + '**Creator only.** `Permission` decides what other players may do with the ' + + 'invention — up to editing, re-publishing and charging for it — so only the ' + + 'account that created it may set it.\n\n' + + '`Permission` is the raw `GeneralPermission` ladder number (UseOnly 20, ' + + 'EditAndSave 40, Publish 60 …), stored verbatim and unmapped, exactly as ' + + '`v1/update` stores the value its picker names. A null one LEAVES IT ALONE — ' + + 'unlike `v4/publish`, where null means UseOnly: publishing something with no ' + + 'permission named is a decision, but editing it without naming one is not.\n\n' + + 'Publishing, pricing and tags are elsewhere (`v4/publish`, `v1/updateprice`, ' + + '`v2/metadata`), as they are for `v1/update`.\n\n' + + 'Answers the enveloped result `v9/save` answers, carrying the UPDATED invention: ' + + 'the client re-renders the detail sheet from `Value.Invention`, so a refusal — an ' + + 'unknown invention and someone else’s alike — is `Success: false` with a null ' + + '`Value` rather than a bare error body, which that client cannot parse.', + security: AUTHED, + requestBody: jsonBody(UpdateInventionRequest, 'The fields to change'), + responses: { + 200: json( + InventionSaveV9Result, + 'The envelope — the updated invention under `Value`, or `Success: false` with ' + + '`Error` when the edit was refused' + ), + 401: json(InventionSaveV9Result, '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(inventionSaveV9Failure('Invalid request body')) + + // The id rides in the body here, not the query string. The gate is what makes this + // creator-only: it loads the invention and refuses anyone but the account that + // created it. + const gate = await creatorsInventionResult( + c, + typeof body.InventionId === 'number' ? body.InventionId : Number.NaN + ) + // Only a missing token is answered as a transport failure. An unknown invention or + // someone else's is a domain answer this client is meant to read — its own status + // enum has DoesNotExist and NotCreator members — so it goes in the envelope, where + // the message reaches a human. Same split `v2/metadata` makes. + if ('rejection' in gate) { + return gate.status === 401 + ? c.json(inventionSaveV9Failure(gate.rejection), 401) + : c.json(inventionSaveV9Failure(gate.rejection)) + } + + // A number is the picker's own form; a string is accepted through the same parser + // `v1/update`'s query param goes through, so a build sending `"EditAndSave"` or + // `"40"` lands on the same level. Anything else (null included) leaves it alone. + const permission = + typeof body.Permission === 'number' + ? body.Permission + : typeof body.Permission === 'string' + ? parsePermissionLevel(body.Permission) + : undefined + + // Null is "leave it"; a string, empty or not, is an edit — `v2/metadata`'s rule, + // since these are the same client's bodies. + const edited = (key: string): string | undefined => + typeof body[key] === 'string' ? body[key] : undefined + const name = edited('Name')?.trim() + const description = edited('Description') + const longDescription = edited('LongDescription') + + for (const rejection of [ + name === undefined ? null : inventionNameRejection(name), + description === undefined ? null : inventionDescriptionRejection(description), + longDescription === undefined ? null : inventionLongDescriptionRejection(longDescription), + ]) { + if (rejection !== null) return c.json(inventionSaveV9Failure(rejection)) + } + + const updated = await updateInvention(c.env.DB, gate.invention.InventionId, { + generalPermission: permission, + allowTrial: typeof body.AllowTrial === 'boolean' ? body.AllowTrial : undefined, + name, + description, + longDescription, + imageName: edited('ImageName'), + }) + if (updated === null) return c.json(inventionSaveV9Failure('No such invention')) + // The tags are reported as the ones the invention HAS, not as ones this call + // changed — it changes none. Same reading `v2/metadata` gives a null TagsRequest. + return c.json(toSaveResultV9(updated, updated.Tags ?? [], INVENTION_TAG_RESULT.success)) + } + ) + // Publish an invention, as the newer client sends it: a PascalCase body instead of a // query string, and an Accessibility of its own — where `v3/publish` only ever flipped // the published flag, this decides whether the result can be FOUND. Same enveloped diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index b2d4f7f..20f8a07 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -2185,6 +2185,158 @@ describe('public endpoints', () => { expect(((await one.json()) as SavedInvention).Name).toBe('Clear Me') }) + // The permission picker's own call. `Permission` is the `GeneralPermission` ladder + // number — 40 is EditAndSave — and it decides what OTHER players may do with the + // invention, up to editing, re-publishing and charging for it. + test('POST /api/inventions/v2/update sets the permission from the body', async () => { + const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, { + method: 'POST', + headers: { ...(await bearer('5170')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Permission Me', inventionDataFilename: 'perm.inv' }), + }) + const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId + expect(inventionId).toBeGreaterThan(0) + + // The body verbatim as the client posts it. + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, { + method: 'POST', + headers: { ...(await bearer('5170')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: inventionId, Permission: 40 }), + }) + expect(res.status).toBe(200) + const result = (await res.json()) as InventionSaveV9Result + expect(result.Success).toBe(true) + // The UPDATED invention comes back in the envelope: the client re-renders the detail + // sheet from `Value.Invention`, so a reply that omitted it would leave the old + // permission on screen. + expect(result.Value?.Invention.GeneralPermission).toBe(40) + + // And it stuck. + const one = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1?inventionId=${inventionId}` + ) + expect(((await one.json()) as SavedInvention).GeneralPermission).toBe(40) + }) + + // THE gate. `GeneralPermission` is what lets another player edit, re-publish or charge + // for someone's build, so a write that took the caller's word for the id would let + // anyone hand themselves rights over any invention in the game. Refused in-band, the + // way `v2/metadata` refuses — this client cannot parse a bare error body — and, the + // part that matters, the stored permission must not move. + test('POST /api/inventions/v2/update is gated to the invention’s creator', async () => { + const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, { + method: 'POST', + headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Not Yours Either', inventionDataFilename: 'nye.inv' }), + }) + const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId + const permissionNow = async () => { + const one = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1?inventionId=${inventionId}` + ) + return ((await one.json()) as SavedInvention).GeneralPermission + } + const before = await permissionNow() + + // Another player, granting themselves Unlimited (100) over someone else's build. + const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, { + method: 'POST', + headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: inventionId, Permission: 100 }), + }) + expect(theirs.status).toBe(200) + expect(await theirs.json()).toEqual({ + Value: null, + Success: false, + Error: 'Not your invention', + error_id: null, + }) + expect(await permissionNow()).toBe(before) + + // An unknown invention is the same kind of answer. + const missing = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, { + method: 'POST', + headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: 987655, Permission: 40 }), + }) + expect(missing.status).toBe(200) + expect((await missing.json()) as InventionSaveV9Result).toMatchObject({ + Value: null, + Error: 'No such invention', + }) + + // A missing token is the one refusal that stays a transport failure — but it still + // answers the envelope, because an unparseable body crashes the client. + const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: inventionId, Permission: 100 }), + }) + expect(anon.status).toBe(401) + expect((await anon.json()) as InventionSaveV9Result).toMatchObject({ + Value: null, + Success: false, + }) + expect(await permissionNow()).toBe(before) + }) + + // A patch, not a replace — and specifically NOT `v4/publish`'s reading of a null + // permission, which is UseOnly. Publishing something without naming a permission is a + // decision; editing it without naming one is not, and defaulting here would silently + // demote an invention every time the creator renamed it. + test('POST /api/inventions/v2/update leaves a null permission alone', async () => { + const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, { + method: 'POST', + headers: { ...(await bearer('5173')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Keep My Rights', inventionDataFilename: 'keep.inv' }), + }) + const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId + + // Set it to Publish (60) first, so a fallback to UseOnly (20) would be visible. + await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, { + method: 'POST', + headers: { ...(await bearer('5173')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: inventionId, Permission: 60 }), + }) + + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, { + method: 'POST', + headers: { ...(await bearer('5173')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: inventionId, Permission: null, Name: 'Renamed' }), + }) + expect(res.status).toBe(200) + const value = ((await res.json()) as InventionSaveV9Result).Value + expect(value?.Invention.GeneralPermission).toBe(60) + expect(value?.Invention.Name).toBe('Renamed') + }) + + // The same parser `v1/update`'s query param goes through, so a build that sends the + // ladder by NAME lands on the same level as one sending the number. + test('POST /api/inventions/v2/update accepts a permission named as a string', async () => { + const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, { + method: 'POST', + headers: { ...(await bearer('5174')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Named Ladder', inventionDataFilename: 'named.inv' }), + }) + const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId + + for (const [sent, expected] of [ + ['EditAndSave', 40], + ['edit_and_save', 40], + ['60', 60], + ] as const) { + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, { + method: 'POST', + headers: { ...(await bearer('5174')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: inventionId, Permission: sent }), + }) + expect(res.status).toBe(200) + expect(((await res.json()) as InventionSaveV9Result).Value?.Invention.GeneralPermission).toBe( + expected + ) + } + }) + test('PUT /api/inventions/v2/metadata refuses another creator’s invention in-band', async () => { const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, { method: 'POST', @@ -8131,6 +8283,7 @@ describe('openapi', () => { 'POST /api/inventions/v1/update', 'POST /api/inventions/v1/updateprice', 'POST /api/inventions/v2/delete', + 'POST /api/inventions/v2/update', 'POST /api/inventions/v4/publish', 'POST /api/inventions/v6/save', 'POST /api/inventions/v9/save',