diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts index 0cd0890..4895362 100644 --- a/apps/api/src/routes/moderation.ts +++ b/apps/api/src/routes/moderation.ts @@ -1,5 +1,9 @@ import { Hono } from 'hono' +import { setDeviceId } from '@repo/domain' + +import { authedId, unauthorized } from '../http' + import type { App } from '../context' // ---- Player reporting ------------------------------------------------------ @@ -23,3 +27,21 @@ export const moderationRoutes = new Hono({ strict: false }) ) .get('/api/PlayerReporting/v1/voteToKickReasons', (c) => c.json([])) // TODO: hydrate from JSON/vtkreasons.json .post('/api/PlayerReporting/v1/hile', (c) => c.json(false)) + + // The client reporting its device id (form-encoded `oldDeviceId`, `newDeviceId`, + // `platform`), rotating from the id it thinks we hold to the current one. We don't + // reconcile the two: the client is the only source for either, so a mismatch tells + // us nothing and last write wins. `platform` is ignored — the account already + // records the platform its login is linked to. Auth-gated; the client ignores the + // response body, and the real service answers with an empty array. + .post('/api/PlayerReporting/v1/deviceId', async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const body = await c.req.parseBody().catch(() => ({}) as Record) + const newDeviceId = body.newDeviceId + if (typeof newDeviceId !== 'string' || newDeviceId === '') { + return c.json({ error: 'newDeviceId is required' }, 400) + } + await setDeviceId(c.env.DB, id, newDeviceId) + return c.json([]) + }) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 8fb83cb..ab6589f 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -222,6 +222,37 @@ describe('public endpoints', () => { }) }) + test('POST /api/PlayerReporting/v1/deviceId stores the new device id', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/deviceId`, { + method: 'POST', + headers: { + ...(await bearer()), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + oldDeviceId: '491e8b9', + newDeviceId: '491e8b9566cb1b593367c72860e978b3d5765326', + platform: '0', + }), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + + const row = await env.DB.prepare( + "SELECT json_extract(data, '$.deviceId') AS deviceId FROM account WHERE account_id = 42" + ).first<{ deviceId: string | null }>() + expect(row?.deviceId).toBe('491e8b9566cb1b593367c72860e978b3d5765326') + }) + + test('POST /api/PlayerReporting/v1/deviceId 401s without a bearer token', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/deviceId`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ newDeviceId: 'abc' }), + }) + expect(res.status).toBe(401) + }) + test('POST /api/playerReputation/v2/bulk returns a reputation per id', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/playerReputation/v2/bulk`, { method: 'POST', diff --git a/packages/domain/src/accounts-db.ts b/packages/domain/src/accounts-db.ts index c65310c..a41d778 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -55,6 +55,12 @@ export interface Account { phone?: string /** Set via PUT /account/me/bio; read back via GET /account/:id/bio. */ bio?: string + /** + * Hardware/install id the client last reported (POST /api/PlayerReporting/v1/deviceId). + * The client rotates it — it posts the id it believes we hold plus the new one — + * so this is simply the most recent value it told us about, not a proven identity. + */ + deviceId?: string /** Remaining username changes; decremented by PUT /account/me/username. */ availableUsernameChanges?: number /** @@ -211,6 +217,15 @@ export async function setLastLoginTime(db: D1Database, id: number, time: string) .run() } +/** Record the device id the client last reported. False when no such account exists. */ +export async function setDeviceId(db: D1Database, id: number, deviceId: string): Promise { + const { meta } = await db + .prepare("UPDATE account SET data = json_set(data, '$.deviceId', ?2) WHERE account_id = ?1") + .bind(id, deviceId) + .run() + return meta.changes > 0 +} + /** Look up multiple accounts by AccountId (order not guaranteed). */ export async function getAccountsByIds(db: D1Database, ids: number[]): Promise { if (ids.length === 0) return []