From ae85bedea170b31cbc4d83d7262d737c7a86acf8 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Sun, 12 Jul 2026 19:55:51 -0400 Subject: [PATCH] inventions...almost working --- .../migrations/0003_invention_featured.sql | 13 + apps/api/src/inventions-db.ts | 457 +++++++++++++- apps/api/src/routes/avatar.ts | 252 +++++++- apps/api/src/test/integration/api.test.ts | 582 +++++++++++++++++- apps/cdn/src/cdn.app.ts | 6 + apps/cdn/src/test/integration/api.test.ts | 15 + apps/storage/src/storage.app.ts | 22 +- apps/storage/src/test/integration/api.test.ts | 19 + 8 files changed, 1333 insertions(+), 33 deletions(-) create mode 100644 apps/api/migrations/0003_invention_featured.sql diff --git a/apps/api/migrations/0003_invention_featured.sql b/apps/api/migrations/0003_invention_featured.sql new file mode 100644 index 0000000..1d64970 --- /dev/null +++ b/apps/api/migrations/0003_invention_featured.sql @@ -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); diff --git a/apps/api/src/inventions-db.ts b/apps/api/src/inventions-db.ts index cdd37a5..c0e2964 100644 --- a/apps/api/src/inventions-db.ts +++ b/apps/api/src/inventions-db.ts @@ -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 `.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 { + 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 { + // 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() + 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 { + 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 { + 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 { + const invention = await getInventionById(db, inventionId) + if (invention === null) return null + + const tags: InventionTag[] = [] + const seen = new Set() + 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 { + 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 { + 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 { + 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 { + const counts = new Map() + 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 { + 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() + + const byId = new Map() + 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 { + 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() + 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 { + 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 { + 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 { + 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, diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index 450ab22..aa6e8be 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -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, + inventionId: number +): Promise<{ invention: SavedInvention } | { response: Response | Promise }> { + 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({ 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 | 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 | 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({ 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({ 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({ 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)) }) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index afe8164..c2af67a 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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 => + 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 => { + 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 => { + 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 => { + 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 room’s published inventions', async () => { + // Two inventions created in room 76, one of them still a draft. + const create = async (name: string, room: number): Promise => { + 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 => { + 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 => + 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 => { + 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 => + 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 => + ((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 => + ({ + 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', diff --git a/apps/cdn/src/cdn.app.ts b/apps/cdn/src/cdn.app.ts index 08db9a1..fa54012 100644 --- a/apps/cdn/src/cdn.app.ts +++ b/apps/cdn/src/cdn.app.ts @@ -107,4 +107,10 @@ const app = new Hono() // 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 diff --git a/apps/cdn/src/test/integration/api.test.ts b/apps/cdn/src/test/integration/api.test.ts index cd073bd..c745082 100644 --- a/apps/cdn/src/test/integration/api.test.ts +++ b/apps/cdn/src/test/integration/api.test.ts @@ -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) + }) }) diff --git a/apps/storage/src/storage.app.ts b/apps/storage/src/storage.app.ts index 465ed48..40a9e2f 100644 --- a/apps/storage/src/storage.app.ts +++ b/apps/storage/src/storage.app.ts @@ -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 `.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 = { + 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, ...names: string[]): string | undefined { for (const [key, value] of Object.entries(body)) { @@ -80,16 +94,18 @@ const app = new Hono() 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/`) so the // bucket stays browsable. The date is part of the returned name, so the key - // the `cdn` worker reads back (`/`) still round-trips. + // the `cdn` worker reads back (`/`) 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' }, }) diff --git a/apps/storage/src/test/integration/api.test.ts b/apps/storage/src/test/integration/api.test.ts index a49f51b..65a4bb4 100644 --- a/apps/storage/src/test/integration/api.test.ts +++ b/apps/storage/src/test/integration/api.test.ts @@ -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']) {