From 767dd47babef8a26c0a552fef626a6923a2e1e62 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Fri, 12 Jun 2026 17:49:24 -0400 Subject: [PATCH] add more econ stuff --- apps/econ/README.md | 13 +++-- apps/econ/src/econ.app.ts | 44 ++++++++++++++++ apps/econ/src/jwt.ts | 58 +++++++++++++++++++++ apps/econ/src/test/integration/api.test.ts | 59 ++++++++++++++++++++++ 4 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 apps/econ/src/jwt.ts diff --git a/apps/econ/README.md b/apps/econ/README.md index 1404a6e..4c944e9 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -6,9 +6,16 @@ endpoints the game client calls on the `econ` service (distinct from the main ## Endpoints -- `GET /api/avatar/v1/defaultunlocked` — default-unlocked avatar items. Returns - an empty array until there's a DB binding. +- `GET /api/avatar/v1/defaultunlocked` — default-unlocked avatar items, served + from the bundled `static/default-avatar-items.json` catalog. +- `GET /api/avatar/v4/items` — `[Authorize]`. The player's avatar items: owned + items concatenated with the default catalog. No DB binding yet, so owned is + empty and this returns just the catalog. +- `GET /api/avatar/v2` — `[Authorize]`. The player's avatar. No DB binding yet, + so it returns the default `{ OutfitSelections, FaceFeatures, SkinColor, + HairColor }` the C# seeds for a new player. ## TODO before production -- Wire a DB binding for the default-unlocked item set. +- Wire a DB binding and prepend each player's owned `AvatarItems` to + `/api/avatar/v4/items`. diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index d80a89d..ce2b8b5 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -4,14 +4,40 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { withNotFound, withOnError } from '@repo/hono-helpers' import defaultAvatarItems from '../static/default-avatar-items.json' +import { validateAndGetAccountId } from './jwt' +import type { Context } from 'hono' import type { App } from './context' /** * Economy Worker. Hosts the avatar/economy endpoints the game client calls on * the `econ` service (these are separate from the main `api` worker). DB-backed * data is stubbed for now — no bindings yet. + * + * Auth-gated routes still validate the Bearer JWT issued by the `auth` worker. */ + +/** + * Resolve the account id from a Bearer token. Returns `null` when the header is + * missing, the token is invalid, or the `sub` claim isn't an integer. + */ +async function authedId(c: Context): Promise { + const authHeader = c.req.header('Authorization') ?? '' + if (!authHeader.toLowerCase().startsWith('bearer ')) return null + + const token = authHeader.slice('Bearer '.length) + const accountId = await validateAndGetAccountId(token) + if (!accountId) return null + + const id = Number.parseInt(accountId, 10) + return Number.isNaN(id) ? null : id +} + +/** Results.Unauthorized() equivalent — 401 with empty body. */ +function unauthorized(c: Context) { + return c.body(null, 401) +} + const app = new Hono() .use( '*', @@ -29,4 +55,22 @@ const app = new Hono() // Default-unlocked avatar items, served from the bundled static JSON. .get('/api/avatar/v1/defaultunlocked', (c) => c.json(defaultAvatarItems)) + // 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. + .get('/api/avatar/v4/items', async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + // TODO: prepend the player's owned AvatarItems once a DB binding exists. + return c.json(defaultAvatarItems) + }) + + // The player's avatar. No DB binding yet, so it always returns the default + // the C# seeds for a player with no PlayerAvatar row. + .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. + return c.json({ OutfitSelections: '', FaceFeatures: '{}', SkinColor: '', HairColor: '' }) + }) + export default app diff --git a/apps/econ/src/jwt.ts b/apps/econ/src/jwt.ts new file mode 100644 index 0000000..d3c5d2d --- /dev/null +++ b/apps/econ/src/jwt.ts @@ -0,0 +1,58 @@ +/** + * Minimal HS256 JWT validation, mirroring the C# `JwtTokenService.ValidateAndGetAccountId`. + * + * Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). + * Swap both for a shared secret binding before this is used for anything real. + */ +const DEV_SECRET = 'dev-insecure-signing-key-change-me' + +function base64urlToBytes(input: string): Uint8Array { + const padded = input.replace(/-/g, '+').replace(/_/g, '/') + const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4)) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return bytes +} + +/** + * Validate an HS256 token and return its `sub` (account id) claim, or `null` + * when the token is malformed, has a bad signature, or is expired. + */ +export async function validateAndGetAccountId( + token: string, + secret: string = DEV_SECRET +): Promise { + const parts = token.split('.') + if (parts.length !== 3) return null + const [header, payload, signature] = parts + + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'] + ) + const valid = await crypto.subtle.verify( + 'HMAC', + key, + base64urlToBytes(signature), + new TextEncoder().encode(`${header}.${payload}`) + ) + if (!valid) return null + + let claims: { sub?: string; exp?: number } + try { + claims = JSON.parse(new TextDecoder().decode(base64urlToBytes(payload))) + } catch { + return null + } + + if (typeof claims.exp === 'number' && claims.exp < Math.floor(Date.now() / 1000)) { + return null + } + + return claims.sub ?? null +} diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 98d694d..3f09942 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -5,6 +5,32 @@ import '../../econ.app' const ORIGIN = 'https://econ.rec.djdevin.net' +// Mint a token the way the `auth` worker does, using the same dev secret. +const DEV_SECRET = 'dev-insecure-signing-key-change-me' + +function b64url(input: ArrayBuffer | string): string { + const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +async function bearer(sub = '42'): Promise> { + const now = Math.floor(Date.now() / 1000) + const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url( + JSON.stringify({ sub, exp: now + 3600 }) + )}` + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(DEV_SECRET), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ) + const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput)) + return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` } +} + describe('econ endpoints', () => { test('GET /api/avatar/v1/defaultunlocked returns the default avatar items', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v1/defaultunlocked`) @@ -15,6 +41,39 @@ describe('econ endpoints', () => { expect(body[0]).toHaveProperty('AvatarItemDesc') }) + test('GET /api/avatar/v4/items 401s without a token', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`) + expect(res.status).toBe(401) + }) + + test('GET /api/avatar/v4/items returns the item catalog with a valid token', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, { + headers: await bearer(), + }) + 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(body[0]).toHaveProperty('FriendlyName') + }) + + test('GET /api/avatar/v2 401s without a token', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2`) + expect(res.status).toBe(401) + }) + + test('GET /api/avatar/v2 returns the default avatar with a valid token', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2`, { headers: await bearer() }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + OutfitSelections: '', + FaceFeatures: '{}', + SkinColor: '', + HairColor: '', + }) + }) + test('unknown path returns 404', async () => { const res = await exports.default.fetch(`${ORIGIN}/nope`) expect(res.status).toBe(404)