mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
avatar endpoints
This commit is contained in:
@@ -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,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
@@ -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. []
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user