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'),
|
.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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user