import { Hono } from 'hono' import { describeRoute, openAPIRouteHandler, validator } from 'hono-openapi' import { useWorkersLogger } from 'workers-tagged-logger' import { createAccount, defaultAccount, getAccount, getAccountByUsername, getAccountsByIds, searchAccounts, updateAccount, } from '@repo/domain' import { logger, withCleanSpec, withDefaultCors, withNotFound, withOnError, } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' // The notification-type ids the hub carries (owned by the `notify` worker). Imported as a // value — the enum has no runtime dependencies. import { NotificationType } from '../../notify/src/notification-types' import { AccountDto, BannerImageRequest, BioRequest, BioResponse, CreateAccountRequest, CreateAccountResult, DisplayNameRequest, EmailRequest, form, HealthResponse, IdentityFlagsRequest, json, ParentalControl, PhoneRequest, PrivacySettings, ProfileImageRequest, PronounsRequest, SelfAccountDto, SuccessResponse, UsernameRequest, UsernameResult, } from './openapi' import type { Context } from 'hono' import type { Account } from '@repo/domain' import type { App } from './context' /** * Account reads/writes are backed by the shared `accounts` table in D1 (schema * owned by the `auth` worker). Accounts not in the table fall back to a * synthesized default (every column has a fallback anyway). Profile mutations * persist to the account row and push an AccountUpdate through the notifications * hub (see `pushAccountUpdate`). * * Auth-gated routes validate the Bearer JWT issued by the `auth` worker. */ /** * Resolve the account id from a Bearer token, mirroring the repeated * auth-header check. 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 { return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) } /** Results.Unauthorized() equivalent — 401 with empty body. */ function unauthorized(c: Context) { return c.body(null, 401) } /** Username changes a fresh account starts with (until one has been consumed). */ const DEFAULT_USERNAME_CHANGES = 1 /** * Username-change result envelope: `{ success, error, value }`, always HTTP 200. * On success `value` is the updated account; on error `error` carries the message * and `value` is an empty string. * * The envelope-at-200 is the reference's (`RecNet`) convention — a refusal is a * successful call that answers "no", and the player-facing sentence rides in `error`. * `POST /account/create` does the same. This was briefly a 400 so a caller could branch * on the status; it isn't, because that's not what the real service does. */ function usernameResult(c: Context, error = '', value: unknown = '') { return c.json({ success: error === '', error, value }) } /** Read a single string field from a form-urlencoded / multipart body. */ async function formField(c: Context, name: string): Promise { const body = await c.req.parseBody().catch(() => ({}) as Record) const value = body[name] return typeof value === 'string' ? value : '' } /** * Project a stored account into the public account DTO — the client's camelCase * shape, excluding private fields like `email` (surfaced only by /account/me). */ function toAccountDto(account: Account) { return { accountId: account.accountId, username: account.username, displayName: account.displayName, profileImage: account.profileImage, // Nothing writes these yet, and rows stored before they existed have neither // key — always emit them as "" rather than letting them go missing. bannerImage: account.bannerImage ?? '', displayEmoji: account.displayEmoji ?? '', isJunior: account.isJunior, platforms: account.platforms, personalPronouns: account.personalPronouns, identityFlags: account.identityFlags, createdAt: account.createdAt, } } /** * 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). * * An unset `email` is `""`, never null — same as `bio`. Two reasons: the client reads * it as a string, and this DTO also rides the `SelfAccountUpdate` hub frame, where the * hub DROPS null values from `Msg` — so a null email doesn't arrive as null, it * vanishes from the frame entirely. */ function toSelfAccountDto(account: Account) { return { ...toAccountDto(account), email: account.email ?? '', // @todo he game client needs this to be set. I forget how birthdays were set, so for now // everyone can be old. birthday: '1904-01-01T00:00:00.000Z', 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 reference * 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, NotificationType.SubscriptionUpdateSelfProfile, toSelfAccountDto(account) ) await hub.notifyPlayer(account.accountId, NotificationType.SubscriptionUpdateProfile, publicDto) await hub.broadcast(NotificationType.SubscriptionUpdateProfile, publicDto) } catch (err) { logger.error('failed to push account update notifications', { accountId: account.accountId, error: err instanceof Error ? err.message : String(err), }) } } /** The empty-body 401 every auth-gated route returns; reused across their specs. */ const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' } /** Bearer-JWT security requirement, for the auth-gated routes. */ const AUTHED = [{ bearerAuth: [] }] const app = new Hono() .use( '*', // middleware (c, next) => useWorkersLogger(c.env.NAME, { environment: c.env.ENVIRONMENT, release: c.env.SENTRY_RELEASE, })(c, next) ) // The website (`www`) is a browser origin calling these endpoints directly, the way // rec.net's own site called the game's API — so the responses need CORS headers or // the browser discards them. `origin: '*'` is deliberate and safe HERE because these // endpoints authenticate with a bearer token in the `Authorization` header, never a // cookie: a hostile page can't read another origin's stored token, so there is no // ambient credential for `*` to expose. Do not add cookie auth without narrowing it. .use('*', withDefaultCors()) .onError(withOnError()) .notFound(withNotFound()) // Root health check. .get( '/', describeRoute({ tags: ['Meta'], summary: 'Health check', responses: { 200: json(HealthResponse, 'Service is up') }, }), (c) => c.json({ service: 'accounts', status: 'ok' }) ) // ---- Self account -------------------------------------------------------- .get( '/account/me', describeRoute({ tags: ['Self'], summary: 'The caller’s own account', description: [ 'The private self DTO, including owner-only fields (email, remaining username', 'changes). An account with no stored row falls back to a synthesized default.', ].join(' '), security: AUTHED, responses: { 200: json(SelfAccountDto, 'The caller’s account'), 401: UNAUTHORIZED_RESPONSE, }, }), async (c) => { const id = await authedId(c) 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) return c.json(toSelfAccountDto(account)) } ) // ---- Search -------------------------------------------------------------- // Prefix-search accounts by username (`?name=`). Returns a bare array of public // account DTOs, ordered alphabetically. Registered before `/account/:id` so the // static `search` path wins over the param route. .get( '/account/search', describeRoute({ tags: ['Lookup'], summary: 'Prefix-search accounts by username', description: 'Case-insensitive prefix match on username, ordered alphabetically.', parameters: [ { name: 'name', in: 'query', required: false, description: 'Username prefix; empty matches nothing meaningful', schema: { type: 'string' }, }, ], responses: { 200: json(AccountDto.array(), 'Matching public accounts') }, }), async (c) => { const name = c.req.query('name') ?? '' const accounts = await searchAccounts(c.env.DB, name) return c.json(accounts.map(toAccountDto)) } ) // ---- Bulk / single lookup ------------------------------------------------ // Register the static `bulk` path before the `/account/:id` param route. .get( '/account/bulk', describeRoute({ tags: ['Lookup'], summary: 'Look up many accounts by id', description: [ 'Accepts repeated `id` query params and/or comma-separated lists. Every requested', 'id appears in the response — ids with no stored row get a synthesized default.', ].join(' '), parameters: [ { name: 'id', in: 'query', required: false, description: 'Repeatable; each value may be a comma-separated list of ids', schema: { type: 'array', items: { type: 'string' } }, }, ], responses: { 200: json(AccountDto.array(), 'One public account per requested id') }, }), async (c) => { // Reads repeated `id` query params; also accept a comma-separated list. const ids = c.req .queries('id') ?.flatMap((v) => v.split(',')) .map((s) => Number.parseInt(s.trim(), 10)) .filter((n) => !Number.isNaN(n)) ?? [] // Resolve stored accounts, synthesizing a default for any id not in the DB // so every requested id is present in the response. const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.accountId, a])) return c.json(ids.map((id) => toAccountDto(stored.get(id) ?? defaultAccount(id)))) } ) .get( '/account/:id/bio', describeRoute({ tags: ['Lookup'], summary: 'A player’s bio', parameters: [ { name: 'id', in: 'path', required: true, description: 'Account id; non-numeric is 400', schema: { type: 'string' }, }, ], responses: { 200: json(BioResponse, 'The bio (empty string when unset)'), 400: { description: 'Non-numeric id (empty body)' }, }, }), async (c) => { const accountId = Number.parseInt(c.req.param('id'), 10) if (Number.isNaN(accountId)) return c.body(null, 400) // Bio is stored on the account JSON (set via PUT /account/me/bio). const account = await getAccount(c.env.DB, accountId) return c.json({ accountId, bio: account?.bio ?? '' }) } ) .get( '/account/:id', describeRoute({ tags: ['Lookup'], summary: 'A single public account', description: 'An id with no stored row falls back to a synthesized default account.', parameters: [ { name: 'id', in: 'path', required: true, description: 'Account id; non-numeric is 400', schema: { type: 'string' }, }, ], responses: { 200: json(AccountDto, 'The public account'), 400: { description: 'Non-numeric id (empty body)' }, }, }), async (c) => { const accountId = Number.parseInt(c.req.param('id'), 10) if (Number.isNaN(accountId)) return c.body(null, 400) // Load the stored account, falling back to a synthesized default. return c.json( toAccountDto((await getAccount(c.env.DB, accountId)) ?? defaultAccount(accountId)) ) } ) // ---- Create -------------------------------------------------------------- .post( '/account/create', describeRoute({ tags: ['Self'], summary: 'Create an account', description: [ 'Mints a new account with an auto-assigned random username (players don’t choose', 'one initially). Not auth-gated. `platformId` is parsed but not yet persisted.', ].join(' '), requestBody: form(CreateAccountRequest, 'Platform fields'), responses: { 200: json(CreateAccountResult, 'The created account, in a result envelope') }, }), async (c) => { // Parsed for fidelity; unused until there's a DB to persist CachedLogins. const platform = await formField(c, 'platform') await formField(c, 'platformId') // Persist a new account with an auto-assigned random username (players // don't choose one initially). const platforms = Number.parseInt(platform, 10) const account = await createAccount(c.env.DB, { platforms: Number.isNaN(platforms) ? 0 : platforms, }) // TODO: also create a dorm Room/SubRoom for the new account. return c.json({ success: true, value: toAccountDto(account) }) } ) // ---- Parental control ---------------------------------------------------- .get( '/parentalcontrol/me', describeRoute({ tags: ['Self'], summary: 'The caller’s parental-control flags', description: 'Nothing stores parental controls yet; purchases are always allowed.', security: AUTHED, responses: { 200: json(ParentalControl, 'Parental-control flags'), 401: UNAUTHORIZED_RESPONSE, }, }), async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) return c.json({ accountId: id, disallowInAppPurchases: true }) } ) // Privacy settings for an account. A bare `{}` fails the client's deserializer // ("Deserialization returned null") — it needs the fields, so echo the id back and // report recent history as visible. Nothing stores per-player privacy yet. .get( '/accountprivacysettings/:id{[0-9]+}', describeRoute({ tags: ['Lookup'], summary: 'An account’s privacy settings', description: [ 'Nothing stores per-player privacy yet; the id is echoed and recent history is', 'reported visible (a bare `{}` fails the client’s deserializer).', ].join(' '), parameters: [ { name: 'id', in: 'path', required: true, description: 'Account id (digits only)', schema: { type: 'string', pattern: '^[0-9]+$' }, }, ], responses: { 200: json(PrivacySettings, 'Privacy settings') }, }), (c) => c.json({ accountId: Number.parseInt(c.req.param('id'), 10), isRecentHistoryVisible: true, }) ) // ---- Profile mutations --------------------------------------------------- // Set the player's display name (persisted on the account row). .put( '/account/me/displayname', describeRoute({ tags: ['Profile'], summary: 'Set display name', description: 'Persisted and broadcast via an AccountUpdate notification.', security: AUTHED, responses: { 200: json(SuccessResponse, 'Updated'), 400: { description: 'Empty, over 15 characters, or non-alphanumeric (empty body)' }, 401: UNAUTHORIZED_RESPONSE, }, }), // An EMPTY 400, which is what this route already answered for an empty name: it // acks with a bare SuccessResponse and has never sent the client a body on // failure, so enforcing the schema doesn't change what a refusal looks like. validator('form', DisplayNameRequest, (r, c) => (r.success ? undefined : c.body(null, 400))), async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) const { displayName } = c.req.valid('form') const account = await updateAccount(c.env.DB, id, { displayName }) await pushAccountUpdate(c, account) return c.json({ success: true }) } ) // Change the caller's username. Rejects a name already taken by another account, // and requires the account to have username changes remaining. On success the // new name is persisted and the remaining-changes counter is decremented. .put( '/account/me/username', describeRoute({ tags: ['Profile'], summary: 'Change username', description: [ 'Letters and digits only, at most 50 characters. Rejects a name taken by another', 'account and requires a remaining change; on success the name is persisted and', 'the counter decremented. Always HTTP 200 — failures carry a message in `error`', '(see the UsernameResult envelope).', ].join(' '), security: AUTHED, responses: { 200: json(UsernameResult, 'Result envelope (success or a validation error)'), 401: UNAUTHORIZED_RESPONSE, }, }), // Shape is checked before the handler runs, so a rejected name costs no D1 read and // — the part that matters — can never spend one of the account's rationed changes. // The message is relayed rather than zod's issue array: `nameRejection` writes the // sentence the player reads, and nothing can render an array of issues. // `c` is annotated so the hook's context matches this app's bindings, and `error` is // Standard Schema's flat issue list rather than a zod error object. validator('form', UsernameRequest, (r, c: Context) => r.success ? undefined : usernameResult(c, r.error[0]?.message ?? 'That username cannot be used.') ), async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) const { username } = c.req.valid('form') // Duplicate check first (case-insensitive); keeping your own name is allowed. const existing = await getAccountByUsername(c.env.DB, username) if (existing && existing.accountId !== id) { return usernameResult(c, 'That username is already taken.') } // Then require a remaining change. const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id) const remaining = account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES if (remaining <= 0) { return usernameResult(c, 'You have no username changes remaining.') } const updated = await updateAccount(c.env.DB, id, { username, availableUsernameChanges: remaining - 1, }) await pushAccountUpdate(c, updated) return usernameResult(c, '', toAccountDto(updated)) } ) // Set the player's email (persisted on the account row; surfaced by /account/me). .post( '/account/me/email', describeRoute({ tags: ['Profile'], summary: 'Set email', description: 'Persisted; surfaced only by `/account/me`. Not broadcast.', security: AUTHED, responses: { 200: json(SuccessResponse, 'Updated'), 400: { description: 'Not a syntactically valid address (empty body)' }, 401: UNAUTHORIZED_RESPONSE, }, }), validator('form', EmailRequest, (r, c) => (r.success ? undefined : c.body(null, 400))), async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) const { email } = c.req.valid('form') await updateAccount(c.env.DB, id, { email }) return c.json({ success: true }) } ) // Set the player's phone (persisted on the account row). .post( '/account/me/phone', describeRoute({ tags: ['Profile'], summary: 'Set phone number', description: 'Persisted on the account row. Not broadcast.', security: AUTHED, responses: { 200: json(SuccessResponse, 'Updated'), 400: { description: 'Empty phone (empty body)' }, 401: UNAUTHORIZED_RESPONSE, }, }), validator('form', PhoneRequest, (r, c) => (r.success ? undefined : c.body(null, 400))), async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) const { phone } = c.req.valid('form') await updateAccount(c.env.DB, id, { phone }) return c.json({ success: true }) } ) // Set the player's identityFlags bitmask (persisted; surfaced by /account/me). // `identityFlags` is part of the public account DTO, so the update has to be pushed // — see the note on personalpronouns below. .put( '/account/me/identityflags', describeRoute({ tags: ['Profile'], summary: 'Set identity flags', description: [ '`identityFlags` bitmask. In the public DTO, so the update is broadcast via', 'AccountUpdate.', ].join(' '), security: AUTHED, requestBody: form(IdentityFlagsRequest, 'The identityFlags bitmask'), responses: { 200: json(SuccessResponse, 'Updated'), 400: { description: 'Non-numeric identityFlags (empty body)' }, 401: UNAUTHORIZED_RESPONSE, }, }), async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) const identityFlags = Number.parseInt((await formField(c, 'identityFlags')).trim(), 10) if (Number.isNaN(identityFlags)) return c.body(null, 400) const account = await updateAccount(c.env.DB, id, { identityFlags }) await pushAccountUpdate(c, account) return c.json({ success: true }) } ) // Set the player's personalPronouns (posted as `pronounFlags`; persisted). // The response body carries no account, so the client only learns the new value from // the `SelfAccountUpdate`/`AccountUpdate` the hub pushes — without it the player's own // UI (and every other client, since personalPronouns is in the public DTO) keeps // showing the old pronouns until something else refetches the account. .put( '/account/me/personalpronouns', describeRoute({ tags: ['Profile'], summary: 'Set personal pronouns', description: [ 'Posted as `pronounFlags`. The response carries no account, so the client learns', 'the new value only from the broadcast AccountUpdate.', ].join(' '), security: AUTHED, requestBody: form(PronounsRequest, 'The pronounFlags bitmask'), responses: { 200: json(SuccessResponse, 'Updated'), 400: { description: 'Non-numeric pronounFlags (empty body)' }, 401: UNAUTHORIZED_RESPONSE, }, }), async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) const personalPronouns = Number.parseInt((await formField(c, 'pronounFlags')).trim(), 10) if (Number.isNaN(personalPronouns)) return c.body(null, 400) const account = await updateAccount(c.env.DB, id, { personalPronouns }) await pushAccountUpdate(c, account) return c.json({ success: true }) } ) .put( '/account/me/bio', describeRoute({ tags: ['Profile'], summary: 'Set bio', description: 'Free text up to 255 characters; empty is allowed. Persisted and broadcast.', security: AUTHED, responses: { 200: json(SuccessResponse, 'Updated'), 400: { description: 'Bio over 255 characters (empty body)' }, 401: UNAUTHORIZED_RESPONSE, }, }), // Refused rather than truncated: silently storing half a sentence reads as data loss. validator('form', BioRequest, (r, c) => (r.success ? undefined : c.body(null, 400))), async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) const { bio } = c.req.valid('form') const account = await updateAccount(c.env.DB, id, { bio }) await pushAccountUpdate(c, account) return c.json({ success: true }) } ) // The profile banner — the wide image behind the header on a player's profile. Same // shape as the avatar below: the body names an image the player has already uploaded // (the client posts one of their own photos, `sharecamera//.jpg`), so this // stores a key and never bytes. // // Broadcasts the AccountUpdate like every other profile mutation here — the banner rides // along in the DTO payload, so anyone looking at the profile redraws it without a refetch. .put( '/account/me/bannerimage', describeRoute({ tags: ['Profile'], summary: 'Set profile banner image', description: 'Persists the banner object key and broadcasts it in the AccountUpdate payload. The ' + 'key names an image the player already uploaded — typically one of their own photos ' + '(`sharecamera/…`) — so nothing is uploaded here.', security: AUTHED, requestBody: form(BannerImageRequest, 'The banner object key'), responses: { 200: json(SuccessResponse, 'Updated'), 400: { description: 'Empty imageName (empty body)' }, 401: UNAUTHORIZED_RESPONSE, }, }), async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) const imageName = await formField(c, 'imageName') if (!imageName) return c.body(null, 400) const account = await updateAccount(c.env.DB, id, { bannerImage: imageName }) await pushAccountUpdate(c, account) return c.json({ success: true }) } ) .put( '/account/me/profileimage', describeRoute({ tags: ['Profile'], summary: 'Set profile image', description: 'Persists the avatar object key and broadcasts it in the AccountUpdate payload.', security: AUTHED, requestBody: form(ProfileImageRequest, 'The avatar object key'), responses: { 200: json(SuccessResponse, 'Updated'), 400: { description: 'Empty imageName (empty body)' }, 401: UNAUTHORIZED_RESPONSE, }, }), async (c) => { const id = await authedId(c) 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 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 }) } ) // The generated spec. Documentation only — no request is validated against it (see // openapi.ts). `hide: true` keeps this route out of its own output. app.get( '/openapi.json', describeRoute({ hide: true }), withCleanSpec( openAPIRouteHandler(app, { documentation: { info: { title: 'recflare accounts', version: '1.0.0', description: [ 'Account reads, profile mutations and lookups for recflare, a private-server', 'reimplementation of the Rec Room backend. Accounts live in the shared `recflare`', 'D1 database, whose `account` schema is owned by the `auth` worker.', ].join('\n'), }, servers: [{ url: 'https://accounts.recflare.net', description: 'Production' }], components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'An `access_token` from the auth worker’s `POST /connect/token`.', }, }, }, }, }) ) ) export default app