[rooms] roles

This commit is contained in:
Devin Zuczek
2026-09-14 13:05:08 -04:00
parent 7add875223
commit 4b4db04069
20 changed files with 1516 additions and 181 deletions
+17
View File
@@ -234,6 +234,23 @@ export const DeleteMessagesRequest = z.object({
MessageIds: z.array(z.int()).describe('Ids of the messages to delete'),
})
/**
* One stored message, as `GET /api/messages/v2/get` serves it — the client's Message model,
* which is also what the `MessageReceived` frame carries (see the `notify` worker's
* `MessageReceivedPayload`). Same shape in the inbox and on the wire, deliberately: they are
* one message, and `Id` is the same id in both so the client can match them up.
*/
export const MessageDto = z.object({
Id: z.int().describe('The message’s id — also the `Id` on the frame that delivered it'),
FromPlayerId: z.int(),
ToPlayerId: z.int(),
SentTime: z.string().describe('ISO-8601 UTC'),
Type: z.int().describe('The Message-model type (a different enum from `NotificationType`)'),
Data: z.string().nullable().describe('Null on the types carrying no payload of their own'),
RoomId: z.int().nullable(),
PlayerEventId: z.int().nullable(),
})
/**
* `POST /api/messages/v1/friendOnlineStatus` — how many of the caller's friends are
* online, wrapped in the client's `{ success, value }` envelope.
+111 -55
View File
@@ -5,8 +5,11 @@ import {
acceptFriendRequest,
addFriend,
countOnlineFriends,
createNotification,
deleteNotifications,
getAccountsByIds,
getMutualFriendIds,
getNotificationsForPlayer,
getRelationshipsForPlayer,
MUTUAL_FRIENDS_LIMIT,
removeFriend,
@@ -30,6 +33,7 @@ import {
json,
JsonArray,
jsonBody,
MessageDto,
MutualFriendDto,
RelationshipDto,
SendMessageRequest,
@@ -43,6 +47,7 @@ import type {
RelationshipChange,
RelationshipFlag,
RelationshipResponse,
StoredNotification,
} from '@repo/domain'
import type { App } from '../context'
@@ -50,31 +55,26 @@ import type { App } from '../context'
const HUB_INSTANCE = 'global'
/**
* The Message a `MessageReceived` frame carries. A type alias rather than an interface:
* `notifyPlayer` takes an index-signature record, which only aliases satisfy implicitly.
* Push the `MessageReceived` frame for an ALREADY-STORED message, resolving false when the
* hub could not be reached.
*
* Takes the stored record rather than building one, so the frame carries the row's `Id` and
* `SentTime` — the client matches the message it is pushed to the one it later reads from
* `GET /api/messages/v2/get` by that id, and the delete endpoint has an id to match.
* Spread into a plain record because `notifyPlayer` takes an index-signature type, which an
* interface doesn't satisfy implicitly.
*/
type Message = {
FromPlayerId: number
ToPlayerId: number
Type: number
Data: string
}
/**
* Push one `MessageReceived` frame, resolving false when the hub could not be reached.
* Unlike the relationship pushes, a failure here is NOT swallowed by the caller: there
* is no message store behind this, so the notification is the whole delivery.
*/
async function pushMessage(c: Context<App>, message: Message): Promise<boolean> {
async function pushMessage(c: Context<App>, message: StoredNotification): Promise<boolean> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
message.ToPlayerId,
NotificationType.MessageReceived,
message
{ ...message }
)
return true
} catch (err) {
logger.error('failed to push MessageReceived notification', {
notificationId: message.Id,
toPlayerId: message.ToPlayerId,
error: err instanceof Error ? err.message : String(err),
})
@@ -296,8 +296,8 @@ export const socialRoutes = new Hono<App>({ strict: false })
)
// A message from one player to another — the "invite me!" style prompts the client
// sends. Nothing is stored: the message IS the notification, pushed to the
// recipient's hub connection (and queued by the hub if they're offline).
// sends. STORED as a notification, then pushed to the recipient's hub connection from
// the stored record, so it survives a push they never hear.
.post(
'/api/messages/v2/send',
describeRoute({
@@ -309,15 +309,16 @@ export const socialRoutes = new Hono<App>({ strict: false })
'`coachMessageAll`), except `FromPlayerId` is the caller rather than the Coach ' +
'account and it goes to one player. The hub queues it when the recipient is ' +
'offline, so it arrives on their next connect.\n\n' +
'Nothing is persisted here — there is no message store, the notification is the ' +
'whole delivery. The sender is the caller (from the bearer token), NOT a body ' +
'field. `Type` is a Message-model type (a different enum from `NotificationType`) ' +
'passed through unmapped, defaulting to 0; `Data` is the payload and is commonly ' +
'empty.\n\n' +
'Persisted first, then pushed from the stored record, so the frame carries the ' +
'stored id and the recipient reads the same message back from ' +
'`GET /api/messages/v2/get` whether or not the push reached them. The sender is ' +
'the caller (from the bearer token), NOT a body field. `Type` is a Message-model ' +
'type (a different enum from `NotificationType`) passed through unmapped, ' +
'defaulting to 0; `Data` is the payload and is commonly empty.\n\n' +
'Answers the same `{ success, error }` envelope as the report / warning writes, ' +
'`error` an empty string on success. A hub failure is reported honestly as a 500 ' +
'with `success: false` — with no store behind it, a swallowed error would be a ' +
'silently dropped message.',
'`error` an empty string on success. A hub failure is still reported honestly as ' +
'a 500 with `success: false` — the message is in their inbox, but the caller ' +
'asked to send one and nothing was delivered.',
security: AUTHED,
requestBody: form(SendMessageRequest, 'The message'),
responses: {
@@ -338,15 +339,20 @@ export const socialRoutes = new Hono<App>({ strict: false })
return c.json({ success: false, error: 'ToPlayerId is required' }, 400)
}
// The Message the notification carries. Mirrors the coach message's shape with
// a real sender and recipient; `Data` stays a string, empty included (the hub
// drops only null/undefined from the frame).
const delivered = await pushMessage(c, {
// Stored FIRST, then pushed from the stored record: the row is the durable copy
// and its id is what goes on the frame. A hub failure after this point leaves
// the message in the recipient's inbox, which is the point of the store — they
// read it on next login instead of losing it.
const message = await createNotification(c.env.DB, {
FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId,
Type: Number.parseInt(str(body.Type) ?? '', 10) || 0,
// `Data` stays a string, empty included — distinct from the null the types
// carrying no payload of their own send.
Data: str(body.Data) ?? '',
})
const delivered = await pushMessage(c, message)
if (!delivered) {
return c.json({ success: false, error: 'Failed to deliver message' }, 500)
}
@@ -367,15 +373,16 @@ export const socialRoutes = new Hono<App>({ strict: false })
'The bulk form of `POST /api/messages/v2/send`: pushes the same ' +
'`MessageReceived` frame to every id in `ToPlayerIds`, each addressed to its own ' +
'recipient (`ToPlayerId` differs per frame — the payload is not shared). Same ' +
'sender rule: the caller’s bearer token, never a body field. Same non-store: the ' +
'notification is the whole delivery, queued by the hub for whoever is offline.\n\n' +
'sender rule: the caller’s bearer token, never a body field. Stored the same way ' +
'too — one notification per recipient, each with its own id.\n\n' +
'The body is JSON rather than the single send’s form encoding, so `Type` is a ' +
'number (still an unmapped Message-model type, defaulting to 0) and `Data` a ' +
'string, commonly empty. Repeated ids are delivered once.\n\n' +
'Answers the same `{ success, error }` envelope. Delivery is attempted for every ' +
'recipient even after one fails, but a hub failure for ANY of them is reported ' +
'honestly as a 500 — the envelope has no room to say which, and with no store ' +
'behind it a swallowed error would be a silently dropped message.',
'honestly as a 500 — the envelope has no room to say which. Each recipient gets ' +
'a stored notification of their own, with its own id, so an undelivered one is ' +
'still waiting in that player’s inbox.',
security: AUTHED,
requestBody: jsonBody(SendMultipleMessagesRequest, 'The message and its recipients'),
responses: {
@@ -410,15 +417,19 @@ export const socialRoutes = new Hono<App>({ strict: false })
const data = typeof body.Data === 'string' ? body.Data : ''
// Every recipient is attempted even if an earlier one fails — the reachable
// players get their message either way.
// players get their message either way. One STORED message each, so every
// recipient's copy has its own id to be read and deleted by.
const results = await Promise.all(
toPlayerIds.map((toPlayerId) =>
pushMessage(c, {
FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId,
Type: type,
Data: data,
})
toPlayerIds.map(async (toPlayerId) =>
pushMessage(
c,
await createNotification(c.env.DB, {
FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId,
Type: type,
Data: data,
})
)
)
)
if (results.includes(false)) {
@@ -620,10 +631,30 @@ export const socialRoutes = new Hono<App>({ strict: false })
describeRoute({
tags: ['Social'],
summary: 'Direct messages',
description: 'There is no message store yet, so this is always an empty list.',
responses: { 200: json(JsonArray, 'An empty list') },
description:
'The caller’s inbox — everything sent to them that they haven’t deleted, newest ' +
'first. The client reads this on login, which is what makes it the DURABLE half of ' +
'a message: the `MessageReceived` frame the hub pushes is best-effort (dropped ' +
'once sent rather than acked, bounded per player, and skipped whenever the hub ' +
'believes it delivered), so a message that only ever existed as a frame could be ' +
'lost with nothing left to show for it.\n\n' +
'Each entry is the same Message the frame carries, `Id` included and equal — one ' +
'message, delivered twice, which is how the client recognises the two as the ' +
'same.\n\n' +
'Auth-gated, unlike the empty list this used to answer: an inbox is the caller’s ' +
'own. Capped at the newest `MAX_NOTIFICATIONS_PER_PLAYER`, which is also what the store ' +
'keeps — the client shows an inbox, not an archive.',
security: AUTHED,
responses: {
200: json(MessageDto.array(), 'The caller’s messages, newest first'),
401: UNAUTHORIZED_RESPONSE,
},
}),
(c) => c.json([])
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getNotificationsForPlayer(c.env.DB, id))
}
)
// The inbox's delete button. A POST rather than a DELETE because the ids arrive as
// a JSON array body — the client batches a multi-select into one call, and its HTTP
@@ -634,18 +665,43 @@ export const socialRoutes = new Hono<App>({ strict: false })
tags: ['Social'],
summary: 'Delete messages',
description:
'Drops the given messages from the caller’s inbox. Nothing happens: there is no ' +
'message store behind `GET /api/messages/v2/get` (the `MessageReceived` ' +
'notification is the whole delivery — see `POST /api/messages/v2/send`), so ' +
'there are no ids to match and nothing to remove.\n\n' +
'Accepted unconditionally and answered 200 with an empty body, which is what the ' +
'client wants: it removes the rows locally and re-reads the empty list either ' +
'way. Not auth-gated, for the same reason `GET /api/messages/v2/get` isn’t — ' +
'the call reaches no state to protect.',
'Drops the given messages from the caller’s inbox — the ids being the `Id` on ' +
'each message `GET /api/messages/v2/get` served.\n\n' +
'Scoped to the CALLER’s inbox, not just to the ids: an id names a message ' +
'globally, so matching on id alone would let anyone holding one clear somebody ' +
'else’s inbox. An id that isn’t theirs, or is already gone, matches nothing — ' +
'not an error, since the client removes its rows locally and re-reads the list ' +
'either way.\n\n' +
'Auth-gated now that there is a store behind it. The ids arrive either as a bare ' +
'JSON array or under `MessageIds`; both are read. Answered 200 with an empty ' +
'body, which is what the client wants.',
security: AUTHED,
requestBody: jsonBody(DeleteMessagesRequest, 'The messages to delete'),
responses: { 200: { description: 'Accepted (empty body)' } },
responses: {
200: { description: 'Accepted (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
(c) => c.body(null, 200)
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// The client batches a multi-select into one call. It posts a bare array; the
// documented `{ MessageIds }` object is accepted too rather than guessing which
// of the two a given build sends.
const body = await c.req.json<unknown>().catch(() => null)
const raw = Array.isArray(body)
? body
: Array.isArray((body as { MessageIds?: unknown } | null)?.MessageIds)
? (body as { MessageIds: unknown[] }).MessageIds
: []
const ids = raw
.map((v) => (typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : Number.NaN))
.filter((v) => Number.isInteger(v))
await deleteNotifications(c.env.DB, id, ids)
return c.body(null, 200)
}
)
// How many of the caller's friends are online — the friends panel's header count.
// Answered from the friend graph joined to live presence, so it agrees with the
+129 -36
View File
@@ -15,6 +15,7 @@ import {
LEVEL_REWARDS,
MAX_LEVEL,
MessageType,
NOTIFICATION_SCHEMA_DDL,
OUTFIT_SCHEMA_DDL,
PRESENCE_SCHEMA_DDL,
PRESENCE_TTL_SECONDS,
@@ -177,6 +178,10 @@ beforeAll(async () => {
// Custom avatar items (owned by the api worker).
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// The message store (owned by the api worker) — the inbox reads it and every send
// writes to it.
for (const stmt of NOTIFICATION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
@@ -5996,7 +6001,17 @@ describe('messages', () => {
type Sent = {
playerId: number
notificationType: number
data: { FromPlayerId: number; ToPlayerId: number; Type: number; Data: string }
// The frame carries the STORED message, so `Id` and `SentTime` come off the row.
data: {
Id: number
FromPlayerId: number
ToPlayerId: number
SentTime: string
Type: number
Data: string | null
RoomId: number | null
PlayerEventId: number | null
}
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const pushed = async (): Promise<Sent[]> =>
@@ -6014,26 +6029,60 @@ describe('messages', () => {
// NotificationType.MessageReceived — the same frame the Coach broadcast uses.
const MESSAGE_RECEIVED = 2
/** One player's inbox, as the client reads it on login. */
const inboxOf = async (playerId: string) =>
(await (
await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`, {
headers: await bearer(playerId),
})
).json()) as Array<{
Id: number
FromPlayerId: number
ToPlayerId: number
SentTime: string
Type: number
Data: string | null
}>
const del = async (body: unknown, headers?: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/messages/v3/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
})
test('POST /api/messages/v2/send pushes MessageReceived to the recipient', async () => {
const res = await send({ ToPlayerId: '2', Type: '10', Data: '' }, await bearer('42'))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
expect(await pushed()).toEqual([
{
// Delivered to the recipient, not the sender.
playerId: 2,
notificationType: MESSAGE_RECEIVED,
// FromPlayerId is the token's subject, not a body field.
data: { FromPlayerId: 42, ToPlayerId: 2, Type: 10, Data: '' },
},
])
const sent = await pushed()
expect(sent).toHaveLength(1)
// Delivered to the recipient, not the sender.
expect(sent[0]?.playerId).toBe(2)
expect(sent[0]?.notificationType).toBe(MESSAGE_RECEIVED)
// FromPlayerId is the token's subject, not a body field. The frame carries the
// STORED message, so it has the row's Id and SentTime on it too.
expect(sent[0]?.data).toMatchObject({
FromPlayerId: 42,
ToPlayerId: 2,
Type: 10,
Data: '',
RoomId: null,
PlayerEventId: null,
})
const frameId = sent[0]!.data.Id
expect(frameId).toBeGreaterThan(0)
// ...and the recipient reads the same message, under the same id, from their inbox.
const inbox = await inboxOf('2')
expect(inbox[0]).toMatchObject({ Id: frameId, FromPlayerId: 42, Type: 10, Data: '' })
})
test('POST /api/messages/v2/send defaults Type and Data when omitted', async () => {
const res = await send({ ToPlayerId: '2' }, await bearer('42'))
expect(res.status).toBe(200)
expect((await pushed())[0]?.data).toEqual({
expect((await pushed())[0]?.data).toMatchObject({
FromPlayerId: 42,
ToPlayerId: 2,
Type: 0,
@@ -6073,18 +6122,17 @@ describe('messages', () => {
expect(await res.json()).toEqual({ success: true, error: '' })
// Each frame is addressed to its own recipient; the sender is the token's subject.
expect(await pushed()).toEqual([
{
playerId: 205,
notificationType: MESSAGE_RECEIVED,
data: { FromPlayerId: 42, ToPlayerId: 205, Type: 20, Data: 'hi' },
},
{
playerId: 206,
notificationType: MESSAGE_RECEIVED,
data: { FromPlayerId: 42, ToPlayerId: 206, Type: 20, Data: 'hi' },
},
])
const sent = await pushed()
expect(sent.map((n) => n.playerId)).toEqual([205, 206])
expect(sent.map((n) => n.notificationType)).toEqual([MESSAGE_RECEIVED, MESSAGE_RECEIVED])
expect(sent[0]?.data).toMatchObject({ FromPlayerId: 42, ToPlayerId: 205, Type: 20, Data: 'hi' })
expect(sent[1]?.data).toMatchObject({ FromPlayerId: 42, ToPlayerId: 206, Type: 20, Data: 'hi' })
// One STORED message each, with ids of their own — not one message shared.
const ids = sent.map((n) => n.data.Id)
expect(new Set(ids).size).toBe(2)
expect((await inboxOf('205'))[0]).toMatchObject({ Id: ids[0], Data: 'hi' })
expect((await inboxOf('206'))[0]).toMatchObject({ Id: ids[1], Data: 'hi' })
})
test('POST /api/messages/v1/sendMultiple defaults Type and Data, and de-duplicates ids', async () => {
@@ -6093,7 +6141,7 @@ describe('messages', () => {
const sent = await pushed()
expect(sent).toHaveLength(1)
expect(sent[0]?.data).toEqual({ FromPlayerId: 42, ToPlayerId: 205, Type: 0, Data: '' })
expect(sent[0]?.data).toMatchObject({ FromPlayerId: 42, ToPlayerId: 205, Type: 0, Data: '' })
})
test('POST /api/messages/v1/sendMultiple 400s with no usable recipient, pushing nothing', async () => {
@@ -6111,19 +6159,66 @@ describe('messages', () => {
expect(await pushed()).toEqual([])
})
test('POST /api/messages/v3/delete accepts anything with an empty 200', async () => {
// No message store, so no id can be real and nothing is gated — an unknown id, an
// empty list and a missing body all land the same way.
for (const body of [{ MessageIds: [1787377235629] }, { MessageIds: [] }, {}]) {
const res = await exports.default.fetch(`${ORIGIN}/api/messages/v3/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
test('GET /api/messages/v2/get serves the caller’s inbox, newest first', async () => {
// Two messages to 9600, one to somebody else — an inbox is the caller's own.
await send({ ToPlayerId: '9600', Data: 'first' }, await bearer('42'))
await send({ ToPlayerId: '9600', Data: 'second' }, await bearer('43'))
await send({ ToPlayerId: '9601', Data: 'not yours' }, await bearer('42'))
const inbox = await inboxOf('9600')
expect(inbox.map((m) => m.Data)).toEqual(['second', 'first'])
expect(inbox[0]).toMatchObject({ FromPlayerId: 43, ToPlayerId: 9600, Type: 0 })
// Every message carries the full Message model, ids and all.
expect(Date.parse(inbox[0].SentTime)).not.toBeNaN()
expect(inbox.every((m) => m.Id > 0)).toBe(true)
// The other player's message is not in it.
expect(inbox.some((m) => m.Data === 'not yours')).toBe(false)
})
test('GET /api/messages/v2/get is auth-gated', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`)
expect(res.status).toBe(401)
})
test('POST /api/messages/v3/delete removes only the caller’s messages', async () => {
await send({ ToPlayerId: '9610', Data: 'keep' }, await bearer('42'))
await send({ ToPlayerId: '9610', Data: 'drop' }, await bearer('42'))
await send({ ToPlayerId: '9611', Data: 'someone else' }, await bearer('42'))
const before = await inboxOf('9610')
const drop = before.find((m) => m.Data === 'drop')!
const theirs = (await inboxOf('9611'))[0]
// The caller deletes their own message, and takes a swing at another player's id
// in the same call — an id names a message globally, so this must not reach it.
const res = await del({ MessageIds: [drop.Id, theirs.Id] }, await bearer('9610'))
expect(res.status).toBe(200)
expect(await res.text()).toBe('')
expect((await inboxOf('9610')).map((m) => m.Data)).toEqual(['keep'])
expect((await inboxOf('9611')).map((m) => m.Data)).toEqual(['someone else'])
})
test('POST /api/messages/v3/delete takes a bare array, and shrugs off the rest', async () => {
await send({ ToPlayerId: '9620', Data: 'bare' }, await bearer('42'))
const message = (await inboxOf('9620'))[0]
// The client posts a bare array; the documented `{ MessageIds }` object works too.
expect((await del([message.Id], await bearer('9620'))).status).toBe(200)
expect(await inboxOf('9620')).toEqual([])
// An unknown id, an empty list and a missing body are all an empty 200 — the client
// removes its rows locally and re-reads the list either way.
for (const body of [{ MessageIds: [1787377235629] }, { MessageIds: [] }, {}, []]) {
const res = await del(body, await bearer('9620'))
expect(res.status).toBe(200)
expect(await res.text()).toBe('')
}
})
test('POST /api/messages/v3/delete is auth-gated', async () => {
expect((await del({ MessageIds: [1] })).status).toBe(401)
})
})
describe('mutual friends', () => {
@@ -6740,9 +6835,7 @@ describe('player events', () => {
upcoming.PlayerEventId,
])
// The same base projection the POST serves: one path, one shape.
expect(events.find((e) => e.PlayerEventId === upcoming.PlayerEventId)).toEqual(
asBase(upcoming)
)
expect(events.find((e) => e.PlayerEventId === upcoming.PlayerEventId)).toEqual(asBase(upcoming))
// No ids is an empty list, not every event.
expect(await (await get('/api/playerevents/v1/bulk')).json()).toEqual([])