[api] add missing invention status endpoint

This commit is contained in:
Devin Zuczek
2026-09-15 16:32:03 -04:00
parent cce921e4cd
commit 7774975619
3 changed files with 297 additions and 0 deletions
+37
View File
@@ -668,6 +668,43 @@ export const UpdateInventionMetadataRequest = z.object({
.describe('Replaces both lists wholesale, as `v1/settags` does; null leaves them alone'), .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 * `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. * `v2/metadata`'s is: a null field keeps what the invention already has.
+107
View File
@@ -104,6 +104,7 @@ import {
UNAUTHORIZED_RESPONSE, UNAUTHORIZED_RESPONSE,
UpdateCustomAvatarItemRequest, UpdateCustomAvatarItemRequest,
UpdateInventionMetadataRequest, UpdateInventionMetadataRequest,
UpdateInventionRequest,
UpdatePriceRequest, UpdatePriceRequest,
} from '../openapi' } from '../openapi'
import { createReport } from '../reports-db' import { createReport } from '../reports-db'
@@ -2055,6 +2056,112 @@ export const avatarRoutes = new Hono<App>({ 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<string, unknown> | 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 // 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 // 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 // the published flag, this decides whether the result can be FOUND. Same enveloped
+153
View File
@@ -2185,6 +2185,158 @@ describe('public endpoints', () => {
expect(((await one.json()) as SavedInvention).Name).toBe('Clear Me') 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 () => { 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`, { const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST', method: 'POST',
@@ -8131,6 +8283,7 @@ describe('openapi', () => {
'POST /api/inventions/v1/update', 'POST /api/inventions/v1/update',
'POST /api/inventions/v1/updateprice', 'POST /api/inventions/v1/updateprice',
'POST /api/inventions/v2/delete', 'POST /api/inventions/v2/delete',
'POST /api/inventions/v2/update',
'POST /api/inventions/v4/publish', 'POST /api/inventions/v4/publish',
'POST /api/inventions/v6/save', 'POST /api/inventions/v6/save',
'POST /api/inventions/v9/save', 'POST /api/inventions/v9/save',