diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index 42d51e6..050400e 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -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, account: Account): Promise { + 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() .use( '*', @@ -108,16 +148,7 @@ const app = new Hono() 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() 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() 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() username, availableUsernameChanges: remaining - 1, }) + await pushAccountUpdate(c, updated) return usernameResult(c, '', toAccountDto(updated)) }) @@ -261,7 +298,8 @@ const app = new Hono() 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() 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 }) }) diff --git a/apps/accounts/src/context.ts b/apps/accounts/src/context.ts index b5f78d8..2cb8e33 100644 --- a/apps/accounts/src/context.ts +++ b/apps/accounts/src/context.ts @@ -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 } /** Variables can be extended */ diff --git a/apps/accounts/vitest.config.ts b/apps/accounts/vitest.config.ts index de0d903..b549955 100644 --- a/apps/accounts/vitest.config.ts +++ b/apps/accounts/vitest.config.ts @@ -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') } } + `, + }, + ], }, }), ], diff --git a/apps/accounts/wrangler.jsonc b/apps/accounts/wrangler.jsonc index 5648e93..7da7983 100644 --- a/apps/accounts/wrangler.jsonc +++ b/apps/accounts/wrangler.jsonc @@ -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": { diff --git a/apps/api/src/api.app.ts b/apps/api/src/api.app.ts index 5f1d22c..30117ad 100644 --- a/apps/api/src/api.app.ts +++ b/apps/api/src/api.app.ts @@ -197,6 +197,7 @@ const app = new Hono({ 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({ 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 diff --git a/apps/api/src/images-db.ts b/apps/api/src/images-db.ts index 40d470f..7607c8d 100644 --- a/apps/api/src/images-db.ts +++ b/apps/api/src/images-db.ts @@ -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 { @@ -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) } diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 47c19a4..145cfc0 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -205,12 +205,11 @@ const app = new Hono() 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 diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 8b77f97..72074ee 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -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 () => { diff --git a/apps/clubs/src/clubs.app.ts b/apps/clubs/src/clubs.app.ts index 49a6f6a..ddff3b6 100644 --- a/apps/clubs/src/clubs.app.ts +++ b/apps/clubs/src/clubs.app.ts @@ -80,4 +80,12 @@ const app = new Hono() // 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 diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 14a1170..8fbd64d 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -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) { 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() 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([])) diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index fa6212a..b35d5fd 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -456,4 +456,11 @@ const app = new Hono() // ---- 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 diff --git a/apps/notify/src/notifications-hub.ts b/apps/notify/src/notifications-hub.ts index 531b11f..2f37b2e 100644 --- a/apps/notify/src/notifications-hub.ts +++ b/apps/notify/src/notifications-hub.ts @@ -223,7 +223,7 @@ export class NotificationsHub extends DurableObject { */ async notifyPlayer( playerId: number, - notificationType: number, + notificationType: string | number, data?: Record ): Promise<{ delivered: number; queued: boolean }> { const payload = this.buildNotificationPayload(notificationType, data) @@ -257,7 +257,7 @@ export class NotificationsHub extends DurableObject { /** Broadcast a notification to every connected (handshaken) client. */ async broadcast( - notificationType: number, + notificationType: string | number, data?: Record ): Promise<{ delivered: number }> { const payload = this.buildNotificationPayload(notificationType, data) @@ -275,10 +275,11 @@ export class NotificationsHub extends DurableObject { /** * 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 { const msg: Record = {} @@ -288,7 +289,7 @@ export class NotificationsHub extends DurableObject { 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 { diff --git a/apps/notify/src/notify.app.ts b/apps/notify/src/notify.app.ts index 87becb2..2bc163a 100644 --- a/apps/notify/src/notify.app.ts +++ b/apps/notify/src/notify.app.ts @@ -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() .use( '*', @@ -48,7 +56,7 @@ const app = new Hono() }) // 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() // TODO: protect these before production. .post('/internal/notify', async (c) => { const body = await c.req - .json<{ playerId?: number; notificationType?: number; data?: Record }>() + .json<{ + playerId?: number + notificationType?: string | number + data?: Record + }>() .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() .post('/internal/broadcast', async (c) => { const body = await c.req - .json<{ notificationType?: number; data?: Record }>() + .json<{ notificationType?: string | number; data?: Record }>() .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( diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 5423c3d..28b6f4b 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -424,6 +424,9 @@ const app = new Hono() ) }) + // 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) => {