avatar endpoints

This commit is contained in:
Devin Zuczek
2026-06-30 18:52:37 -04:00
parent 1c89350906
commit 3a3f96bba6
11 changed files with 3213 additions and 533 deletions
+53
View File
@@ -0,0 +1,53 @@
/**
* Avatar storage on the shared `recflare` accounts table. The avatar is a single
* JSON payload the client sends/consumes and never queries on, so it lives in a
* dedicated nullable `avatar` TEXT column on the player's account row (added by
* the auth worker's migration 0002_avatar).
*
* The `auth` worker owns the accounts schema/migrations; econ only reads/writes
* the avatar column. SCHEMA_DDL mirrors the table so tests can build it without
* depending on the auth worker — keep it in sync with auth's accounts-db.ts.
*/
/** Schema DDL for tests — the accounts table including the avatar column. */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS accounts (
data TEXT NOT NULL,
avatar TEXT,
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.AccountId')) VIRTUAL,
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Username'))) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON accounts (account_id)`,
]
/** The stored avatar payload — opaque JSON the client sets and reads back. */
export type Avatar = Record<string, unknown>
interface AvatarRow {
avatar: string | null
}
/** Read the player's stored avatar, or null when they have none yet. */
export async function getAvatar(db: D1Database, accountId: number): Promise<Avatar | null> {
const row = await db
.prepare('SELECT avatar FROM accounts WHERE account_id = ?1')
.bind(accountId)
.first<AvatarRow>()
return row?.avatar ? (JSON.parse(row.avatar) as Avatar) : null
}
/**
* Persist the player's avatar onto their account row. Returns false when no
* account row exists for the id (nothing was updated).
*/
export async function setAvatar(
db: D1Database,
accountId: number,
avatar: Avatar
): Promise<boolean> {
const { meta } = await db
.prepare('UPDATE accounts SET avatar = ?2 WHERE account_id = ?1')
.bind(accountId, JSON.stringify(avatar))
.run()
return meta.changes > 0
}
+2 -1
View File
@@ -2,7 +2,8 @@ import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
export type Env = SharedHonoEnv & {
// add additional Bindings here
/** Shared `recflare` D1 (accounts table) — stores the player's avatar. */
DB: D1Database
}
/** Variables can be extended */
+28 -12
View File
@@ -8,6 +8,7 @@ import defaultAvatarItems from '../static/default-avatar-items.json'
import myProgress from '../static/my-progress.json'
import storefrontGiftDrop3 from '../static/storefronts-v3-giftdropstore-3.json'
import weeklyChallenge from '../static/weekly-challenge.json'
import { getAvatar, setAvatar } from './avatar-db'
import { validateAndGetAccountId } from './jwt'
import type { Context } from 'hono'
@@ -72,9 +73,8 @@ const app = new Hono<App>()
// Default-unlocked avatar items, served from the bundled static JSON.
.get('/api/avatar/v1/defaultunlocked', (c) => c.json(defaultAvatarItems))
// Default base avatar items. Reads the same source file as defaultunlocked,
// so it returns the identical catalog.
.get('/api/avatar/v1/defaultbaseavataritems', (c) => c.json(defaultAvatarItems))
// Default base avatar items — empty stub for now. No auth.
.get('/api/avatar/v1/defaultbaseavataritems', (c) => c.json([]))
// The player's avatar items — owned items concatenated with the default
// catalog. No DB binding yet, so owned is empty and this is just the catalog.
@@ -85,25 +85,41 @@ const app = new Hono<App>()
return c.json(defaultAvatarItems)
})
// The player's owned custom avatar items. No auth; returns `{ items: [] }`.
// The client downloads these when custom-item creation is
// The player's owned custom avatar items. [Authorize]; paginated. Empty stub for
// now (no DB binding). The client downloads these when custom-item creation is
// allowed; a 404 here surfaces as "Failed to download unlocked avatar items".
.get('/econ/customAvatarItems/v1/owned', (c) => c.json({ items: [] }))
.get('/econ/customAvatarItems/v1/owned', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({ Results: [], TotalResults: 0 })
})
// The player's objectives progress. Serves a static JSON file verbatim with
// no auth — same default for everyone until there's a DB binding to track
// per-player progress.
.get('/api/objectives/v1/myprogress', (c) => c.json(myProgress))
// The player's avatar. No DB binding yet, so it always returns the default
// for a player with no PlayerAvatar row.
// The player's avatar, stored as a JSON blob on their account row. Falls back
// to the default outfit when they haven't saved one — the client's parser NREs
// on an empty OutfitSelections (real RecNet never returns one).
.get('/api/avatar/v2', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// TODO: load/create the PlayerAvatar for `id` once a DB binding exists.
// Must return a populated outfit — the client's parser NREs on an empty
// OutfitSelections (real RecNet never returns one), so serve a valid default.
return c.json(defaultAvatar)
return c.json((await getAvatar(c.env.DB, id)) ?? defaultAvatar)
})
// Save the player's avatar. [Authorize]. Stores the posted JSON payload verbatim
// on the account row and echoes it back. 400 on a non-object body; 404 when the
// caller has no account row to attach it to.
.post('/api/avatar/v2/set', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const avatar = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (avatar === null || typeof avatar !== 'object' || Array.isArray(avatar)) {
return c.body(null, 400)
}
if (!(await setAvatar(c.env.DB, id, avatar))) return c.body(null, 404)
return c.json(avatar)
})
// NUX checklist — the client fetches this on the econ host during load. []
+76 -11
View File
@@ -1,10 +1,28 @@
import { env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers'
import { describe, expect, test } from 'vitest'
import { beforeAll, describe, expect, test } from 'vitest'
import '../../econ.app'
import { SCHEMA_DDL } from '../../avatar-db'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const ORIGIN = 'https://example.com'
// Build the accounts table and seed the test player (the default token's sub, 42)
// so avatar reads/writes have a row to attach to.
beforeAll(async () => {
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
await env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)')
.bind(JSON.stringify({ AccountId: 42, Username: 'Tester', DisplayName: 'Tester' }))
.run()
})
// Mint a token the way the `auth` worker does, using the same dev secret.
const DEV_SECRET = 'dev-insecure-signing-key-change-me'
@@ -41,13 +59,10 @@ describe('econ endpoints', () => {
expect(body[0]).toHaveProperty('AvatarItemDesc')
})
test('GET /api/avatar/v1/defaultbaseavataritems returns the same catalog', async () => {
test('GET /api/avatar/v1/defaultbaseavataritems is an empty stub (no auth)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v1/defaultbaseavataritems`)
expect(res.status).toBe(200)
const body = (await res.json()) as unknown[]
expect(Array.isArray(body)).toBe(true)
expect(body.length).toBeGreaterThan(0)
expect(body[0]).toHaveProperty('AvatarItemDesc')
expect(await res.json()).toEqual([])
})
test('GET /api/avatar/v4/items 401s without a token', async () => {
@@ -72,8 +87,11 @@ describe('econ endpoints', () => {
expect(res.status).toBe(401)
})
test('GET /api/avatar/v2 returns a populated default avatar with a valid token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2`, { headers: await bearer() })
test('GET /api/avatar/v2 returns a populated default avatar when none is saved', async () => {
// Account 7 has no saved avatar → falls back to the default outfit.
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2`, {
headers: await bearer('7'),
})
expect(res.status).toBe(200)
const body = (await res.json()) as { OutfitSelections: string; FaceFeatures: string }
// Must be non-empty — the client's outfit parser NREs on an empty string.
@@ -82,10 +100,57 @@ describe('econ endpoints', () => {
expect(body.FaceFeatures).toContain('eyeId')
})
test('GET /econ/customAvatarItems/v1/owned returns { items: [] } (no auth)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/econ/customAvatarItems/v1/owned`)
test('POST /api/avatar/v2/set 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/set`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ OutfitSelections: 'a,,0' }),
})
expect(res.status).toBe(401)
})
test('POST /api/avatar/v2/set saves the avatar, and GET reads it back', async () => {
const headers = { ...(await bearer()), 'Content-Type': 'application/json' }
const avatar = {
OutfitSelections: '1fd69ef8-0b74-4962-af5a-67f0bf0358f2,,0;d0a9262f-5504-46a7-bb10-7507503db58e,,1',
OutfitSelectionsV2: '{"selections":[]}',
FaceFeatures: '{"eyeId":"AjGMoJhEcEehacRZjUMuDg"}',
SkinColor: '3529b670-a66d-448e-9573-1905eae5b9bf',
HairColor: '0e_jaaObREWTf1AorAZ95g',
CustomAvatarItems: [],
}
// Save echoes the payload back.
const setRes = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/set`, {
method: 'POST',
headers,
body: JSON.stringify(avatar),
})
expect(setRes.status).toBe(200)
expect(await setRes.json()).toEqual(avatar)
// And it persists — GET now returns the saved avatar, not the default.
const getRes = await exports.default.fetch(`${ORIGIN}/api/avatar/v2`, { headers: await bearer() })
expect(await getRes.json()).toEqual(avatar)
})
test('POST /api/avatar/v2/set 404s when the caller has no account row', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/set`, {
method: 'POST',
headers: { ...(await bearer('99999')), 'Content-Type': 'application/json' },
body: JSON.stringify({ OutfitSelections: 'a,,0' }),
})
expect(res.status).toBe(404)
})
test('GET /econ/customAvatarItems/v1/owned 401s without a token, returns an empty paginated stub', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/econ/customAvatarItems/v1/owned`)
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/econ/customAvatarItems/v1/owned`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ items: [] })
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
})
test('GET /api/objectives/v1/myprogress returns the default progress (no auth)', async () => {