more endpoints

This commit is contained in:
Devin Zuczek
2026-07-05 19:23:06 -04:00
parent 8f61adff1b
commit 7cf380a401
14 changed files with 179 additions and 33 deletions
+55 -16
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { useWorkersLogger } from 'workers-tagged-logger' import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers' import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
import { import {
createAccount, createAccount,
@@ -85,6 +85,46 @@ function toAccountDto(account: Account) {
} }
} }
/**
* Project a stored account into the private self DTO (the /account/me shape) —
* the public DTO plus owner-only fields. `juniorState`/`parentAccountId` are
* OMITTED when null (emitting `null` makes the client's enum parser throw);
* `email`/`birthday` are kept as null (not enums, so null is fine).
*/
function toSelfAccountDto(account: Account) {
return {
...toAccountDto(account),
email: account.email ?? null,
birthday: null,
availableUsernameChanges: account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES,
}
}
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
/**
* Push the notifications that follow an account mutation, mirroring the C#/Go
* hub behavior: the owner receives `SelfAccountUpdate` and `AccountUpdate`, and
* every connected client receives an `AccountUpdate` broadcast. Hub failures are
* logged and swallowed — the account write has already committed, so a hub
* hiccup must not fail the request.
*/
async function pushAccountUpdate(c: Context<App>, account: Account): Promise<void> {
try {
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
const publicDto = toAccountDto(account)
await hub.notifyPlayer(account.accountId, 'SelfAccountUpdate', toSelfAccountDto(account))
await hub.notifyPlayer(account.accountId, 'AccountUpdate', publicDto)
await hub.broadcast('AccountUpdate', publicDto)
} catch (err) {
logger.error('failed to push account update notifications', {
accountId: account.accountId,
error: err instanceof Error ? err.message : String(err),
})
}
}
const app = new Hono<App>() const app = new Hono<App>()
.use( .use(
'*', '*',
@@ -108,16 +148,7 @@ const app = new Hono<App>()
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
// Load the stored account, falling back to a synthesized default. // Load the stored account, falling back to a synthesized default.
const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id) const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id)
// `juniorState` (an enum) and `parentAccountId` are OMITTED when null — return c.json(toSelfAccountDto(account))
// emitting `"juniorState":null` makes the client's enum parser throw
// ("Can't parse JSON to Enum format"). `email`/`birthday` are kept as null
// (they aren't enums, so null is fine).
return c.json({
...toAccountDto(account),
email: account.email ?? null,
birthday: null,
availableUsernameChanges: account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES,
})
}) })
// ---- Bulk / single lookup ------------------------------------------------ // ---- Bulk / single lookup ------------------------------------------------
@@ -176,6 +207,10 @@ const app = new Hono<App>()
return c.json({ accountId: id, disallowInAppPurchases: false }) return c.json({ accountId: id, disallowInAppPurchases: false })
}) })
// Privacy settings for an account. The client deserializes this into an
// object, so it must return `{}` (not `[]`).
.get('/accountprivacysettings/:id', (c) => c.json({}))
// ---- Profile mutations --------------------------------------------------- // ---- Profile mutations ---------------------------------------------------
// Set the player's display name (persisted on the account row). // Set the player's display name (persisted on the account row).
.put('/account/me/displayname', async (c) => { .put('/account/me/displayname', async (c) => {
@@ -183,7 +218,8 @@ const app = new Hono<App>()
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
const displayName = (await formField(c, 'displayName')).trim() const displayName = (await formField(c, 'displayName')).trim()
if (displayName === '') return c.body(null, 400) if (displayName === '') return c.body(null, 400)
await updateAccount(c.env.DB, id, { displayName }) const account = await updateAccount(c.env.DB, id, { displayName })
await pushAccountUpdate(c, account)
return c.json({ success: true }) return c.json({ success: true })
}) })
@@ -214,6 +250,7 @@ const app = new Hono<App>()
username, username,
availableUsernameChanges: remaining - 1, availableUsernameChanges: remaining - 1,
}) })
await pushAccountUpdate(c, updated)
return usernameResult(c, '', toAccountDto(updated)) return usernameResult(c, '', toAccountDto(updated))
}) })
@@ -261,7 +298,8 @@ const app = new Hono<App>()
const id = await authedId(c) const id = await authedId(c)
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
const bio = await formField(c, 'bio') const bio = await formField(c, 'bio')
await updateAccount(c.env.DB, id, { bio }) const account = await updateAccount(c.env.DB, id, { bio })
await pushAccountUpdate(c, account)
return c.json({ success: true }) return c.json({ success: true })
}) })
@@ -270,9 +308,10 @@ const app = new Hono<App>()
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
const imageName = await formField(c, 'imageName') const imageName = await formField(c, 'imageName')
if (!imageName) return c.body(null, 400) if (!imageName) return c.body(null, 400)
// Persist the new avatar key on the account row (the C# also fires an // Persist the new avatar key on the account row and fire the AccountUpdate
// AccountUpdate websocket — no notify binding here, so it's omitted). // websocket (the new profileImage rides along in the DTO payload).
await updateAccount(c.env.DB, id, { profileImage: imageName }) const account = await updateAccount(c.env.DB, id, { profileImage: imageName })
await pushAccountUpdate(c, account)
return c.json({ success: true }) return c.json({ success: true })
}) })
+6
View File
@@ -1,10 +1,16 @@
import type { HonoApp } from '@repo/hono-helpers' import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
// Type-only import (erased at build) of the DO class owned by the `notify`
// worker, so the cross-worker RPC stub is fully typed.
import type { NotificationsHub } from '../../notify/src/notifications-hub'
export type Env = SharedHonoEnv & { export type Env = SharedHonoEnv & {
// Shared rooms/accounts D1 database (schema owned by the `auth` worker). Used // Shared rooms/accounts D1 database (schema owned by the `auth` worker). Used
// to look up accounts in bulk/by id and to create new accounts. // to look up accounts in bulk/by id and to create new accounts.
DB: D1Database DB: D1Database
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
// push AccountUpdate/SelfAccountUpdate notifications on profile mutations.
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
} }
/** Variables can be extended */ /** Variables can be extended */
+22
View File
@@ -9,6 +9,28 @@ export default defineConfig({
bindings: { bindings: {
ENVIRONMENT: 'VITEST', ENVIRONMENT: 'VITEST',
}, },
// The worker's RECFLARE_NOTIFICATIONS_HUB binding points at the `notify`
// worker's DO (script_name: "notify"). That worker isn't part of this
// isolated test, so provide a minimal stub service exposing the same
// NotificationsHub RPC surface — enough for the runtime to start and for
// notification sends to no-op.
workers: [
{
name: 'notify',
modules: true,
compatibilityDate: '2025-09-20',
compatibilityFlags: ['nodejs_compat'],
durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' },
script: `
import { DurableObject } from 'cloudflare:workers'
export class NotificationsHub extends DurableObject {
async notifyPlayer() { return { delivered: 0, queued: true } }
async broadcast() { return { delivered: 0 } }
}
export default { fetch() { return new Response('ok') } }
`,
},
],
}, },
}), }),
], ],
+11
View File
@@ -15,6 +15,17 @@
"database_id": "local" "database_id": "local"
} }
], ],
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
// the `notify` worker). We only invoke its RPC methods; no migration here.
"durable_objects": {
"bindings": [
{
"name": "RECFLARE_NOTIFICATIONS_HUB",
"class_name": "NotificationsHub",
"script_name": "notify"
}
]
},
"logpush": false, "logpush": false,
"upload_source_maps": true, "upload_source_maps": true,
"observability": { "observability": {
+12 -1
View File
@@ -197,6 +197,7 @@ const app = new Hono<App>({ strict: false })
// ---- Social --------------------------------------------------------------- // ---- Social ---------------------------------------------------------------
.get('/api/relationships/v2/get', (c) => c.json([])) .get('/api/relationships/v2/get', (c) => c.json([]))
.get('/api/messages/v2/get', (c) => c.json([])) .get('/api/messages/v2/get', (c) => c.json([]))
.get('/api/messages/v1/favoriteFriendOnlineStatus', (c) => c.json([]))
// ---- Reputation / progression -------------------------------------------- // ---- Reputation / progression --------------------------------------------
.get('/api/playerReputation/v1/:id', (c) => .get('/api/playerReputation/v1/:id', (c) =>
@@ -506,7 +507,17 @@ const app = new Hono<App>({ strict: false })
const playerId = Number.parseInt(c.req.param('playerId'), 10) const playerId = Number.parseInt(c.req.param('playerId'), 10)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getImagesByPlayer(c.env.DB, playerId, skip, take)) return c.json(await getImagesByPlayer(c.env.DB, playerId, 0, skip, take))
})
// A player's photos with a sort option. `sort` orders the list (1 = most
// cheered, else newest). Paginated via skip/take (take defaults to 100). Bare array.
.get('/api/images/v5/player/:playerId{[0-9]+}', async (c) => {
const playerId = Number.parseInt(c.req.param('playerId'), 10)
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getImagesByPlayer(c.env.DB, playerId, sort, skip, take))
}) })
// A player's photo feed — the public images they took plus ones they're tagged // A player's photo feed — the public images they took plus ones they're tagged
+2 -1
View File
@@ -138,6 +138,7 @@ const newestFirst = (a: SavedImage, b: SavedImage) =>
export async function getImagesByPlayer( export async function getImagesByPlayer(
db: D1Database, db: D1Database,
playerId: number, playerId: number,
sort: number,
skip: number, skip: number,
take: number take: number
): Promise<SavedImage[]> { ): Promise<SavedImage[]> {
@@ -148,7 +149,7 @@ export async function getImagesByPlayer(
return results return results
.map((r) => JSON.parse(r.data) as SavedImage) .map((r) => JSON.parse(r.data) as SavedImage)
.filter((img) => img.Accessibility === 1) .filter((img) => img.Accessibility === 1)
.sort(newestFirst) .sort(sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst)
.slice(skip, skip + take) .slice(skip, skip + take)
} }
+2 -3
View File
@@ -205,12 +205,11 @@ const app = new Hono<App>()
return c.json({ success: true }) return c.json({ success: true })
}) })
// Developer role lookup. Not implemented yet. // Developer role lookup. No developer role granted by default.
.get('/role/developer/:id', (c) => { .get('/role/developer/:id', (c) => {
const { id } = c.req.param() const { id } = c.req.param()
logger.info('developer role lookup', { id }) logger.info('developer role lookup', { id })
// TODO: implement return c.json({ success: false })
return c.json({ success: true })
}) })
export default app export default app
+2 -2
View File
@@ -212,10 +212,10 @@ describe('auth worker routes', () => {
expect(await rotate.json()).toEqual({ success: true }) expect(await rotate.json()).toEqual({ success: true })
}) })
test('GET /role/developer/:id returns ok', async () => { test('GET /role/developer/:id does not grant developer', async () => {
const res = await exports.default.fetch(`${ORIGIN}/role/developer/42`) const res = await exports.default.fetch(`${ORIGIN}/role/developer/42`)
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true }) expect(await res.json()).toEqual({ success: false })
}) })
test('unknown path returns 404', async () => { test('unknown path returns 404', async () => {
+8
View File
@@ -80,4 +80,12 @@ const app = new Hono<App>()
// The clubs the player is a member of (GetMyMembershipClubs). No DB → empty. // The clubs the player is a member of (GetMyMembershipClubs). No DB → empty.
.get('/club/mine/member', (c) => c.json([])) .get('/club/mine/member', (c) => c.json([]))
// The clubs the player created (GetMyCreatedClubs). No DB → empty.
.get('/club/mine/created', (c) => c.json([]))
// The set of club category tags a club can be filed under — a fixed list.
.get('/club/categoryTags', (c) =>
c.json(['Social', 'Creative', 'Competitive', 'Casual', 'Entertainment'])
)
export default app export default app
+26
View File
@@ -11,6 +11,7 @@ import weeklyChallenge from '../static/weekly-challenge.json'
import { getAvatar, setAvatar } from './avatar-db' import { getAvatar, setAvatar } from './avatar-db'
import { validateAndGetAccountId } from './jwt' import { validateAndGetAccountId } from './jwt'
import type { Avatar } from './avatar-db'
import type { Context } from 'hono' import type { Context } from 'hono'
import type { App } from './context' import type { App } from './context'
@@ -43,6 +44,21 @@ function unauthorized(c: Context<App>) {
return c.body(null, 401) return c.body(null, 401)
} }
/**
* Project a stored avatar into the public render subset returned by
* `GET /api/avatar/v2/:id` — the fields needed to draw another player's avatar
* (the full blob also holds `OutfitSelectionsV2`/`CustomAvatarItems`, which this
* view omits).
*/
function toAvatarV2Dto(avatar: Avatar) {
return {
OutfitSelections: avatar.OutfitSelections,
FaceFeatures: avatar.FaceFeatures,
SkinColor: avatar.SkinColor,
HairColor: avatar.HairColor,
}
}
/** RecNet currency types (the `CurrencyType` enum the client uses). */ /** RecNet currency types (the `CurrencyType` enum the client uses). */
const CurrencyType = { const CurrencyType = {
Invalid: 0, Invalid: 0,
@@ -153,6 +169,16 @@ const app = new Hono<App>()
return c.json([]) return c.json([])
}) })
// A player's avatar by account id, projected to the public render subset (used
// to draw other players' avatars). No auth — like the accounts `/account/:id`
// lookup. Falls back to the default outfit when the player hasn't saved one.
// Registered after the static `/api/avatar/v2/*` routes so `:id` can't shadow them.
.get('/api/avatar/v2/:id', async (c) => {
const accountId = Number.parseInt(c.req.param('id'), 10)
if (Number.isNaN(accountId)) return c.body(null, 400)
return c.json(toAvatarV2Dto((await getAvatar(c.env.DB, accountId)) ?? defaultAvatar))
})
// Unlocked equipment. Returns "[]" with no auth. // Unlocked equipment. Returns "[]" with no auth.
.get('/api/equipment/v2/getUnlocked', (c) => c.json([])) .get('/api/equipment/v2/getUnlocked', (c) => c.json([]))
+7
View File
@@ -456,4 +456,11 @@ const app = new Hono<App>()
// ---- Room instance ------------------------------------------------------- // ---- Room instance -------------------------------------------------------
.post('/roominstance/:id/reportjoinresult', (c) => c.body(null, 200)) .post('/roominstance/:id/reportjoinresult', (c) => c.body(null, 200))
// Rooms flagged as needing a developer/moderator to spawn in. No such queue
// yet → empty list.
.get('/rooms/requiring/developer', (c) => c.json([]))
// Rooms flagged as requiring an RR+ subscription. No such queue yet → empty list.
.get('/rooms/requiring/rrplus', (c) => c.json([]))
export default app export default app
+6 -5
View File
@@ -223,7 +223,7 @@ export class NotificationsHub extends DurableObject<Env> {
*/ */
async notifyPlayer( async notifyPlayer(
playerId: number, playerId: number,
notificationType: number, notificationType: string | number,
data?: Record<string, unknown> data?: Record<string, unknown>
): Promise<{ delivered: number; queued: boolean }> { ): Promise<{ delivered: number; queued: boolean }> {
const payload = this.buildNotificationPayload(notificationType, data) const payload = this.buildNotificationPayload(notificationType, data)
@@ -257,7 +257,7 @@ export class NotificationsHub extends DurableObject<Env> {
/** Broadcast a notification to every connected (handshaken) client. */ /** Broadcast a notification to every connected (handshaken) client. */
async broadcast( async broadcast(
notificationType: number, notificationType: string | number,
data?: Record<string, unknown> data?: Record<string, unknown>
): Promise<{ delivered: number }> { ): Promise<{ delivered: number }> {
const payload = this.buildNotificationPayload(notificationType, data) const payload = this.buildNotificationPayload(notificationType, data)
@@ -275,10 +275,11 @@ export class NotificationsHub extends DurableObject<Env> {
/** /**
* Build the `Notification` argument: a JSON string `{ Id, Msg }` * Build the `Notification` argument: a JSON string `{ Id, Msg }`
* (null values are dropped from `Msg`). * (null values are dropped from `Msg`). `Id` is a client-defined tag — a
* string name (e.g. "AccountUpdate") or a numeric code.
*/ */
private buildNotificationPayload( private buildNotificationPayload(
notificationType: number, notificationType: string | number,
data?: Record<string, unknown> data?: Record<string, unknown>
): string { ): string {
const msg: Record<string, unknown> = {} const msg: Record<string, unknown> = {}
@@ -288,7 +289,7 @@ export class NotificationsHub extends DurableObject<Env> {
msg[key] = value msg[key] = value
} }
} }
return JSON.stringify({ Id: notificationType ?? 0, Msg: msg }) return JSON.stringify({ Id: notificationType ?? '', Msg: msg })
} }
private invocation(target: string, args: unknown[]): string { private invocation(target: string, args: unknown[]): string {
+17 -5
View File
@@ -18,6 +18,14 @@ import type { App } from './context'
/** The hub state is global → one DO instance. */ /** The hub state is global → one DO instance. */
const HUB_INSTANCE = 'global' const HUB_INSTANCE = 'global'
/**
* A valid notification `Id` — a client-defined string tag (e.g. "AccountUpdate")
* or a numeric code. An empty string is treated as missing.
*/
function isNotificationType(value: unknown): value is string | number {
return (typeof value === 'string' && value !== '') || typeof value === 'number'
}
const app = new Hono<App>() const app = new Hono<App>()
.use( .use(
'*', '*',
@@ -48,7 +56,7 @@ const app = new Hono<App>()
}) })
// The hub WebSocket. Upgrade requests are forwarded to the Durable Object. // The hub WebSocket. Upgrade requests are forwarded to the Durable Object.
.get('/hub/v1', (c) => { .get('/hub/v1', async (c) => {
if ((c.req.header('upgrade') ?? '').toLowerCase() !== 'websocket') { if ((c.req.header('upgrade') ?? '').toLowerCase() !== 'websocket') {
return c.json({ error: 'Expected a WebSocket upgrade request' }, 426) return c.json({ error: 'Expected a WebSocket upgrade request' }, 426)
} }
@@ -60,9 +68,13 @@ const app = new Hono<App>()
// TODO: protect these before production. // TODO: protect these before production.
.post('/internal/notify', async (c) => { .post('/internal/notify', async (c) => {
const body = await c.req const body = await c.req
.json<{ playerId?: number; notificationType?: number; data?: Record<string, unknown> }>() .json<{
playerId?: number
notificationType?: string | number
data?: Record<string, unknown>
}>()
.catch(() => null) .catch(() => null)
if (!body || typeof body.playerId !== 'number' || typeof body.notificationType !== 'number') { if (!body || typeof body.playerId !== 'number' || !isNotificationType(body.notificationType)) {
return c.json({ error: 'playerId and notificationType are required' }, 400) return c.json({ error: 'playerId and notificationType are required' }, 400)
} }
const result = await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( const result = await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
@@ -75,9 +87,9 @@ const app = new Hono<App>()
.post('/internal/broadcast', async (c) => { .post('/internal/broadcast', async (c) => {
const body = await c.req const body = await c.req
.json<{ notificationType?: number; data?: Record<string, unknown> }>() .json<{ notificationType?: string | number; data?: Record<string, unknown> }>()
.catch(() => null) .catch(() => null)
if (!body || typeof body.notificationType !== 'number') { if (!body || !isNotificationType(body.notificationType)) {
return c.json({ error: 'notificationType is required' }, 400) return c.json({ error: 'notificationType is required' }, 400)
} }
const result = await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).broadcast( const result = await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).broadcast(
+3
View File
@@ -424,6 +424,9 @@ const app = new Hono<App>()
) )
}) })
// The caller's per-room player data. Stub → empty blob (client reads `Data`).
.get('/rooms/:roomId{[0-9]+}/playerdata/me', (c) => c.json({ Data: '' }))
// Single room by id. 404 when the room isn't in D1. Ignores the // Single room by id. 404 when the room isn't in D1. Ignores the
// include/unityAsset* query params. // include/unityAsset* query params.
.get('/rooms/:roomId{[0-9]+}', async (c) => { .get('/rooms/:roomId{[0-9]+}', async (c) => {