diff --git a/apps/api/migrations/0002_invention.sql b/apps/api/migrations/0002_invention.sql new file mode 100644 index 0000000..70f1d29 --- /dev/null +++ b/apps/api/migrations/0002_invention.sql @@ -0,0 +1,18 @@ +-- Saved-invention metadata storage. Like the image/rooms/accounts tables in this +-- shared database, an invention is a single JSON blob in the `data` column, with +-- queryable fields (Id, CreatorPlayerId) exposed as SQLite generated (virtual) +-- columns extracted from that JSON. Owned by the `api` worker; generated from +-- src/inventions-db.ts (SCHEMA_DDL) — keep in sync. +-- +-- The invention's data file is uploaded separately through the `storage` worker +-- (under the `invention/` prefix) and referenced here by `CurrentVersion.BlobName`; +-- only the metadata lives in this table. The DTO mirrors Rec Room's PascalCase +-- `RRInvention` shape. + +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 + ); +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); diff --git a/apps/api/src/inventions-db.ts b/apps/api/src/inventions-db.ts new file mode 100644 index 0000000..cdd37a5 --- /dev/null +++ b/apps/api/src/inventions-db.ts @@ -0,0 +1,179 @@ +/** + * Saved-invention storage on the shared `recflare` D1 database. Each invention is + * a single JSON blob in the `data` column; queryable fields (Id, CreatorPlayerId) + * are SQLite generated (virtual) columns extracted from that JSON — the same + * JSON-blob pattern the image/rooms/accounts tables use. + * + * The `api` worker owns this schema/migration (migrations/0002_invention.sql, + * applied under its own `migrations_table`). The invention's data file itself is + * uploaded separately through the `storage` worker (under the `invention/` prefix) + * and referenced here by `CurrentVersion.BlobName`; only the metadata lives here. + * + * The stored/returned DTO mirrors Rec Room's `RRInvention` (PascalCase), including + * the nested `CurrentVersion` that carries the blob name and per-version costs — + * shaped after a real `GET /api/inventions/v1?inventionId=…` response. + */ + +/** Schema DDL (mirror of migrations/0002_invention.sql, sans any seed rows). */ +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 + )`, + `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)`, +] + +/** A single saved version of an invention (Rec Room's `RRInventionVersion`). */ +export interface InventionVersion { + InventionId: number + ReplicationId: string + VersionNumber: number + BlobName: string + BlobHash: string | null + InstantiationCost: number + LightsCost: number + ChipsCost: number + CloudVariablesCost: number + AICost: number +} + +/** A stored invention record (Rec Room's `RRInvention`; returned by save / mine). */ +export interface SavedInvention { + InventionId: number + ReplicationId: string + CreatorPlayerId: number + Name: string + Description: string + ImageName: string + CurrentVersionNumber: number + CurrentVersion: InventionVersion + Accessibility: number + IsPublished: boolean + IsFeatured: boolean + ModifiedAt: string + CreatedAt: string + FirstPublishedAt: string | null + CreationRoomId: number + NumPlayersHaveUsedInRoom: number + NumDownloads: number + CheerCount: number + CreatorPermission: number + GeneralPermission: number + IsAGInvention: boolean + IsCertifiedInvention: boolean + Price: number + AllowTrial: boolean + HideFromPlayer: boolean + ReferencedInventions: number[] +} + +interface InventionRow { + data: string +} + +/** Fields the client supplies on save (camelCase); everything else is defaulted here. */ +export interface NewInvention { + creatorPlayerId: number + name: string + description?: string | null + imageName?: string | null + instantiationCost?: number + lightsCost?: number + chipsCost?: number + 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. + */ +export async function createInvention( + db: D1Database, + input: NewInvention +): Promise { + // Sequential id: one past the current max (the table starts empty). + const row = await db + .prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM invention') + .first<{ next: number }>() + const inventionId = row?.next ?? 1 + const now = new Date().toISOString() + const invention: SavedInvention = { + InventionId: inventionId, + ReplicationId: crypto.randomUUID(), + CreatorPlayerId: input.creatorPlayerId, + Name: input.name, + Description: input.description ?? '', + ImageName: input.imageName ?? '', + CurrentVersionNumber: 1, + CurrentVersion: { + InventionId: inventionId, + ReplicationId: crypto.randomUUID(), + VersionNumber: 1, + BlobName: input.inventionDataFilename ?? '', + BlobHash: null, + InstantiationCost: input.instantiationCost ?? 0, + LightsCost: input.lightsCost ?? 0, + ChipsCost: input.chipsCost ?? 0, + CloudVariablesCost: input.cloudVariablesCost ?? 0, + AICost: input.aiCost ?? 0, + }, + Accessibility: 0, + IsPublished: false, + IsFeatured: false, + ModifiedAt: now, + CreatedAt: now, + FirstPublishedAt: null, + CreationRoomId: input.creationRoomId ?? 0, + NumPlayersHaveUsedInRoom: 0, + NumDownloads: 0, + CheerCount: 0, + CreatorPermission: input.creatorAccountRole ?? 0, + GeneralPermission: 0, + IsAGInvention: false, + IsCertifiedInvention: false, + Price: 0, + AllowTrial: false, + HideFromPlayer: false, + ReferencedInventions: input.referencedInventions ?? [], + } + await db.prepare('INSERT INTO invention (data) VALUES (?1)').bind(JSON.stringify(invention)).run() + return invention +} + +/** + * The inventions a player has created — their "my inventions" list, newest first. + * Uses the creator_player_id index; the per-player set is small, so ordering is + * done in memory. Returns a bare array of SavedInvention. + */ +export async function getInventionsByCreator( + db: D1Database, + creatorPlayerId: number +): Promise { + const { results } = await db + .prepare('SELECT data FROM invention WHERE creator_player_id = ?1') + .bind(creatorPlayerId) + .all() + return results + .map((r) => JSON.parse(r.data) as SavedInvention) + .sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId) +} + +/** Look up a single invention by its numeric id, or null when there's no such row. */ +export async function getInventionById( + db: D1Database, + inventionId: number +): Promise { + const row = await db + .prepare('SELECT data FROM invention WHERE id = ?1') + .bind(inventionId) + .first() + return row ? (JSON.parse(row.data) as SavedInvention) : null +} diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index a7ddf8c..450ab22 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -1,6 +1,7 @@ import { Hono } from 'hono' import { authedId, unauthorized } from '../http' +import { createInvention, getInventionById, getInventionsByCreator } from '../inventions-db' import type { App } from '../context' @@ -68,5 +69,55 @@ export const avatarRoutes = new Hono({ strict: false }) c.json({ Results: [], TotalResults: 0 }) ) - // Saved inventions — empty list with no DB. - .get('/api/inventions/v2/mine', (c) => c.json([])) + // A single invention by id (`?inventionId=…`). Returns the stored RRInvention, + // or 404 when there's no such invention. + .get('/api/inventions/v1', async (c) => { + const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10) + if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400) + const invention = await getInventionById(c.env.DB, inventionId) + return invention ? c.json(invention) : c.notFound() + }) + + // 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) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json(await getInventionsByCreator(c.env.DB, id)) + }) + + // 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). + .post('/api/inventions/v6/save', async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const body = (await c.req.json().catch(() => null)) as Record | null + if (body === null) return c.json({ error: 'Invalid request body' }, 400) + + 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 invention = await createInvention(c.env.DB, { + creatorPlayerId: id, + name, + description: str(body.description), + imageName: str(body.imageName), + instantiationCost: num(body.instantiationCost), + lightsCost: num(body.lightsCost), + chipsCost: num(body.chipsCost), + 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) + }) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index aa64fa8..afe8164 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -5,10 +5,12 @@ import { beforeAll, describe, expect, test } from 'vitest' import '../../api.app' import { SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db' +import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db' 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' declare module 'cloudflare:test' { interface ProvidedEnv extends Env {} @@ -72,6 +74,9 @@ beforeAll(async () => { // Relationships table (owned by the api worker) — friendship endpoints use it. for (const stmt of RELATIONSHIPS_SCHEMA_DDL) await env.DB.prepare(stmt).run() + + // Inventions table (owned by the api worker) — invention save/mine use it. + for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run() }) // Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the @@ -247,12 +252,94 @@ describe('public endpoints', () => { expect(await res.json()).toEqual({}) }) - test('GET /api/inventions/v2/mine returns []', async () => { + test('GET /api/inventions/v2/mine 401s without a bearer token', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`) + expect(res.status).toBe(401) + }) + + test('GET /api/inventions/v2/mine returns [] for a player with none', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, { + headers: await bearer('7777'), + }) expect(res.status).toBe(200) expect(await res.json()).toEqual([]) }) + test('POST /api/inventions/v6/save persists the invention and lists it in mine', async () => { + const body = { + name: '071126 13:10:50', + description: 'No description yet', + imageName: '2026-07-11/0ff3d5f9-e544-422d-84a0-dec46195a82b.jpg', + instantiationCost: 103, + lightsCost: 0, + chipsCost: 0, + cloudVariablesCost: 0, + aiCost: 0, + creationRoomId: 73, + inventionDataFilename: '2026-07-11/cc15a7fa-2e81-4da0-b8f1-2a4dcd8ae1a3', + referencedInventions: [], + creatorAccountRole: 255, + } + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { + method: 'POST', + headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + expect(res.status).toBe(200) + const saved = (await res.json()) as SavedInvention + 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) + expect(saved.CreationRoomId).toBe(73) + expect(saved.CreatorPermission).toBe(255) + // Freshly saved → private/unpublished until the player publishes it. + expect(saved.IsPublished).toBe(false) + expect(saved.FirstPublishedAt).toBeNull() + expect(typeof saved.CreatedAt).toBe('string') + + const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, { + headers: await bearer('5150'), + }) + expect(mine.status).toBe(200) + const list = (await mine.json()) as SavedInvention[] + expect(list.map((i) => i.InventionId)).toContain(saved.InventionId) + + // The saved invention is fetchable by id via the v1 lookup. + const one = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1?inventionId=${saved.InventionId}` + ) + expect(one.status).toBe(200) + expect((await one.json()) as SavedInvention).toMatchObject({ InventionId: saved.InventionId }) + }) + + test('POST /api/inventions/v6/save 401s without a bearer token', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'x' }), + }) + expect(res.status).toBe(401) + }) + + test('POST /api/inventions/v6/save 400s without a name', 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' }), + }) + expect(res.status).toBe(400) + }) + + 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('POST /api/sanitize/v1 echoes the value; isPure reports true', async () => { const san = await exports.default.fetch(`${ORIGIN}/api/sanitize/v1`, { method: 'POST', @@ -663,18 +750,30 @@ describe('relationships', () => { test('send → the two sides see Sent / Received; accept → both Friend; remove → gone', async () => { // 500 sends 501 a request. - const sent = (await (await mutate('/api/relationships/v2/sendfriendrequest', '500', 501)).json()) as Rel + const sent = (await ( + await mutate('/api/relationships/v2/sendfriendrequest', '500', 501) + ).json()) as Rel expect(sent).toMatchObject({ PlayerID: 501, RelationshipType: 1 }) // 500 sees it as Sent (1); 501 sees the mirror as Received (2). - expect(await relationships('500')).toEqual([{ PlayerID: 501, RelationshipType: 1, Favorited: 0, Ignored: 0, Muted: 0 }]) - expect(await relationships('501')).toEqual([{ PlayerID: 500, RelationshipType: 2, Favorited: 0, Ignored: 0, Muted: 0 }]) + expect(await relationships('500')).toEqual([ + { PlayerID: 501, RelationshipType: 1, Favorited: 0, Ignored: 0, Muted: 0 }, + ]) + expect(await relationships('501')).toEqual([ + { PlayerID: 500, RelationshipType: 2, Favorited: 0, Ignored: 0, Muted: 0 }, + ]) // 501 accepts → both are Friends (3). - const accepted = (await (await mutate('/api/relationships/v2/acceptfriendrequest', '501', 500)).json()) as Rel + const accepted = (await ( + await mutate('/api/relationships/v2/acceptfriendrequest', '501', 500) + ).json()) as Rel expect(accepted).toMatchObject({ PlayerID: 500, RelationshipType: 3 }) - expect(await relationships('500')).toEqual([{ PlayerID: 501, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }]) - expect(await relationships('501')).toEqual([{ PlayerID: 500, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }]) + expect(await relationships('500')).toEqual([ + { PlayerID: 501, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }, + ]) + expect(await relationships('501')).toEqual([ + { PlayerID: 500, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }, + ]) // 500 removes → neither side has a relationship. expect((await mutate('/api/relationships/v2/removefriend', '500', 501)).status).toBe(200) @@ -685,15 +784,21 @@ describe('relationships', () => { test('addfriend makes them friends directly', async () => { const res = (await (await mutate('/api/relationships/v2/addfriend', '510', 511)).json()) as Rel expect(res).toMatchObject({ PlayerID: 511, RelationshipType: 3 }) - expect(await relationships('511')).toEqual([{ PlayerID: 510, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }]) + expect(await relationships('511')).toEqual([ + { PlayerID: 510, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }, + ]) }) test('crossing friend requests become a friendship', async () => { await mutate('/api/relationships/v2/sendfriendrequest', '520', 521) // 521 sends back to 520 → the crossing requests resolve to Friend for both. - const crossed = (await (await mutate('/api/relationships/v2/sendfriendrequest', '521', 520)).json()) as Rel + const crossed = (await ( + await mutate('/api/relationships/v2/sendfriendrequest', '521', 520) + ).json()) as Rel expect(crossed).toMatchObject({ PlayerID: 520, RelationshipType: 3 }) - expect(await relationships('520')).toEqual([{ PlayerID: 521, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }]) + expect(await relationships('520')).toEqual([ + { PlayerID: 521, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }, + ]) }) test('a self-targeted request is rejected', async () => { @@ -739,6 +844,8 @@ describe('relationships', () => { }) // 710's own side is untouched — the requester never ignored anyone. const view710 = (await relationships('710')) as unknown as FullRel[] - expect(view710).toEqual([expect.objectContaining({ PlayerID: 711, RelationshipType: 1, Ignored: 0 })]) + expect(view710).toEqual([ + expect.objectContaining({ PlayerID: 711, RelationshipType: 1, Ignored: 0 }), + ]) }) })