inventions...almost working

This commit is contained in:
Devin Zuczek
2026-07-12 19:55:51 -04:00
parent a404541f2c
commit ae85bedea1
8 changed files with 1333 additions and 33 deletions
@@ -0,0 +1,13 @@
-- Break `IsFeatured` out of the invention JSON blob into a queryable generated
-- column, so the featured feed (`/api/inventions/v1/featured`) filters in SQL on
-- an index instead of parsing every published invention in memory. Generated from
-- src/inventions-db.ts (SCHEMA_DDL) — keep in sync.
--
-- SQLite allows ALTER TABLE ADD COLUMN only for VIRTUAL generated columns (a
-- STORED one would need rewriting existing rows), which is what we want anyway:
-- the value stays derived from `data`, so nothing can drift out of sync with it.
-- json_extract of a JSON `true` is 1, so the column reads 1/0.
ALTER TABLE invention
ADD COLUMN is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL;
CREATE INDEX IF NOT EXISTS idx_invention_featured ON invention (is_featured);
+445 -12
View File
@@ -14,15 +14,21 @@
* shaped after a real `GET /api/inventions/v1?inventionId=…` response.
*/
/** Schema DDL (mirror of migrations/0002_invention.sql, sans any seed rows). */
/**
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql,
* sans any seed rows). `is_featured` backs the featured feed's query; json_extract
* of a JSON `true` is 1, so the column is 1/0.
*/
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS invention (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.InventionId')) VIRTUAL,
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_invention_id ON invention (id)`,
`CREATE INDEX IF NOT EXISTS idx_invention_creator ON invention (creator_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_invention_featured ON invention (is_featured)`,
]
/** A single saved version of an invention (Rec Room's `RRInventionVersion`). */
@@ -39,6 +45,25 @@ export interface InventionVersion {
AICost: number
}
/**
* Where a tag came from. 1 is a third kind the real API emits (the size bucket,
* e.g. `medium`) that nothing here produces, so it's named but unused.
*/
export const INVENTION_TAG_TYPE = {
custom: 0, // user submitted
unknown: 1,
auto: 2, // derived from the invention itself, e.g. `useonly` / `lowink`
} as const
/**
* A tag on an invention (Rec Room's `RRInventionTag`). Stored on the record and
* echoed back through `v1/details`; `v1/settags` answers with the bare tag names.
*/
export interface InventionTag {
Tag: string
Type: number
}
/** A stored invention record (Rec Room's `RRInvention`; returned by save / mine). */
export interface SavedInvention {
InventionId: number
@@ -67,16 +92,53 @@ export interface SavedInvention {
AllowTrial: boolean
HideFromPlayer: boolean
ReferencedInventions: number[]
/**
* Tags served by `v1/details` and written by `v1/settags`. Optional and unset on
* save: the real `RRInvention` carries no Tags field and the client sends no tags
* when saving, so an untagged invention's DTO stays identical to the real one.
*/
Tags?: InventionTag[]
}
interface InventionRow {
data: string
}
/** Fields the client supplies on save (camelCase); everything else is defaulted here. */
/**
* What the client expects back from `v6/save`: the invention and its version side
* by side under a status envelope, rather than the single nested `RRInvention` the
* read endpoints return. `Status` is 0 on success.
*/
export interface InventionSaveResult {
Status: number
Invention: SavedInvention
InventionVersion: InventionVersion
}
/** Wrap a stored invention in the save envelope, lifting out its current version. */
export function toSaveResult(invention: SavedInvention): InventionSaveResult {
return { Status: 0, Invention: invention, InventionVersion: invention.CurrentVersion }
}
/**
* Invention data blobs are named `<name>.inv`, and the client expects the extension
* on the `BlobName` it reads back. Uploads through the `storage` worker already land
* under an `.inv` key, so this is a no-op for them; it's here so a `BlobName` we hand
* the client can never be missing the extension.
*/
function inventionBlobName(filename: string): string {
return filename.toLowerCase().endsWith('.inv') ? filename : `${filename}.inv`
}
/**
* Fields the client supplies on save (camelCase); everything else is defaulted here.
* `inventionDataFilename` is the one the caller must supply — an invention with no
* data blob is unusable. An empty `name`/`description` is defaulted, not rejected.
*/
export interface NewInvention {
creatorPlayerId: number
name: string
inventionDataFilename: string
name?: string | null
description?: string | null
imageName?: string | null
instantiationCost?: number
@@ -85,15 +147,19 @@ export interface NewInvention {
cloudVariablesCost?: number
aiCost?: number
creationRoomId?: number | null
inventionDataFilename?: string | null
referencedInventions?: number[]
creatorAccountRole?: number
}
/**
* Insert a new invention record, returning the stored row. A freshly saved
* invention is private/unpublished — it shows up only in the creator's own list
* until they publish it, so Accessibility/IsPublished/FirstPublishedAt reflect that.
*
* It is, however, fully permissioned from the start: the creator gets Unlimited over
* their own invention, and so does everyone else once it's published — publishing is
* what narrows `GeneralPermission` down (to UseOnly by default). Trials are allowed.
* The client's `creatorAccountRole` is ignored: it's the player's role in the room
* they built it in, not a permission over the invention.
*/
export async function createInvention(
db: D1Database,
@@ -109,15 +175,15 @@ export async function createInvention(
InventionId: inventionId,
ReplicationId: crypto.randomUUID(),
CreatorPlayerId: input.creatorPlayerId,
Name: input.name,
Description: input.description ?? '',
Name: input.name?.trim() || 'Untitled',
Description: input.description?.trim() || 'No description yet',
ImageName: input.imageName ?? '',
CurrentVersionNumber: 1,
CurrentVersion: {
InventionId: inventionId,
ReplicationId: crypto.randomUUID(),
VersionNumber: 1,
BlobName: input.inventionDataFilename ?? '',
BlobName: inventionBlobName(input.inventionDataFilename),
BlobHash: null,
InstantiationCost: input.instantiationCost ?? 0,
LightsCost: input.lightsCost ?? 0,
@@ -135,12 +201,12 @@ export async function createInvention(
NumPlayersHaveUsedInRoom: 0,
NumDownloads: 0,
CheerCount: 0,
CreatorPermission: input.creatorAccountRole ?? 0,
GeneralPermission: 0,
CreatorPermission: INVENTION_PERMISSION.unlimited,
GeneralPermission: INVENTION_PERMISSION.unlimited,
IsAGInvention: false,
IsCertifiedInvention: false,
Price: 0,
AllowTrial: false,
AllowTrial: true,
HideFromPlayer: false,
ReferencedInventions: input.referencedInventions ?? [],
}
@@ -166,6 +232,373 @@ export async function getInventionsByCreator(
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
}
/**
* Invention search — the browse/search list the client shows when picking an
* invention to spawn. Only published, non-hidden inventions are visible here (a
* player's own unpublished ones come from `getInventionsByCreator`). `value` is
* matched case-insensitively against the name and description, term by term; an
* empty `value` browses everything published. Paginated via skip/take, newest
* first. Returns a bare array — the shape the client expects from v2/search.
*/
export async function searchInventions(
db: D1Database,
value: string,
skip: number,
take: number
): Promise<SavedInvention[]> {
let inventions = await publicInventions(db)
const terms = value
.trim()
.toLowerCase()
.split(/[\s+]+/)
.filter(Boolean)
for (const term of terms) {
inventions = inventions.filter(
(i) => i.Name.toLowerCase().includes(term) || i.Description.toLowerCase().includes(term)
)
}
return inventions
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
.slice(skip, skip + take)
}
/**
* Every invention any player may see: published and not hidden. The feeds and
* search all draw from this set; a player's own unpublished inventions reach them
* only through `getInventionsByCreator`. `featuredOnly` narrows to the curated
* ones via the indexed `is_featured` column.
*/
async function publicInventions(db: D1Database, featuredOnly = false): Promise<SavedInvention[]> {
// json_extract of a JSON `true` is 1, so these filters stay in SQL.
const { results } = await db
.prepare(
`SELECT data FROM invention
WHERE json_extract(data, '$.IsPublished') = 1
AND json_extract(data, '$.HideFromPlayer') = 0
${featuredOnly ? 'AND is_featured = 1' : ''}`
)
.all<InventionRow>()
return results.map((r) => JSON.parse(r.data) as SavedInvention)
}
/** Engagement score used to rank the top feed (downloads weigh most, then cheers). */
function topScore(invention: SavedInvention): number {
const n = (v: unknown): number => (typeof v === 'number' ? v : 0)
return (
n(invention.NumDownloads) * 3 +
n(invention.CheerCount) * 2 +
n(invention.NumPlayersHaveUsedInRoom)
)
}
/**
* The "top today" feed — published inventions ranked by engagement. The real feed
* ranks by *today's* activity; we don't track per-day counters, so this ranks by
* lifetime engagement instead. Ties fall back to invention id so paging is stable.
* Paginated via skip/take; returns a bare array, like the other invention feeds.
*/
export async function getTopInventions(
db: D1Database,
skip: number,
take: number
): Promise<SavedInvention[]> {
const inventions = await publicInventions(db)
return inventions
.sort((a, b) => topScore(b) - topScore(a) || b.InventionId - a.InventionId)
.slice(skip, skip + take)
}
/**
* The featured feed — published inventions flagged `IsFeatured`, newest first.
* Selected on the indexed `is_featured` column rather than by parsing every public
* invention. Nothing sets that flag yet, so this falls back to the top feed rather
* than handing the client an empty shelf; once inventions are curated it serves them.
*/
export async function getFeaturedInventions(
db: D1Database,
skip: number,
take: number
): Promise<SavedInvention[]> {
const featured = await publicInventions(db, true)
if (featured.length === 0) return getTopInventions(db, skip, take)
return featured
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
.slice(skip, skip + take)
}
/**
* Replace an invention's tags (the `v1/settags` write). Auto tags are the ones the
* client derives from the invention itself (Type 2); custom tags are the creator's
* own (Type 0). Both lists are replaced wholesale — auto first, then custom, the
* order the tags come back in — and are lowercased/trimmed and de-duplicated so
* `details` doesn't echo back near-duplicates. Returns the stored tag list, or null
* when there's no such invention.
*/
export async function setInventionTags(
db: D1Database,
inventionId: number,
autoTags: string[],
customTags: string[]
): Promise<InventionTag[] | null> {
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
const tags: InventionTag[] = []
const seen = new Set<string>()
for (const [list, type] of [
[autoTags, INVENTION_TAG_TYPE.auto],
[customTags, INVENTION_TAG_TYPE.custom],
] as const) {
for (const raw of list) {
const tag = raw.trim().toLowerCase()
if (tag === '' || seen.has(tag)) continue
seen.add(tag)
tags.push({ Tag: tag, Type: type })
}
}
await writeInvention(db, { ...invention, Tags: tags })
return tags
}
/**
* What other players may do with a published invention — the `GeneralPermission`
* ladder, each level implying the ones below it. `v1/update` takes these by name or
* number (`permission=useonly` / `permission=20`), and `v3/publish` defaults to
* UseOnly.
*/
export const INVENTION_PERMISSION = {
unassigned: 0,
limitedoneuseonly: 10,
useonly: 20,
editandsave: 40,
publish: 60,
charge: 80,
unlimited: 100,
} as const
/**
* Parse a permission level the way the client sends it: a name (`useonly`,
* `edit_and_save`) or the raw number. Undefined when it's neither.
*/
export function parsePermissionLevel(value: string): number | undefined {
const key = value.trim().toLowerCase().replace(/_/g, '')
if (key in INVENTION_PERMISSION) {
return INVENTION_PERMISSION[key as keyof typeof INVENTION_PERMISSION]
}
const numeric = Number.parseInt(value.trim(), 10)
return Number.isNaN(numeric) ? undefined : numeric
}
/** Fields `v1/update` can change. Anything left undefined keeps its stored value. */
export interface InventionPatch {
name?: string
description?: string
imageName?: string
allowTrial?: boolean
generalPermission?: number
}
/**
* Apply an edit to an invention's metadata (the `v1/update` write). Only the keys
* present on the patch change; everything else — versions, counters, published
* state — is left alone. Publishing and pricing are deliberately *not* here: they
* go through `publishInvention` / `setInventionPrice`, as they do in the real API.
* Returns the updated invention, or null when there's no such row.
*/
export async function updateInvention(
db: D1Database,
inventionId: number,
patch: InventionPatch
): Promise<SavedInvention | null> {
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
const updated: SavedInvention = {
...invention,
Name: patch.name ?? invention.Name,
Description: patch.description ?? invention.Description,
ImageName: patch.imageName ?? invention.ImageName,
AllowTrial: patch.allowTrial ?? invention.AllowTrial,
GeneralPermission: patch.generalPermission ?? invention.GeneralPermission,
}
await writeInvention(db, updated)
return updated
}
/**
* Publish an invention (`v3/publish`) — what puts it into search and the feeds.
* Publishing sets the permission other players get (UseOnly unless the creator asks
* for another level) and its price, and the first publish stamps `FirstPublishedAt`.
* Returns the published invention, or null when there's no such row.
*/
export async function publishInvention(
db: D1Database,
inventionId: number,
permissionLevel: number | undefined,
price: number | undefined
): Promise<SavedInvention | null> {
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
const updated: SavedInvention = {
...invention,
IsPublished: true,
GeneralPermission: permissionLevel ?? INVENTION_PERMISSION.useonly,
Price: price ?? 0,
FirstPublishedAt: invention.FirstPublishedAt ?? new Date().toISOString(),
}
await writeInvention(db, updated)
return updated
}
/**
* Set an invention's price (`v1/updateprice`). Returns the updated invention, or
* null when there's no such row; the caller rejects negative prices.
*/
export async function setInventionPrice(
db: D1Database,
inventionId: number,
price: number
): Promise<SavedInvention | null> {
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
const updated: SavedInvention = { ...invention, Price: price }
await writeInvention(db, updated)
return updated
}
/** The tag filter chips the client offers when browsing inventions. */
export interface InventionTagFilters {
PinnedFilters: string[]
PopularFilters: string[]
TrendingFilters: string[] | null
}
/**
* The tag filters shown on the invention browse screen (`v1/tagfilters`), derived
* from the tags actually in use: the most common tags across published inventions,
* most popular first, with the top few pinned. `TrendingFilters` is null — that
* needs recent-activity tracking we don't keep, and the client treats it as absent.
*
* With no published, tagged inventions this is empty, which just means no chips.
*/
export async function getInventionTagFilters(db: D1Database): Promise<InventionTagFilters> {
const counts = new Map<string, number>()
for (const invention of await publicInventions(db)) {
for (const tag of invention.Tags ?? []) {
counts.set(tag.Tag, (counts.get(tag.Tag) ?? 0) + 1)
}
}
const popular = [...counts.entries()]
.sort(([tagA, countA], [tagB, countB]) => countB - countA || tagA.localeCompare(tagB))
.slice(0, 20)
.map(([tag]) => tag)
return {
PinnedFilters: popular.slice(0, 5),
PopularFilters: popular,
TrendingFilters: null,
}
}
/**
* Look up a batch of inventions by id (`v2/batch?id=1&id=2`). Returns whatever
* exists, in the order the ids were asked for; unknown ids are simply absent. The
* caller decides who may see what — an unpublished invention is visible only to its
* creator — so this returns the rows unfiltered.
*/
export async function getInventionsByIds(
db: D1Database,
inventionIds: number[]
): Promise<SavedInvention[]> {
if (inventionIds.length === 0) return []
const placeholders = inventionIds.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT data FROM invention WHERE id IN (${placeholders})`)
.bind(...inventionIds)
.all<InventionRow>()
const byId = new Map<number, SavedInvention>()
for (const row of results) {
const invention = JSON.parse(row.data) as SavedInvention
byId.set(invention.InventionId, invention)
}
return inventionIds.map((id) => byId.get(id)).filter((i): i is SavedInvention => i !== undefined)
}
/**
* The inventions belonging to a room (`v1/room?id=…`) — the ones created there,
* matched on `CreationRoomId`. Published, non-hidden only, so this can't expose a
* creator's drafts to everyone else in the room. Newest first, paginated via
* skip/take; bare array, like the other invention lists.
*/
export async function getInventionsByRoom(
db: D1Database,
roomId: number,
skip: number,
take: number
): Promise<SavedInvention[]> {
const { results } = await db
.prepare(
`SELECT data FROM invention
WHERE json_extract(data, '$.CreationRoomId') = ?1
AND json_extract(data, '$.IsPublished') = 1
AND json_extract(data, '$.HideFromPlayer') = 0`
)
.bind(roomId)
.all<InventionRow>()
return results
.map((r) => JSON.parse(r.data) as SavedInvention)
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
.slice(skip, skip + take)
}
/**
* A single version of an invention (`v1/version?inventionId=…&version=…`), which
* is how the client resolves the blob to download for a given version number.
*
* We keep only the current version on the record — nothing writes version history
* (there's no `v4/addversion` yet), and a fresh save is always version 1. So this
* answers for the current version number and reports null for any other, rather
* than inventing a version whose blob doesn't exist.
*/
export async function getInventionVersion(
db: D1Database,
inventionId: number,
versionNumber: number
): Promise<InventionVersion | null> {
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
return invention.CurrentVersionNumber === versionNumber ? invention.CurrentVersion : null
}
/** Persist an edited invention record, bumping ModifiedAt. */
async function writeInvention(db: D1Database, invention: SavedInvention): Promise<void> {
const updated: SavedInvention = { ...invention, ModifiedAt: new Date().toISOString() }
await db
.prepare('UPDATE invention SET data = ?1 WHERE id = ?2')
.bind(JSON.stringify(updated), invention.InventionId)
.run()
}
/**
* The tags shown on an invention's detail card (`v1/details`). Returns null when
* there's no such invention, so the route can 404 rather than pretend the id is a
* real, untagged invention. Untagged inventions come back as an empty list — which
* is every invention today, since nothing writes tags yet.
*/
export async function getInventionTags(
db: D1Database,
inventionId: number
): Promise<InventionTag[] | null> {
const invention = await getInventionById(db, inventionId)
return invention === null ? null : (invention.Tags ?? [])
}
/** Look up a single invention by its numeric id, or null when there's no such row. */
export async function getInventionById(
db: D1Database,
+243 -9
View File
@@ -1,9 +1,51 @@
import { Hono } from 'hono'
import { authedId, unauthorized } from '../http'
import { createInvention, getInventionById, getInventionsByCreator } from '../inventions-db'
import {
createInvention,
getFeaturedInventions,
getInventionById,
getInventionsByCreator,
getInventionsByIds,
getInventionsByRoom,
getInventionTagFilters,
getInventionTags,
getInventionVersion,
getTopInventions,
parsePermissionLevel,
publishInvention,
searchInventions,
setInventionPrice,
setInventionTags,
toSaveResult,
updateInvention,
} from '../inventions-db'
import type { Context } from 'hono'
import type { App } from '../context'
import type { SavedInvention } from '../inventions-db'
/**
* The gate every invention write runs through: the caller must be signed in, the
* invention must exist, and it must be theirs. Yields the loaded invention, or the
* error response to return as-is (401 / 404 / 403).
*/
async function creatorsInvention(
c: Context<App>,
inventionId: number
): Promise<{ invention: SavedInvention } | { response: Response | Promise<Response> }> {
const playerId = await authedId(c)
if (playerId === null) return { response: unauthorized(c) }
if (Number.isNaN(inventionId)) {
return { response: c.json({ error: 'inventionId is required' }, 400) }
}
const invention = await getInventionById(c.env.DB, inventionId)
if (invention === null) return { response: c.notFound() }
if (invention.CreatorPlayerId !== playerId) {
return { response: c.json({ error: 'Not your invention' }, 403) }
}
return { invention }
}
// ---- Avatar gifts ----------------------------------------------------------
// The avatar read endpoints (`v4/items`, `v2`, `v2/set`, `v3/saved`, `v2/gifts`)
@@ -78,6 +120,194 @@ export const avatarRoutes = new Hono<App>({ strict: false })
return invention ? c.json(invention) : c.notFound()
})
// The tag filter chips on the invention browse screen. Derived from the tags in
// use on published inventions — most popular first, top few pinned. Public.
.get('/api/inventions/v1/tagfilters', async (c) => c.json(await getInventionTagFilters(c.env.DB)))
// A batch of inventions by id (`?id=1&id=2`, and each `id` may itself be a
// comma-separated list). Unknown ids are dropped rather than 404ing, and an empty
// request is an empty list. Auth is optional and only widens what you see: an
// unpublished invention comes back only to its creator. Bare array.
.get('/api/inventions/v2/batch', 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 playerId = await authedId(c)
const inventions = await getInventionsByIds(c.env.DB, ids)
return c.json(
inventions.filter(
(i) => i.IsPublished || (playerId !== null && i.CreatorPlayerId === playerId)
)
)
})
// 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('/api/inventions/v1/room', async (c) => {
const roomId = Number.parseInt(c.req.query('id') ?? '', 10)
if (Number.isNaN(roomId)) return c.json({ error: 'id is required' }, 400)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getInventionsByRoom(c.env.DB, roomId, skip, take))
})
// The signed-in player's own relationship to an invention (`/personaldetails/2`)
// — just whether they're cheering it. We store no cheers (nothing can cheer an
// invention yet), so this is always false; it stays a 200 for signed-out callers
// too, since the client only reads the flag.
.get('/api/inventions/v1/personaldetails/:inventionId{[0-9]+}', (c) =>
c.json({ IsCheering: false })
)
// A single version of an invention (`?inventionId=…&version=…`) — the bare
// RRInventionVersion, which carries the blob name the client downloads. Public.
// Only the current version exists (nothing writes version history yet), so any
// other version number 404s rather than naming a blob that isn't there.
.get('/api/inventions/v1/version', async (c) => {
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10)
if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400)
const version = await getInventionVersion(c.env.DB, inventionId, versionNumber)
return version === null ? c.notFound() : c.json(version)
})
// 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('/api/inventions/v1/update', async (c) => {
const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10))
if ('response' in gate) return gate.response
// Query params arrive as strings; only the ones actually present are applied.
const nonEmpty = (name: string): string | undefined => {
const v = c.req.query(name)?.trim()
return v === undefined || v === '' ? undefined : v
}
const allowTrial = c.req.query('allowTrial')
const permission = c.req.query('permission')
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
name: nonEmpty('name'),
// Present-but-empty clears the description, so this checks presence.
description: c.req.query('description'),
imageName: nonEmpty('imageName'),
allowTrial:
allowTrial === undefined
? undefined
: allowTrial.toLowerCase() === 'true' || allowTrial === '1',
generalPermission: permission === undefined ? undefined : parsePermissionLevel(permission),
})
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
})
// Publish an invention — this is what puts it into search and the feeds. Sets the
// permission other players get (`permissionLevel`, defaulting to UseOnly) and its
// `price`. Auth-gated, creator only; answers the save envelope.
.get('/api/inventions/v3/publish', async (c) => {
const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10))
if ('response' in gate) return gate.response
const permissionLevel = c.req.query('permissionLevel')
const price = Number.parseInt(c.req.query('price') ?? '', 10)
const published = await publishInvention(
c.env.DB,
gate.invention.InventionId,
permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel),
Number.isNaN(price) || price < 0 ? undefined : price
)
return published === null ? c.notFound() : c.json(toSaveResult(published))
})
// Set an invention's price. Unlike update/publish this one POSTs a JSON body.
// Auth-gated, creator only; answers the save envelope.
.post('/api/inventions/v1/updateprice', async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
const gate = await creatorsInvention(c, inventionId)
if ('response' in gate) return gate.response
const price = typeof body.Price === 'number' ? body.Price : Number.NaN
if (Number.isNaN(price) || price < 0) return c.json({ error: 'Price must be >= 0' }, 400)
const updated = await setInventionPrice(c.env.DB, gate.invention.InventionId, price)
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
})
// Replace an invention's tags. `CustomTags` are the creator's own (Type 0),
// `AutoTags` the ones the client derives from the invention (Type 2); both lists
// are replaced wholesale. Auth-gated, and only the creator may retag their own
// invention. Answers `{ Result, Tags }` — `Result` 0 is success, and `Tags` is the
// flat list of tag *names* (auto first, then custom); the typed `{ Tag, Type }`
// objects are what `v1/details` serves.
.post('/api/inventions/v1/settags', async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
const gate = await creatorsInvention(c, inventionId)
if ('response' in gate) return gate.response
const strings = (v: unknown): string[] =>
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
const tags = await setInventionTags(
c.env.DB,
gate.invention.InventionId,
strings(body.AutoTags),
strings(body.CustomTags)
)
return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) })
})
// An invention's detail card (`?inventionId=…`) — just its tags, as `{ Tags }`.
// Untagged inventions report an empty list. 404s on unknown ids.
.get('/api/inventions/v1/details', async (c) => {
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
const tags = await getInventionTags(c.env.DB, inventionId)
return tags === null ? c.notFound() : c.json({ Tags: tags })
})
// The "top today" invention feed — published inventions ranked by engagement
// (lifetime, not per-day: we keep no daily counters). Paginated via skip/take
// (take defaults to 50, as the client asks for). Bare array.
.get('/api/inventions/v1/toptoday', async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
return c.json(await getTopInventions(c.env.DB, skip, take))
})
// The featured invention feed — curated (`IsFeatured`) inventions, falling back
// to the top feed while nothing is curated. Bare array, like toptoday.
.get('/api/inventions/v1/featured', async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
return c.json(await getFeaturedInventions(c.env.DB, skip, take))
})
// Invention search/browse: published inventions matching `value` (matched against
// name + description; absent → browse everything published), newest first.
// Paginated via skip/take (take defaults to 100). Returns a bare array.
.get('/api/inventions/v2/search', async (c) => {
const value = c.req.query('value') ?? ''
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await searchInventions(c.env.DB, value, skip, take))
})
// The signed-in player's saved inventions ("my inventions"), newest first.
// Auth-gated; returns a bare array (empty when the player has saved none).
.get('/api/inventions/v2/mine', async (c) => {
@@ -87,8 +317,11 @@ export const avatarRoutes = new Hono<App>({ strict: false })
})
// Save an invention's metadata. The data file itself is uploaded separately
// through the `storage` worker and referenced here by `inventionDataFilename`.
// Auth-gated; returns the stored invention (with its assigned inventionId).
// through the `storage` worker and referenced here by `inventionDataFilename`
// the one required field, since an invention with no data blob is unusable. An
// omitted name/description is defaulted rather than rejected. Auth-gated; returns
// the `{ Status, Invention, InventionVersion }` envelope the client expects (the
// invention carries its assigned inventionId).
.post('/api/inventions/v6/save', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
@@ -99,12 +332,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
const name = str(body.name)
if (name === undefined) return c.json({ error: 'name is required' }, 400)
const inventionDataFilename = str(body.inventionDataFilename)?.trim()
if (!inventionDataFilename) {
return c.json({ error: 'inventionDataFilename is required' }, 400)
}
const invention = await createInvention(c.env.DB, {
creatorPlayerId: id,
name,
inventionDataFilename,
name: str(body.name),
description: str(body.description),
imageName: str(body.imageName),
instantiationCost: num(body.instantiationCost),
@@ -113,11 +349,9 @@ export const avatarRoutes = new Hono<App>({ strict: false })
cloudVariablesCost: num(body.cloudVariablesCost),
aiCost: num(body.aiCost),
creationRoomId: num(body.creationRoomId),
inventionDataFilename: str(body.inventionDataFilename),
referencedInventions: Array.isArray(body.referencedInventions)
? body.referencedInventions.filter((v): v is number => typeof v === 'number')
: undefined,
creatorAccountRole: num(body.creatorAccountRole),
})
return c.json(invention)
return c.json(toSaveResult(invention))
})
+573 -9
View File
@@ -10,7 +10,7 @@ import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
import type { Env } from '../../context'
import type { SavedImage } from '../../images-db'
import type { SavedInvention } from '../../inventions-db'
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
@@ -286,17 +286,43 @@ describe('public endpoints', () => {
body: JSON.stringify(body),
})
expect(res.status).toBe(200)
const saved = (await res.json()) as SavedInvention
// Save answers with the `{ Status, Invention, InventionVersion }` envelope —
// the version sits alongside the invention, not only nested inside it.
const result = (await res.json()) as InventionSaveResult
expect(result.Status).toBe(0)
const saved = result.Invention
expect(saved.InventionId).toBeGreaterThan(0)
expect(saved.CreatorPlayerId).toBe(5150)
expect(saved.Name).toBe(body.name)
expect(saved.Description).toBe(body.description)
expect(saved.ImageName).toBe(body.imageName)
// Costs + the data blob live on the nested CurrentVersion.
expect(saved.CurrentVersion.InstantiationCost).toBe(103)
expect(saved.CurrentVersion.BlobName).toBe(body.inventionDataFilename)
// Costs + the data blob live on the version. The blob name always carries the
// `.inv` extension the client expects, whether or not the client sent it.
expect(result.InventionVersion).toMatchObject({
InventionId: saved.InventionId,
VersionNumber: 1,
InstantiationCost: 103,
LightsCost: 0,
BlobName: `${body.inventionDataFilename}.inv`,
})
expect(saved.CurrentVersion.BlobName).toBe(`${body.inventionDataFilename}.inv`)
// An extension the client already supplied isn't doubled up.
const withExt = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Already .inv', inventionDataFilename: '2026-07-12/x.inv' }),
})
expect(((await withExt.json()) as InventionSaveResult).InventionVersion.BlobName).toBe(
'2026-07-12/x.inv'
)
expect(saved.CreationRoomId).toBe(73)
expect(saved.CreatorPermission).toBe(255)
// Fully permissioned from the start (the client's creatorAccountRole is a room
// role, not an invention permission, so it's ignored); publishing is what
// narrows GeneralPermission down.
expect(saved.CreatorPermission).toBe(100)
expect(saved.GeneralPermission).toBe(100)
expect(saved.AllowTrial).toBe(true)
// Freshly saved → private/unpublished until the player publishes it.
expect(saved.IsPublished).toBe(false)
expect(saved.FirstPublishedAt).toBeNull()
@@ -321,25 +347,563 @@ describe('public endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'x' }),
body: JSON.stringify({ name: 'x', inventionDataFilename: 'a.inv' }),
})
expect(res.status).toBe(401)
})
test('POST /api/inventions/v6/save 400s without a name', async () => {
test('POST /api/inventions/v6/save 400s without the invention data blob', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer()), 'Content-Type': 'application/json' },
body: JSON.stringify({ description: 'no name' }),
body: JSON.stringify({ name: 'no blob' }),
})
expect(res.status).toBe(400)
})
test('POST /api/inventions/v6/save defaults a missing name and description', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('6161')), 'Content-Type': 'application/json' },
body: JSON.stringify({ inventionDataFilename: 'a.inv', name: ' ' }),
})
expect(res.status).toBe(200)
expect(((await res.json()) as InventionSaveResult).Invention).toMatchObject({
Name: 'Untitled',
Description: 'No description yet',
})
})
test('GET /api/inventions/v1 404s for an unknown invention', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=999999`)
expect(res.status).toBe(404)
})
test('GET /api/inventions/v1/details returns the tag list; 404s on an unknown id', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('6060')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Tagless Sofabed', inventionDataFilename: 'a.inv' }),
})
const { Invention } = (await save.json()) as InventionSaveResult
// A freshly saved invention is untagged until settags writes to it.
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=${Invention.InventionId}`
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Tags: [] })
// Tags stored on the record are echoed back under `Tags`.
const tagged = { ...Invention, InventionId: 5150, Tags: [{ Tag: 'medium', Type: 1 }] }
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
.bind(JSON.stringify(tagged))
.run()
const withTags = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=5150`
)
expect(await withTags.json()).toEqual({ Tags: [{ Tag: 'medium', Type: 1 }] })
const unknown = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=999999`
)
expect(unknown.status).toBe(404)
})
test('POST /api/inventions/v1/settags tags the invention; details serves them back', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('4242')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Sofabed', inventionDataFilename: 'a.inv' }),
})
const { Invention } = (await save.json()) as InventionSaveResult
const settags = async (body: unknown, sub = '4242'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/api/inventions/v1/settags`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
// Custom tags are Type 0, auto tags Type 2.
const res = await settags({
InventionId: Invention.InventionId,
AutoTags: ['lowink'],
CustomTags: ['blah'],
})
expect(res.status).toBe(200)
// settags answers the flat list of tag *names*, auto first, then custom.
expect(await res.json()).toEqual({ Result: 0, Tags: ['lowink', 'blah'] })
// details serves the typed objects: custom is Type 0, auto is Type 2.
const details = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=${Invention.InventionId}`
)
expect(await details.json()).toEqual({
Tags: [
{ Tag: 'lowink', Type: 2 },
{ Tag: 'blah', Type: 0 },
],
})
// Both lists are replaced wholesale, and tags are normalized + de-duplicated.
const replaced = await settags({
InventionId: Invention.InventionId,
AutoTags: [],
CustomTags: ['Modern', ' modern ', 'Bed'],
})
expect(await replaced.json()).toEqual({ Result: 0, Tags: ['modern', 'bed'] })
// Only the creator may retag; unknown inventions 404; no token → 401.
const notMine = await settags({ InventionId: Invention.InventionId, CustomTags: ['x'] }, '9999')
expect(notMine.status).toBe(403)
const unknown = await settags({ InventionId: 999999, CustomTags: ['x'] })
expect(unknown.status).toBe(404)
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/settags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: Invention.InventionId, CustomTags: ['x'] }),
})
expect(anon.status).toBe(401)
})
test('GET /api/inventions/v2/search returns published inventions, filtered by value', async () => {
// Only published inventions are searchable, and nothing published exists via
// the save path (a fresh save is private), so seed the rows directly.
const published = (id: number, name: string, description: string): SavedInvention =>
({
InventionId: id,
ReplicationId: crypto.randomUUID(),
CreatorPlayerId: 8080,
Name: name,
Description: description,
ImageName: '',
CurrentVersionNumber: 1,
CurrentVersion: { InventionId: id, VersionNumber: 1, BlobName: '' },
IsPublished: true,
HideFromPlayer: false,
CreatedAt: `2026-07-0${id}T00:00:00Z`,
}) as unknown as SavedInvention
for (const inv of [
published(101, 'Modern Sofabed', 'Stylistic modern bed'),
published(102, 'Racing Game', 'A retro inspired TV gaming set'),
// Unpublished + hidden rows must stay out of the results.
{ ...published(103, 'Secret Sofabed', ''), IsPublished: false },
{ ...published(104, 'Hidden Sofabed', ''), HideFromPlayer: true },
]) {
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
.bind(JSON.stringify(inv))
.run()
}
// No `value` → browse everything published, newest first.
const all = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/search?skip=0&take=100`)
expect(all.status).toBe(200)
expect(((await all.json()) as SavedInvention[]).map((i) => i.InventionId)).toEqual([102, 101])
// `value` matches name or description, case-insensitively.
const hit = await exports.default.fetch(
`${ORIGIN}/api/inventions/v2/search?value=${encodeURIComponent('modern sofabed')}`
)
expect(((await hit.json()) as SavedInvention[]).map((i) => i.InventionId)).toEqual([101])
// skip/take paginate the published set.
const page = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/search?skip=1&take=1`)
expect(((await page.json()) as SavedInvention[]).map((i) => i.InventionId)).toEqual([101])
const miss = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/search?value=nomatch`)
expect(await miss.json()).toEqual([])
})
test('GET /api/inventions/v1/tagfilters ranks the tags in use', async () => {
// Two published inventions tagged `furniture`, one `bed` — plus a tagged draft,
// whose tags must not leak into the public filter chips.
const make = async (name: string, tags: string[], publish: boolean): Promise<void> => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('9090')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name, inventionDataFilename: 'a.inv' }),
})
const { Invention } = (await save.json()) as InventionSaveResult
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/settags`, {
method: 'POST',
headers: { ...(await bearer('9090')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: Invention.InventionId, CustomTags: tags }),
})
if (publish) {
await exports.default.fetch(
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}`,
{ headers: await bearer('9090') }
)
}
}
await make('Filter Sofa', ['furniture', 'bed'], true)
await make('Filter Chair', ['furniture'], true)
await make('Filter Draft', ['secrettag'], false)
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/tagfilters`)
expect(res.status).toBe(200)
const filters = (await res.json()) as {
PinnedFilters: string[]
PopularFilters: string[]
TrendingFilters: null
}
// Most-used tag first, and the draft's tag is nowhere to be seen.
expect(filters.PopularFilters.slice(0, 2)).toEqual(['furniture', 'bed'])
expect(filters.PopularFilters).not.toContain('secrettag')
expect(filters.PinnedFilters).toEqual(filters.PopularFilters.slice(0, 5))
expect(filters.TrendingFilters).toBeNull()
})
test('GET /api/inventions/v2/batch returns the requested inventions', async () => {
const save = async (name: string): Promise<SavedInvention> => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('5566')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name, inventionDataFilename: 'a.inv' }),
})
return ((await res.json()) as InventionSaveResult).Invention
}
const batch = async (query: string, sub?: string): Promise<SavedInvention[]> => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/batch?${query}`, {
headers: sub === undefined ? {} : await bearer(sub),
})
expect(res.status).toBe(200)
return (await res.json()) as SavedInvention[]
}
const first = await save('Batch One')
const draft = await save('Batch Draft')
await exports.default.fetch(
`${ORIGIN}/api/inventions/v3/publish?inventionId=${first.InventionId}`,
{
headers: await bearer('5566'),
}
)
// Repeated ids and comma-separated ids both work, and order is preserved.
const ids = await batch(`id=${first.InventionId}&id=${first.InventionId}`)
expect(ids.map((i) => i.InventionId)).toEqual([first.InventionId, first.InventionId])
const commaSeparated = await batch(`id=${first.InventionId},999999`)
expect(commaSeparated.map((i) => i.InventionId)).toEqual([first.InventionId])
// The draft is hidden from everyone but its creator.
expect((await batch(`id=${draft.InventionId}`)).map((i) => i.InventionId)).toEqual([])
expect((await batch(`id=${draft.InventionId}`, '9999')).map((i) => i.InventionId)).toEqual([])
expect((await batch(`id=${draft.InventionId}`, '5566')).map((i) => i.InventionId)).toEqual([
draft.InventionId,
])
// No ids at all → an empty list, not an error.
expect(await batch('')).toEqual([])
})
test('GET /api/inventions/v1/room lists a rooms published inventions', async () => {
// Two inventions created in room 76, one of them still a draft.
const create = async (name: string, room: number): Promise<SavedInvention> => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('8484')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name, creationRoomId: room, inventionDataFilename: 'a.inv' }),
})
return ((await res.json()) as InventionSaveResult).Invention
}
const publish = async (id: number): Promise<void> => {
await exports.default.fetch(`${ORIGIN}/api/inventions/v3/publish?inventionId=${id}`, {
headers: await bearer('8484'),
})
}
const inRoom = await create('Room Lamp', 76)
const draft = await create('Draft Lamp In Room', 76)
const otherRoom = await create('Other Room Lamp', 77)
await publish(inRoom.InventionId)
await publish(otherRoom.InventionId)
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/room?id=76`)
expect(res.status).toBe(200)
const ids = ((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
expect(ids).toEqual([inRoom.InventionId])
// The unpublished one and the other room's are both excluded.
expect(ids).not.toContain(draft.InventionId)
expect(ids).not.toContain(otherRoom.InventionId)
// A room with no inventions is an empty list, not a 404.
const empty = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/room?id=999`)
expect(await empty.json()).toEqual([])
const noId = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/room`)
expect(noId.status).toBe(400)
})
test('GET /api/inventions/v1/personaldetails/:id reports the cheer flag', async () => {
// No cheer storage yet, so nobody is ever cheering — signed in or not.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/personaldetails/2`, {
headers: await bearer('42'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ IsCheering: false })
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/personaldetails/2`)
expect(anon.status).toBe(200)
expect(await anon.json()).toEqual({ IsCheering: false })
})
test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('7373')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Versioned Lamp',
instantiationCost: 42,
inventionDataFilename: '2026-07-12/lamp.inv',
}),
})
const { Invention } = (await save.json()) as InventionSaveResult
// The bare RRInventionVersion — the blob name is what the client downloads.
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
)
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject({
InventionId: Invention.InventionId,
VersionNumber: 1,
BlobName: '2026-07-12/lamp.inv',
InstantiationCost: 42,
})
// Only the current version exists; anything else 404s, as does an unknown id.
const v2 = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=2`
)
expect(v2.status).toBe(404)
const unknown = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=999999&version=1`
)
expect(unknown.status).toBe(404)
// Both params are required.
const noVersion = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}`
)
expect(noVersion.status).toBe(400)
const noId = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/version?version=1`)
expect(noId.status).toBe(400)
})
test('GET /api/inventions/v1/update edits metadata + permission, creator only', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('3131')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Draft Lamp',
description: 'No description yet',
inventionDataFilename: 'a.inv',
}),
})
const { Invention } = (await save.json()) as InventionSaveResult
const update = async (query: string, sub = '3131'): Promise<Response> =>
exports.default.fetch(
`${ORIGIN}/api/inventions/v1/update?inventionId=${Invention.InventionId}&${query}`,
{ headers: await bearer(sub) }
)
// Update answers the save envelope. Only the params present change — the name
// is left alone here.
const res = await update(`description=${encodeURIComponent('my description')}`)
expect(res.status).toBe(200)
const edited = (await res.json()) as InventionSaveResult
expect(edited.Status).toBe(0)
expect(edited.InventionVersion.InventionId).toBe(Invention.InventionId)
expect(edited.Invention).toMatchObject({
InventionId: Invention.InventionId,
Description: 'my description',
Name: 'Draft Lamp',
IsPublished: false,
})
// `permission` takes a name or the raw number, and lands on GeneralPermission.
const byName = (await (await update('permission=edit_and_save')).json()) as InventionSaveResult
expect(byName.Invention.GeneralPermission).toBe(40)
const byNumber = (await (await update('permission=80')).json()) as InventionSaveResult
expect(byNumber.Invention.GeneralPermission).toBe(80)
// An empty description clears it; an empty name does *not* blank the invention.
const cleared = (await (await update('description=&name=')).json()) as InventionSaveResult
expect(cleared.Invention).toMatchObject({ Description: '', Name: 'Draft Lamp' })
// allowTrial takes true/1.
const trial = (await (await update('allowTrial=true')).json()) as InventionSaveResult
expect(trial.Invention.AllowTrial).toBe(true)
// Update does not publish or price — those are v3/publish and v1/updateprice.
expect(trial.Invention.IsPublished).toBe(false)
// Only the creator may edit; unknown inventions 404; no token → 401.
expect((await update('description=nope', '9999')).status).toBe(403)
const unknown = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/update?inventionId=999999&description=x`,
{ headers: await bearer('3131') }
)
expect(unknown.status).toBe(404)
const anon = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/update?inventionId=${Invention.InventionId}&description=x`
)
expect(anon.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',
headers: { ...(await bearer('2121')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Publishable Lamp', inventionDataFilename: 'a.inv' }),
})
const { Invention } = (await save.json()) as InventionSaveResult
const search = async (): Promise<number[]> => {
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v2/search?value=${encodeURIComponent('Publishable Lamp')}`
)
return ((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
}
// A saved draft is invisible until it's published.
expect(await search()).toEqual([])
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}&permissionLevel=charge&price=250`,
{ headers: await bearer('2121') }
)
expect(res.status).toBe(200)
const published = (await res.json()) as InventionSaveResult
expect(published.Status).toBe(0)
expect(published.Invention).toMatchObject({
IsPublished: true,
GeneralPermission: 80, // charge
Price: 250,
})
expect(typeof published.Invention.FirstPublishedAt).toBe('string')
expect(await search()).toEqual([Invention.InventionId])
// Publishing with no permissionLevel defaults to UseOnly, and price to 0.
const other = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('2121')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Plain Lamp', inventionDataFilename: 'a.inv' }),
})
const plain = ((await other.json()) as InventionSaveResult).Invention
const defaulted = (await (
await exports.default.fetch(
`${ORIGIN}/api/inventions/v3/publish?inventionId=${plain.InventionId}`,
{ headers: await bearer('2121') }
)
).json()) as InventionSaveResult
expect(defaulted.Invention).toMatchObject({
IsPublished: true,
GeneralPermission: 20, // useonly
Price: 0,
})
// Creator-gated like the other writes.
const notMine = await exports.default.fetch(
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}`,
{ headers: await bearer('9999') }
)
expect(notMine.status).toBe(403)
})
test('POST /api/inventions/v1/updateprice sets the price, creator only', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('1212')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Priced Lamp', inventionDataFilename: 'a.inv' }),
})
const { Invention } = (await save.json()) as InventionSaveResult
const updateprice = async (body: unknown, sub = '1212'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/api/inventions/v1/updateprice`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const res = await updateprice({ InventionId: Invention.InventionId, Price: 500 })
expect(res.status).toBe(200)
const priced = (await res.json()) as InventionSaveResult
expect(priced.Status).toBe(0)
expect(priced.Invention.Price).toBe(500)
// A negative price is rejected; other players can't reprice someone's invention.
expect((await updateprice({ InventionId: Invention.InventionId, Price: -1 })).status).toBe(400)
expect(
(await updateprice({ InventionId: Invention.InventionId, Price: 10 }, '9999')).status
).toBe(403)
})
test('GET /api/inventions/v1/toptoday + v1/featured serve the invention feeds', async () => {
const ids = async (res: Response): Promise<number[]> =>
((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
// Nothing is flagged IsFeatured yet → featured falls back to the top feed.
const beforeTop = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
const beforeFeatured = await ids(
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`)
)
expect(beforeFeatured).toEqual(beforeTop)
const feedInvention = (
id: number,
downloads: number,
extra: Partial<SavedInvention> = {}
): SavedInvention =>
({
InventionId: id,
CreatorPlayerId: 8080,
Name: `Feed ${id}`,
Description: '',
ImageName: '',
CurrentVersionNumber: 1,
CurrentVersion: { InventionId: id, VersionNumber: 1, BlobName: '' },
IsPublished: true,
IsFeatured: false,
HideFromPlayer: false,
NumDownloads: downloads,
CheerCount: 0,
NumPlayersHaveUsedInRoom: 0,
CreatedAt: '2026-07-01T00:00:00Z',
...extra,
}) as unknown as SavedInvention
for (const inv of [
feedInvention(201, 500),
feedInvention(202, 9000, { IsFeatured: true, CreatedAt: '2026-07-02T00:00:00Z' }),
feedInvention(203, 3000, { IsFeatured: true, CreatedAt: '2026-07-03T00:00:00Z' }),
// Unpublished/hidden inventions stay out of both feeds, featured or not.
feedInvention(204, 99999, { IsPublished: false, IsFeatured: true }),
feedInvention(205, 99999, { HideFromPlayer: true, IsFeatured: true }),
]) {
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
.bind(JSON.stringify(inv))
.run()
}
// Top: engagement-ranked, so the biggest download counts lead.
const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
expect(top.slice(0, 3)).toEqual([202, 203, 201])
expect(top).not.toContain(204)
expect(top).not.toContain(205)
// Featured: only the flagged, visible inventions — newest first.
const featured = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))
expect(featured).toEqual([203, 202])
// skip/take paginate the top feed.
const page = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?skip=1&take=1`)
expect(await ids(page)).toEqual([203])
})
test('POST /api/sanitize/v1 echoes the value; isPure reports true', async () => {
const san = await exports.default.fetch(`${ORIGIN}/api/sanitize/v1`, {
method: 'POST',
+6
View File
@@ -107,4 +107,10 @@ const app = new Hono<App>()
// the path.
.get('/room/:dataBlob{.+}', (c) => serveAsset(c, `room/${c.req.param('dataBlob')}`))
// Invention data by name. The client fetches this for an invention's
// `CurrentVersion.BlobName` to spawn it. Streamed from R2 under `invention/`.
// Like room blobs the name is date-foldered, and it carries the `.inv` extension
// the upload stored it under, so the rest of the path is matched as-is.
.get('/invention/:dataBlob{.+}', (c) => serveAsset(c, `invention/${c.req.param('dataBlob')}`))
export default app
+15
View File
@@ -64,4 +64,19 @@ describe('cdn endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/room/missing.room`)
expect(res.status).toBe(404)
})
test('GET /invention/:dataBlob streams the invention blob from R2', async () => {
// Date-foldered, `.inv`-suffixed — the name the storage worker generates and the
// api worker hands back as the invention's BlobName.
const name = '2026-07-12/6f1c0c3e-1b6a-4a52-9f52-0f4a1a6d2f77.inv'
await env.CDN_ASSETS.put(`invention/${name}`, new Uint8Array([1, 2, 3]))
const res = await exports.default.fetch(`${ORIGIN}/invention/${name}`)
expect(res.status).toBe(200)
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3]))
})
test('GET /invention/:dataBlob 404s when the blob is absent', async () => {
const res = await exports.default.fetch(`${ORIGIN}/invention/missing.inv`)
expect(res.status).toBe(404)
})
})
+19 -3
View File
@@ -36,6 +36,20 @@ function subfolderForFileType(fileType: string): string | undefined {
return UPLOAD_SUBFOLDER[Number.parseInt(fileType, 10)]
}
/**
* The file extension an upload of a given type keeps. Invention data blobs are
* named `<name>.inv` — the client expects the extension on the `BlobName` it later
* gets back from the api worker, so it has to be part of the stored key too, or the
* blob wouldn't be there to download. Other types are stored under a bare name.
*/
const UPLOAD_EXTENSION: Record<number, string> = {
5: '.inv',
}
function extensionForFileType(fileType: string): string {
return UPLOAD_EXTENSION[Number.parseInt(fileType, 10)] ?? ''
}
/** Read a text form field by any of its accepted names, matched case-insensitively. */
function textField(body: Record<string, unknown>, ...names: string[]): string | undefined {
for (const [key, value] of Object.entries(body)) {
@@ -80,16 +94,18 @@ const app = new Hono<App>()
const file = Object.values(body).find((v): v is File => v instanceof File)
if (file) {
const subfolder = subfolderForFileType(textField(body, 'filetype') ?? '0')
const fileType = textField(body, 'filetype') ?? '0'
const subfolder = subfolderForFileType(fileType)
if (subfolder === undefined) {
// makeUploadName == "" → no destination for an unknown/missing type.
return c.json({ error: 'missing or unknown FileType' }, 400)
}
// Folder each upload under its date (e.g. `room/2026-02-03/<uuid>`) so the
// bucket stays browsable. The date is part of the returned name, so the key
// the `cdn` worker reads back (`<subfolder>/<name>`) still round-trips.
// the `cdn` worker reads back (`<subfolder>/<name>`) still round-trips — as
// does the extension, which is why it goes on the key, not just the name.
const datePrefix = new Date().toISOString().slice(0, 10)
const filename = `${datePrefix}/${crypto.randomUUID()}`
const filename = `${datePrefix}/${crypto.randomUUID()}${extensionForFileType(fileType)}`
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type || 'application/octet-stream' },
})
@@ -103,6 +103,25 @@ it('POST /upload folders each FileType under its own subfolder', async () => {
}
})
it('POST /upload names an Invention (FileType 5) upload with the .inv extension', async () => {
// The client expects the `.inv` extension on the BlobName it later reads back from
// the api worker, so the extension has to be on the stored key too — otherwise the
// cdn worker would have nothing to serve at that name.
const bytes = new Uint8Array([0x49, 0x4e, 0x56])
const res = await SELF.fetch(`${ORIGIN}/upload`, {
method: 'POST',
headers: await bearer(),
body: uploadForm('5', bytes),
})
expect(res.status).toBe(200)
const { filename } = (await res.json()) as { filename: string }
expect(filename).toMatch(/^\d{4}-\d{2}-\d{2}\/[0-9a-f-]{36}\.inv$/)
const stored = await env.CDN_ASSETS.get(`invention/${filename}`)
expect(stored).not.toBeNull()
expect(new Uint8Array(await stored!.arrayBuffer())).toEqual(bytes)
})
it('POST /upload 400s for a binary with an unknown/missing FileType', async () => {
// Unknown type (999) and the Unknown enum value (0) have no destination → 400.
for (const fileType of ['999', '0']) {