mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[api] add missing invention status endpoint
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<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
|
||||
// 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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user