mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
more endpoints
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
|
||||
import {
|
||||
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>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -108,16 +148,7 @@ const app = new Hono<App>()
|
||||
if (id === null) return unauthorized(c)
|
||||
// Load the stored account, falling back to a synthesized default.
|
||||
const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id)
|
||||
// `juniorState` (an enum) and `parentAccountId` are OMITTED when null —
|
||||
// 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,
|
||||
})
|
||||
return c.json(toSelfAccountDto(account))
|
||||
})
|
||||
|
||||
// ---- Bulk / single lookup ------------------------------------------------
|
||||
@@ -176,6 +207,10 @@ const app = new Hono<App>()
|
||||
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 ---------------------------------------------------
|
||||
// Set the player's display name (persisted on the account row).
|
||||
.put('/account/me/displayname', async (c) => {
|
||||
@@ -183,7 +218,8 @@ const app = new Hono<App>()
|
||||
if (id === null) return unauthorized(c)
|
||||
const displayName = (await formField(c, 'displayName')).trim()
|
||||
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 })
|
||||
})
|
||||
|
||||
@@ -214,6 +250,7 @@ const app = new Hono<App>()
|
||||
username,
|
||||
availableUsernameChanges: remaining - 1,
|
||||
})
|
||||
await pushAccountUpdate(c, updated)
|
||||
return usernameResult(c, '', toAccountDto(updated))
|
||||
})
|
||||
|
||||
@@ -261,7 +298,8 @@ const app = new Hono<App>()
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
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 })
|
||||
})
|
||||
|
||||
@@ -270,9 +308,10 @@ const app = new Hono<App>()
|
||||
if (id === null) return unauthorized(c)
|
||||
const imageName = await formField(c, 'imageName')
|
||||
if (!imageName) return c.body(null, 400)
|
||||
// Persist the new avatar key on the account row (the C# also fires an
|
||||
// AccountUpdate websocket — no notify binding here, so it's omitted).
|
||||
await updateAccount(c.env.DB, id, { profileImage: imageName })
|
||||
// Persist the new avatar key on the account row and fire the AccountUpdate
|
||||
// websocket (the new profileImage rides along in the DTO payload).
|
||||
const account = await updateAccount(c.env.DB, id, { profileImage: imageName })
|
||||
await pushAccountUpdate(c, account)
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { HonoApp } from '@repo/hono-helpers'
|
||||
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 & {
|
||||
// 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.
|
||||
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 */
|
||||
|
||||
@@ -9,6 +9,28 @@ export default defineConfig({
|
||||
bindings: {
|
||||
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') } }
|
||||
`,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -15,6 +15,17 @@
|
||||
"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,
|
||||
"upload_source_maps": true,
|
||||
"observability": {
|
||||
|
||||
+12
-1
@@ -197,6 +197,7 @@ const app = new Hono<App>({ strict: false })
|
||||
// ---- Social ---------------------------------------------------------------
|
||||
.get('/api/relationships/v2/get', (c) => c.json([]))
|
||||
.get('/api/messages/v2/get', (c) => c.json([]))
|
||||
.get('/api/messages/v1/favoriteFriendOnlineStatus', (c) => c.json([]))
|
||||
|
||||
// ---- Reputation / progression --------------------------------------------
|
||||
.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 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, 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
|
||||
|
||||
@@ -138,6 +138,7 @@ const newestFirst = (a: SavedImage, b: SavedImage) =>
|
||||
export async function getImagesByPlayer(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
sort: number,
|
||||
skip: number,
|
||||
take: number
|
||||
): Promise<SavedImage[]> {
|
||||
@@ -148,7 +149,7 @@ export async function getImagesByPlayer(
|
||||
return results
|
||||
.map((r) => JSON.parse(r.data) as SavedImage)
|
||||
.filter((img) => img.Accessibility === 1)
|
||||
.sort(newestFirst)
|
||||
.sort(sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst)
|
||||
.slice(skip, skip + take)
|
||||
}
|
||||
|
||||
|
||||
@@ -205,12 +205,11 @@ const app = new Hono<App>()
|
||||
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) => {
|
||||
const { id } = c.req.param()
|
||||
logger.info('developer role lookup', { id })
|
||||
// TODO: implement
|
||||
return c.json({ success: true })
|
||||
return c.json({ success: false })
|
||||
})
|
||||
|
||||
export default app
|
||||
|
||||
@@ -212,10 +212,10 @@ describe('auth worker routes', () => {
|
||||
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`)
|
||||
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 () => {
|
||||
|
||||
@@ -80,4 +80,12 @@ const app = new Hono<App>()
|
||||
// The clubs the player is a member of (GetMyMembershipClubs). No DB → empty.
|
||||
.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
|
||||
|
||||
@@ -11,6 +11,7 @@ import weeklyChallenge from '../static/weekly-challenge.json'
|
||||
import { getAvatar, setAvatar } from './avatar-db'
|
||||
import { validateAndGetAccountId } from './jwt'
|
||||
|
||||
import type { Avatar } from './avatar-db'
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from './context'
|
||||
|
||||
@@ -43,6 +44,21 @@ function unauthorized(c: Context<App>) {
|
||||
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). */
|
||||
const CurrencyType = {
|
||||
Invalid: 0,
|
||||
@@ -153,6 +169,16 @@ const app = new Hono<App>()
|
||||
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.
|
||||
.get('/api/equipment/v2/getUnlocked', (c) => c.json([]))
|
||||
|
||||
|
||||
@@ -456,4 +456,11 @@ const app = new Hono<App>()
|
||||
// ---- Room instance -------------------------------------------------------
|
||||
.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
|
||||
|
||||
@@ -223,7 +223,7 @@ export class NotificationsHub extends DurableObject<Env> {
|
||||
*/
|
||||
async notifyPlayer(
|
||||
playerId: number,
|
||||
notificationType: number,
|
||||
notificationType: string | number,
|
||||
data?: Record<string, unknown>
|
||||
): Promise<{ delivered: number; queued: boolean }> {
|
||||
const payload = this.buildNotificationPayload(notificationType, data)
|
||||
@@ -257,7 +257,7 @@ export class NotificationsHub extends DurableObject<Env> {
|
||||
|
||||
/** Broadcast a notification to every connected (handshaken) client. */
|
||||
async broadcast(
|
||||
notificationType: number,
|
||||
notificationType: string | number,
|
||||
data?: Record<string, unknown>
|
||||
): Promise<{ delivered: number }> {
|
||||
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 }`
|
||||
* (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(
|
||||
notificationType: number,
|
||||
notificationType: string | number,
|
||||
data?: Record<string, unknown>
|
||||
): string {
|
||||
const msg: Record<string, unknown> = {}
|
||||
@@ -288,7 +289,7 @@ export class NotificationsHub extends DurableObject<Env> {
|
||||
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 {
|
||||
|
||||
@@ -18,6 +18,14 @@ import type { App } from './context'
|
||||
/** The hub state is global → one DO instance. */
|
||||
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>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -48,7 +56,7 @@ const app = new Hono<App>()
|
||||
})
|
||||
|
||||
// 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') {
|
||||
return c.json({ error: 'Expected a WebSocket upgrade request' }, 426)
|
||||
}
|
||||
@@ -60,9 +68,13 @@ const app = new Hono<App>()
|
||||
// TODO: protect these before production.
|
||||
.post('/internal/notify', async (c) => {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
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) => {
|
||||
const body = await c.req
|
||||
.json<{ notificationType?: number; data?: Record<string, unknown> }>()
|
||||
.json<{ notificationType?: string | number; data?: Record<string, unknown> }>()
|
||||
.catch(() => null)
|
||||
if (!body || typeof body.notificationType !== 'number') {
|
||||
if (!body || !isNotificationType(body.notificationType)) {
|
||||
return c.json({ error: 'notificationType is required' }, 400)
|
||||
}
|
||||
const result = await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).broadcast(
|
||||
|
||||
@@ -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
|
||||
// include/unityAsset* query params.
|
||||
.get('/rooms/:roomId{[0-9]+}', async (c) => {
|
||||
|
||||
Reference in New Issue
Block a user