[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
+46
View File
@@ -0,0 +1,46 @@
-- Stored notifications — the delayed half of a WebSocket push, served back by
-- `GET /api/messages/v2/get` when a player logs in. Owned by the `api` worker; also written
-- by `rooms` (a room-role invite) and `notify` (a coach message to one player), which push
-- the matching `MessageReceived` frame. Generated from
-- packages/domain/src/notifications-db.ts (NOTIFICATION_SCHEMA_DDL) — keep in sync.
--
-- Until this table existed the inbox endpoint answered an empty list unconditionally and the
-- hub's queued frame was the whole of a notification sent to an offline player. That queue is
-- best-effort by construction — dropped once sent rather than acked, bounded per player, and
-- skipped whenever the hub believes it delivered (a socket the client has abandoned still
-- counts) — so a push could be lost with nothing left to show for it.
--
-- `notification_id` is what ties the two halves together: it is the `Id` on the frame that
-- delivers the notification, so the client can match what it was pushed to what it later
-- reads from the inbox, and `POST /api/messages/v3/delete` has an id to match. Senders write
-- the row FIRST and build the frame from it. AUTOINCREMENT rather than a bare rowid alias
-- because the id is handed to the client and deleting a row frees its rowid — a reused id
-- would point a client's stale entry at somebody else's notification.
--
-- Named `notification` and not `message`: the `chat` worker already owns a `message` table on
-- this same shared database (its thread posts). `CREATE TABLE IF NOT EXISTS` against a name
-- already taken does nothing and says nothing, so the first draft of this migration silently
-- adopted chat's table and failed only on the index below, over a column it doesn't have.
--
-- Deliberately NOT stored, because none of it is worth reading later: the coach BROADCAST (a
-- maintenance notice to everyone online at once — a row per player, thousands per send), the
-- vote-to-kick prompt (about a session that has since ended), and a cheer (its durable record
-- is the `reputation` counters; the Message is the part that plays in front of the room).
--
-- `data` is nullable because a Message's `Data` is: the types carrying no payload of their
-- own send null, distinct from the empty string a DM sends. Where set it is always a STRING,
-- whatever it means to that type (a room-role invite puts the offered role tier in it).
CREATE TABLE IF NOT EXISTS notification (
notification_id INTEGER PRIMARY KEY AUTOINCREMENT,
from_player_id INTEGER NOT NULL,
to_player_id INTEGER NOT NULL,
type INTEGER NOT NULL,
data TEXT,
room_id INTEGER,
player_event_id INTEGER,
sent_time TEXT NOT NULL
);
-- The inbox read and the newest-wins trim are both "this player's notifications, newest
-- first", which is the whole access pattern.
CREATE INDEX IF NOT EXISTS idx_notification_recipient ON notification (to_player_id, notification_id DESC);
+17
View File
@@ -234,6 +234,23 @@ export const DeleteMessagesRequest = z.object({
MessageIds: z.array(z.int()).describe('Ids of the messages to delete'), 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 * `POST /api/messages/v1/friendOnlineStatus` — how many of the caller's friends are
* online, wrapped in the client's `{ success, value }` envelope. * online, wrapped in the client's `{ success, value }` envelope.
+106 -50
View File
@@ -5,8 +5,11 @@ import {
acceptFriendRequest, acceptFriendRequest,
addFriend, addFriend,
countOnlineFriends, countOnlineFriends,
createNotification,
deleteNotifications,
getAccountsByIds, getAccountsByIds,
getMutualFriendIds, getMutualFriendIds,
getNotificationsForPlayer,
getRelationshipsForPlayer, getRelationshipsForPlayer,
MUTUAL_FRIENDS_LIMIT, MUTUAL_FRIENDS_LIMIT,
removeFriend, removeFriend,
@@ -30,6 +33,7 @@ import {
json, json,
JsonArray, JsonArray,
jsonBody, jsonBody,
MessageDto,
MutualFriendDto, MutualFriendDto,
RelationshipDto, RelationshipDto,
SendMessageRequest, SendMessageRequest,
@@ -43,6 +47,7 @@ import type {
RelationshipChange, RelationshipChange,
RelationshipFlag, RelationshipFlag,
RelationshipResponse, RelationshipResponse,
StoredNotification,
} from '@repo/domain' } from '@repo/domain'
import type { App } from '../context' import type { App } from '../context'
@@ -50,31 +55,26 @@ import type { App } from '../context'
const HUB_INSTANCE = 'global' const HUB_INSTANCE = 'global'
/** /**
* The Message a `MessageReceived` frame carries. A type alias rather than an interface: * Push the `MessageReceived` frame for an ALREADY-STORED message, resolving false when the
* `notifyPlayer` takes an index-signature record, which only aliases satisfy implicitly. * 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 = { async function pushMessage(c: Context<App>, message: StoredNotification): Promise<boolean> {
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> {
try { try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
message.ToPlayerId, message.ToPlayerId,
NotificationType.MessageReceived, NotificationType.MessageReceived,
message { ...message }
) )
return true return true
} catch (err) { } catch (err) {
logger.error('failed to push MessageReceived notification', { logger.error('failed to push MessageReceived notification', {
notificationId: message.Id,
toPlayerId: message.ToPlayerId, toPlayerId: message.ToPlayerId,
error: err instanceof Error ? err.message : String(err), 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 // 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 // sends. STORED as a notification, then pushed to the recipient's hub connection from
// recipient's hub connection (and queued by the hub if they're offline). // the stored record, so it survives a push they never hear.
.post( .post(
'/api/messages/v2/send', '/api/messages/v2/send',
describeRoute({ describeRoute({
@@ -309,15 +309,16 @@ export const socialRoutes = new Hono<App>({ strict: false })
'`coachMessageAll`), except `FromPlayerId` is the caller rather than the Coach ' + '`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 ' + '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' + 'offline, so it arrives on their next connect.\n\n' +
'Nothing is persisted here — there is no message store, the notification is the ' + 'Persisted first, then pushed from the stored record, so the frame carries the ' +
'whole delivery. The sender is the caller (from the bearer token), NOT a body ' + 'stored id and the recipient reads the same message back from ' +
'field. `Type` is a Message-model type (a different enum from `NotificationType`) ' + '`GET /api/messages/v2/get` whether or not the push reached them. The sender is ' +
'passed through unmapped, defaulting to 0; `Data` is the payload and is commonly ' + 'the caller (from the bearer token), NOT a body field. `Type` is a Message-model ' +
'empty.\n\n' + '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, ' + '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 ' + '`error` an empty string on success. A hub failure is still reported honestly as ' +
'with `success: false` — with no store behind it, a swallowed error would be a ' + 'a 500 with `success: false` — the message is in their inbox, but the caller ' +
'silently dropped message.', 'asked to send one and nothing was delivered.',
security: AUTHED, security: AUTHED,
requestBody: form(SendMessageRequest, 'The message'), requestBody: form(SendMessageRequest, 'The message'),
responses: { responses: {
@@ -338,15 +339,20 @@ export const socialRoutes = new Hono<App>({ strict: false })
return c.json({ success: false, error: 'ToPlayerId is required' }, 400) return c.json({ success: false, error: 'ToPlayerId is required' }, 400)
} }
// The Message the notification carries. Mirrors the coach message's shape with // Stored FIRST, then pushed from the stored record: the row is the durable copy
// a real sender and recipient; `Data` stays a string, empty included (the hub // and its id is what goes on the frame. A hub failure after this point leaves
// drops only null/undefined from the frame). // the message in the recipient's inbox, which is the point of the store — they
const delivered = await pushMessage(c, { // read it on next login instead of losing it.
const message = await createNotification(c.env.DB, {
FromPlayerId: fromPlayerId, FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId, ToPlayerId: toPlayerId,
Type: Number.parseInt(str(body.Type) ?? '', 10) || 0, 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) ?? '', Data: str(body.Data) ?? '',
}) })
const delivered = await pushMessage(c, message)
if (!delivered) { if (!delivered) {
return c.json({ success: false, error: 'Failed to deliver message' }, 500) 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 ' + 'The bulk form of `POST /api/messages/v2/send`: pushes the same ' +
'`MessageReceived` frame to every id in `ToPlayerIds`, each addressed to its own ' + '`MessageReceived` frame to every id in `ToPlayerIds`, each addressed to its own ' +
'recipient (`ToPlayerId` differs per frame — the payload is not shared). Same ' + '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 ' + 'sender rule: the caller’s bearer token, never a body field. Stored the same way ' +
'notification is the whole delivery, queued by the hub for whoever is offline.\n\n' + '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 ' + '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 ' + 'number (still an unmapped Message-model type, defaulting to 0) and `Data` a ' +
'string, commonly empty. Repeated ids are delivered once.\n\n' + 'string, commonly empty. Repeated ids are delivered once.\n\n' +
'Answers the same `{ success, error }` envelope. Delivery is attempted for every ' + '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 ' + '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 ' + 'honestly as a 500 — the envelope has no room to say which. Each recipient gets ' +
'behind it a swallowed error would be a silently dropped message.', 'a stored notification of their own, with its own id, so an undelivered one is ' +
'still waiting in that player’s inbox.',
security: AUTHED, security: AUTHED,
requestBody: jsonBody(SendMultipleMessagesRequest, 'The message and its recipients'), requestBody: jsonBody(SendMultipleMessagesRequest, 'The message and its recipients'),
responses: { responses: {
@@ -410,10 +417,13 @@ export const socialRoutes = new Hono<App>({ strict: false })
const data = typeof body.Data === 'string' ? body.Data : '' const data = typeof body.Data === 'string' ? body.Data : ''
// Every recipient is attempted even if an earlier one fails — the reachable // 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( const results = await Promise.all(
toPlayerIds.map((toPlayerId) => toPlayerIds.map(async (toPlayerId) =>
pushMessage(c, { pushMessage(
c,
await createNotification(c.env.DB, {
FromPlayerId: fromPlayerId, FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId, ToPlayerId: toPlayerId,
Type: type, Type: type,
@@ -421,6 +431,7 @@ export const socialRoutes = new Hono<App>({ strict: false })
}) })
) )
) )
)
if (results.includes(false)) { if (results.includes(false)) {
return c.json({ success: false, error: 'Failed to deliver message' }, 500) return c.json({ success: false, error: 'Failed to deliver message' }, 500)
} }
@@ -620,10 +631,30 @@ export const socialRoutes = new Hono<App>({ strict: false })
describeRoute({ describeRoute({
tags: ['Social'], tags: ['Social'],
summary: 'Direct messages', summary: 'Direct messages',
description: 'There is no message store yet, so this is always an empty list.', description:
responses: { 200: json(JsonArray, 'An empty list') }, '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 // 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 // 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'], tags: ['Social'],
summary: 'Delete messages', summary: 'Delete messages',
description: description:
'Drops the given messages from the caller’s inbox. Nothing happens: there is no ' + 'Drops the given messages from the caller’s inbox — the ids being the `Id` on ' +
'message store behind `GET /api/messages/v2/get` (the `MessageReceived` ' + 'each message `GET /api/messages/v2/get` served.\n\n' +
'notification is the whole delivery — see `POST /api/messages/v2/send`), so ' + 'Scoped to the CALLER’s inbox, not just to the ids: an id names a message ' +
'there are no ids to match and nothing to remove.\n\n' + 'globally, so matching on id alone would let anyone holding one clear somebody ' +
'Accepted unconditionally and answered 200 with an empty body, which is what the ' + 'else’s inbox. An id that isn’t theirs, or is already gone, matches nothing — ' +
'client wants: it removes the rows locally and re-reads the empty list either ' + 'not an error, since the client removes its rows locally and re-reads the list ' +
'way. Not auth-gated, for the same reason `GET /api/messages/v2/get` isn’t — ' + 'either way.\n\n' +
'the call reaches no state to protect.', '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'), 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. // 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 // Answered from the friend graph joined to live presence, so it agrees with the
+127 -34
View File
@@ -15,6 +15,7 @@ import {
LEVEL_REWARDS, LEVEL_REWARDS,
MAX_LEVEL, MAX_LEVEL,
MessageType, MessageType,
NOTIFICATION_SCHEMA_DDL,
OUTFIT_SCHEMA_DDL, OUTFIT_SCHEMA_DDL,
PRESENCE_SCHEMA_DDL, PRESENCE_SCHEMA_DDL,
PRESENCE_TTL_SECONDS, PRESENCE_TTL_SECONDS,
@@ -177,6 +178,10 @@ beforeAll(async () => {
// Custom avatar items (owned by the api worker). // Custom avatar items (owned by the api worker).
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run() 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 // 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 = { type Sent = {
playerId: number playerId: number
notificationType: 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 hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const pushed = async (): Promise<Sent[]> => const pushed = async (): Promise<Sent[]> =>
@@ -6014,26 +6029,60 @@ describe('messages', () => {
// NotificationType.MessageReceived — the same frame the Coach broadcast uses. // NotificationType.MessageReceived — the same frame the Coach broadcast uses.
const MESSAGE_RECEIVED = 2 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 () => { test('POST /api/messages/v2/send pushes MessageReceived to the recipient', async () => {
const res = await send({ ToPlayerId: '2', Type: '10', Data: '' }, await bearer('42')) const res = await send({ ToPlayerId: '2', Type: '10', Data: '' }, await bearer('42'))
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' }) expect(await res.json()).toEqual({ success: true, error: '' })
expect(await pushed()).toEqual([ const sent = await pushed()
{ expect(sent).toHaveLength(1)
// Delivered to the recipient, not the sender. // Delivered to the recipient, not the sender.
playerId: 2, expect(sent[0]?.playerId).toBe(2)
notificationType: MESSAGE_RECEIVED, expect(sent[0]?.notificationType).toBe(MESSAGE_RECEIVED)
// FromPlayerId is the token's subject, not a body field. // FromPlayerId is the token's subject, not a body field. The frame carries the
data: { FromPlayerId: 42, ToPlayerId: 2, Type: 10, Data: '' }, // 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 () => { test('POST /api/messages/v2/send defaults Type and Data when omitted', async () => {
const res = await send({ ToPlayerId: '2' }, await bearer('42')) const res = await send({ ToPlayerId: '2' }, await bearer('42'))
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect((await pushed())[0]?.data).toEqual({ expect((await pushed())[0]?.data).toMatchObject({
FromPlayerId: 42, FromPlayerId: 42,
ToPlayerId: 2, ToPlayerId: 2,
Type: 0, Type: 0,
@@ -6073,18 +6122,17 @@ describe('messages', () => {
expect(await res.json()).toEqual({ success: true, error: '' }) expect(await res.json()).toEqual({ success: true, error: '' })
// Each frame is addressed to its own recipient; the sender is the token's subject. // Each frame is addressed to its own recipient; the sender is the token's subject.
expect(await pushed()).toEqual([ const sent = await pushed()
{ expect(sent.map((n) => n.playerId)).toEqual([205, 206])
playerId: 205, expect(sent.map((n) => n.notificationType)).toEqual([MESSAGE_RECEIVED, MESSAGE_RECEIVED])
notificationType: MESSAGE_RECEIVED, expect(sent[0]?.data).toMatchObject({ FromPlayerId: 42, ToPlayerId: 205, Type: 20, Data: 'hi' })
data: { 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.
playerId: 206, const ids = sent.map((n) => n.data.Id)
notificationType: MESSAGE_RECEIVED, expect(new Set(ids).size).toBe(2)
data: { FromPlayerId: 42, ToPlayerId: 206, Type: 20, Data: 'hi' }, 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 () => { 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() const sent = await pushed()
expect(sent).toHaveLength(1) 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 () => { test('POST /api/messages/v1/sendMultiple 400s with no usable recipient, pushing nothing', async () => {
@@ -6111,19 +6159,66 @@ describe('messages', () => {
expect(await pushed()).toEqual([]) expect(await pushed()).toEqual([])
}) })
test('POST /api/messages/v3/delete accepts anything with an empty 200', async () => { test('GET /api/messages/v2/get serves the caller’s inbox, newest first', async () => {
// No message store, so no id can be real and nothing is gated — an unknown id, an // Two messages to 9600, one to somebody else — an inbox is the caller's own.
// empty list and a missing body all land the same way. await send({ ToPlayerId: '9600', Data: 'first' }, await bearer('42'))
for (const body of [{ MessageIds: [1787377235629] }, { MessageIds: [] }, {}]) { await send({ ToPlayerId: '9600', Data: 'second' }, await bearer('43'))
const res = await exports.default.fetch(`${ORIGIN}/api/messages/v3/delete`, { await send({ ToPlayerId: '9601', Data: 'not yours' }, await bearer('42'))
method: 'POST',
headers: { 'Content-Type': 'application/json' }, const inbox = await inboxOf('9600')
body: JSON.stringify(body), 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(res.status).toBe(200)
expect(await res.text()).toBe('') 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', () => { describe('mutual friends', () => {
@@ -6740,9 +6835,7 @@ describe('player events', () => {
upcoming.PlayerEventId, upcoming.PlayerEventId,
]) ])
// The same base projection the POST serves: one path, one shape. // The same base projection the POST serves: one path, one shape.
expect(events.find((e) => e.PlayerEventId === upcoming.PlayerEventId)).toEqual( expect(events.find((e) => e.PlayerEventId === upcoming.PlayerEventId)).toEqual(asBase(upcoming))
asBase(upcoming)
)
// No ids is an empty list, not every event. // No ids is an empty list, not every event.
expect(await (await get('/api/playerevents/v1/bulk')).json()).toEqual([]) expect(await (await get('/api/playerevents/v1/bulk')).json()).toEqual([])
+1
View File
@@ -15,6 +15,7 @@
"test": "run-vitest" "test": "run-vitest"
}, },
"dependencies": { "dependencies": {
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*", "@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
"hono": "4.13.5", "hono": "4.13.5",
+6
View File
@@ -5,6 +5,12 @@ import type { NotificationsHub } from './notifications-hub'
export type Env = SharedHonoEnv & { export type Env = SharedHonoEnv & {
/** Durable Object hosting the SignalR notifications hub. */ /** Durable Object hosting the SignalR notifications hub. */
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub> RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
/**
* The shared `recflare` D1 database. Used only for the notification store — a coach
* message to one player is written there before it is pushed, so it survives the hub's
* best-effort queue. The hub DO itself keeps its own state in its own SQLite.
*/
DB: D1Database
/** /**
* HS256 JWT signing key, read with `await env.JWT_SECRET.get()`; all workers * HS256 JWT signing key, read with `await env.JWT_SECRET.get()`; all workers
* bind the same store so tokens signed by `auth` verify here. Used to gate the * bind the same store so tokens signed by `auth` verify here. Used to gate the
+6 -1
View File
@@ -236,7 +236,12 @@ export interface MessageReceivedPayload {
/** ISO-8601. */ /** ISO-8601. */
SentTime: string SentTime: string
Type: number Type: number
Data: string /**
* Always a STRING on the wire — a payload with structure to it goes in escaped, never
* as a nested object. Null on the message types that carry no data of their own (a
* room-role invite, say), which the hub then drops from the frame entirely.
*/
Data: string | null
RoomId: number | null RoomId: number | null
PlayerEventId: number | null PlayerEventId: number | null
} }
+24 -14
View File
@@ -115,11 +115,15 @@ export interface HubState {
*/ */
export const MAX_PENDING_PER_PLAYER = 500 export const MAX_PENDING_PER_PLAYER = 500
/** The Coach system account — the `FromPlayerId` on a coach message (see coachMessageAll). */ /**
const COACH_PLAYER_ID = 1 * The Coach system account — the `FromPlayerId` on a coach message (see coachMessageAll).
* Exported because the targeted send stores its message before pushing it, and the row has
* to name the same sender the frame does.
*/
export const COACH_PLAYER_ID = 1
/** The Message `Type` a coach/system message carries (a Message-model enum, not a NotificationType). */ /** The Message `Type` a coach/system message carries (a Message-model enum, not a NotificationType). */
const COACH_MESSAGE_TYPE = 100 export const COACH_MESSAGE_TYPE = 100
export class NotificationsHub extends DurableObject<Env> { export class NotificationsHub extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) { constructor(ctx: DurableObjectState, env: Env) {
@@ -550,20 +554,26 @@ export class NotificationsHub extends DurableObject<Env> {
* the Coach account (player 1), addressed to one player with a `ToPlayerId` — the shape * the Coach account (player 1), addressed to one player with a `ToPlayerId` — the shape
* `api`'s player-to-player send uses. * `api`'s player-to-player send uses.
* *
* Takes the ALREADY-STORED message rather than building one, so the frame carries the
* row's `Id` and `SentTime` and the player can read the same message back from
* `GET /api/messages/v2/get`. The worker route writes the row (the DO has no business
* doing D1 writes — every hub call funnels through this one global instance).
*
* Unlike the broadcast this one QUEUES when the recipient is offline (see * Unlike the broadcast this one QUEUES when the recipient is offline (see
* {@link notifyPlayer}). The broadcast is online-only because it has no recipient to * {@link notifyPlayer}). The broadcast is online-only because it has no recipient to
* hold anything for; a message written to a named player is worth keeping until they * hold anything for — and, for the same reason, nothing to store: a message written to
* next connect. * a named player is worth keeping until they next connect.
*/ */
async coachMessage( async coachMessage(message: {
playerId: number, Id: number
content: string FromPlayerId: number
): Promise<{ delivered: number; queued: boolean }> { ToPlayerId: number
return this.notifyPlayer(playerId, NotificationType.MessageReceived, { SentTime: string
FromPlayerId: COACH_PLAYER_ID, Type: number
ToPlayerId: playerId, Data: string | null
Type: COACH_MESSAGE_TYPE, }): Promise<{ delivered: number; queued: boolean }> {
Data: content, return this.notifyPlayer(message.ToPlayerId, NotificationType.MessageReceived, {
...message,
}) })
} }
+23 -7
View File
@@ -1,10 +1,16 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { useWorkersLogger } from 'workers-tagged-logger' import { useWorkersLogger } from 'workers-tagged-logger'
import { createNotification } from '@repo/domain'
import { logger, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers' import { logger, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt' import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import { NotificationsHub, OWNER_HEADER } from './notifications-hub' import {
COACH_MESSAGE_TYPE,
COACH_PLAYER_ID,
NotificationsHub,
OWNER_HEADER,
} from './notifications-hub'
import type { Context, MiddlewareHandler } from 'hono' import type { Context, MiddlewareHandler } from 'hono'
import type { App } from './context' import type { App } from './context'
@@ -228,9 +234,13 @@ const app = new Hono<App>()
return c.json({ success: true, ...result }) return c.json({ success: true, ...result })
}) })
// The targeted form of the broadcast above: one coach message to ONE player. Queued // The targeted form of the broadcast above: one coach message to ONE player.
// by the hub when they're offline (unlike coach-message-all, which reaches only //
// whoever is connected), so this arrives either way. // Unlike the broadcast, this one is a real message: it is STORED first, so the player
// reads it from `GET /api/messages/v2/get` whether or not the hub reached them, and the
// frame carries the stored row's id. The broadcast stays unstored — it reaches everyone
// online at once, so a row per player would be thousands of writes per maintenance
// notice, for something nobody needs to re-read.
.post('/internal/coach-message', async (c) => { .post('/internal/coach-message', async (c) => {
const body = await c.req const body = await c.req
.json<{ playerId?: number; messageContent?: string }>() .json<{ playerId?: number; messageContent?: string }>()
@@ -240,9 +250,15 @@ const app = new Hono<App>()
return c.json({ error: 'playerId is required' }, 400) return c.json({ error: 'playerId is required' }, 400)
} }
if (content === '') return c.json({ error: 'messageContent is required' }, 400) if (content === '') return c.json({ error: 'messageContent is required' }, 400)
const { playerId } = body
const result = await hubCall(c, (hub) => hub.coachMessage(playerId, content)) const notification = await createNotification(c.env.DB, {
return c.json({ success: true, ...result }) FromPlayerId: COACH_PLAYER_ID,
ToPlayerId: body.playerId,
Type: COACH_MESSAGE_TYPE,
Data: content,
})
const result = await hubCall(c, (hub) => hub.coachMessage(notification))
return c.json({ success: true, notificationId: notification.Id, ...result })
}) })
// Read-only view of the hub's routing state, for working out why a notification // Read-only view of the hub's routing state, for working out why a notification
+43 -1
View File
@@ -2,11 +2,13 @@ import { adminSecretsStore, env, runInDurableObject } from 'cloudflare:test'
import { exports } from 'cloudflare:workers' import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest' import { beforeAll, describe, expect, test } from 'vitest'
import { NOTIFICATION_SCHEMA_DDL } from '@repo/domain'
import '../../notify.app' import '../../notify.app'
import type { Env } from '../../context'
import { MAX_PENDING_PER_PLAYER } from '../../notifications-hub' import { MAX_PENDING_PER_PLAYER } from '../../notifications-hub'
import type { Env } from '../../context'
import type { HubState } from '../../notifications-hub' import type { HubState } from '../../notifications-hub'
declare module 'cloudflare:test' { declare module 'cloudflare:test' {
@@ -50,6 +52,9 @@ async function bearer(
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves. // Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
beforeAll(async () => { beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET) await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET)
// The notification store a targeted coach message writes to. Owned by the `api` worker's
// migrations, which don't run here, so build it from the mirrored DDL.
for (const ddl of NOTIFICATION_SCHEMA_DDL) await env.DB.prepare(ddl).run()
}) })
/** Who `connect` is by default — kept clear of the player ids the tests notify. */ /** Who `connect` is by default — kept clear of the player ids the tests notify. */
@@ -402,6 +407,43 @@ describe('notification delivery', () => {
ws.close() ws.close()
}) })
test('coach-message stores a notification; the broadcast does not', async () => {
const stored = async (playerId: number) =>
(
await env.DB.prepare(
'SELECT notification_id, from_player_id, type, data FROM notification WHERE to_player_id = ?1'
)
.bind(playerId)
.all<{ notification_id: number; from_player_id: number; type: number; data: string }>()
).results
// A message to ONE player is a real message: stored, so they read it from their
// inbox on next login whether or not the hub reached them, and the frame carries
// the stored row's id.
const playerId = 9013
const res = await post('/internal/coach-message', { playerId, messageContent: 'kept' })
const { notificationId } = (await res.json()) as { notificationId: number }
const rows = await stored(playerId)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
notification_id: notificationId,
from_player_id: 1,
type: 100,
data: 'kept',
})
// The BROADCAST stores nothing — it reaches everyone online at once, so a row per
// player would be thousands of writes per maintenance notice.
const listener = await connect('coach-nostore', {
headers: await bearer('9014', ['gameClient']),
})
await post('/internal/coach-message-all', { messageContent: 'everyone' })
await listener.waitFor((r) => r.type === 1 && r.target === 'Notification')
expect(await stored(9014)).toEqual([])
listener.ws.close()
})
test('coach-message 400s without a player or a message', async () => { test('coach-message 400s without a player or a message', async () => {
expect((await post('/internal/coach-message', { messageContent: 'nobody' })).status).toBe(400) expect((await post('/internal/coach-message', { messageContent: 'nobody' })).status).toBe(400)
expect((await post('/internal/coach-message', { playerId: 9012 })).status).toBe(400) expect((await post('/internal/coach-message', { playerId: 9012 })).status).toBe(400)
+15
View File
@@ -8,6 +8,21 @@
"bindings": [{ "name": "RECFLARE_NOTIFICATIONS_HUB", "class_name": "NotificationsHub" }] "bindings": [{ "name": "RECFLARE_NOTIFICATIONS_HUB", "class_name": "NotificationsHub" }]
}, },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["NotificationsHub"] }], "migrations": [{ "tag": "v1", "new_sqlite_classes": ["NotificationsHub"] }],
// The shared `recflare` D1 database, for the message store behind
// `GET /api/messages/v2/get`: a coach message to ONE player is a durable message and is
// written here before it is pushed. No `migrations_dir` — the `api` worker owns this
// schema (apps/api/migrations/0019_message.sql); notify only writes rows.
//
// The coach BROADCAST is deliberately not stored: it reaches everyone online at once, so
// persisting it would write a row per player — thousands per maintenance notice, for
// something nobody needs to re-read.
"d1_databases": [
{
"binding": "DB",
"database_name": "recflare",
"database_id": "local"
}
],
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the // Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local" // same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
// store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time. // store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time.
+22 -2
View File
@@ -400,6 +400,15 @@ export const MissingLookupParam = z
.string() .string()
.describe("`\"Either 'id' or 'name' query parameter is required\"`") .describe("`\"Either 'id' or 'name' query parameter is required\"`")
/**
* A bare `{ success: true }` — the acknowledgement a write answers with when the client
* has nothing to re-render from it. NOT the `{ success, error, value }` room envelope: no
* `error` key and no `value`, so don't reach for this where the client redraws the room.
*/
export const SuccessEnvelope = z.object({
success: z.literal(true),
})
/** /**
* `GET /Room_server/rooms/{roomId}/bans/{playerId}/isBanned` — the ban check's envelope. * `GET /Room_server/rooms/{roomId}/bans/{playerId}/isBanned` — the ban check's envelope.
* *
@@ -571,9 +580,20 @@ export const ImageRequest = z.object({
imageName: z.string().describe('A key from the storage upload, stored un-prefixed'), imageName: z.string().describe('A key from the storage upload, stored un-prefixed'),
}) })
/** `PUT /rooms/{roomId}/roles/{accountId}`. */ /** `PUT /rooms/{roomId}/roles/{accountId}` — a grant, or the invited player's answer. */
export const RoleRequest = z.object({ export const RoleRequest = z.object({
role: z.string().describe('The role tier: 10 Host, 20 Moderator, 30 CoOwner, 255 Creator'), role: z
.string()
.describe(
'The role tier: 10 Host, 20 Moderator. 30 CoOwner and 255 Creator are refused on a ' +
'grant — co-ownership is invited. Answering your own invite, it must equal the ' +
'standing `InvitedRole`, or be `0` to decline'
),
})
/** `PUT /rooms/{roomId}/roles/{accountId}/invite`. */
export const InviteRoleRequest = z.object({
role: z.string().describe('The role tier offered: 10 Host, 20 Moderator, 30 CoOwner'),
}) })
/** `POST /rooms/{roomId}/bans` — the player to ban from the room. */ /** `POST /rooms/{roomId}/bans` — the player to ban from the room. */
+266 -20
View File
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { import {
Accessibility, Accessibility,
answerRoomRoleInvite,
applyRoomTagEdit, applyRoomTagEdit,
areFriends, areFriends,
autocompleteRoomSearch, autocompleteRoomSearch,
@@ -12,6 +13,7 @@ import {
cloneRoom, cloneRoom,
cloneSubRoom, cloneSubRoom,
countRoomsByCreator, countRoomsByCreator,
createNotification,
createSubRoom, createSubRoom,
deleteRoom, deleteRoom,
deleteRoomLeaderboard, deleteRoomLeaderboard,
@@ -24,6 +26,7 @@ import {
getHotRooms, getHotRooms,
getInteraction, getInteraction,
getOrCreateDormRoom, getOrCreateDormRoom,
getPlayerIdsInRoom,
getPresence, getPresence,
getPublicRoomsByCreator, getPublicRoomsByCreator,
getRecommendedRooms, getRecommendedRooms,
@@ -38,12 +41,17 @@ import {
getSubRoomSaves, getSubRoomSaves,
getTrendingRooms, getTrendingRooms,
getVisitedRooms, getVisitedRooms,
inviteRoomRole,
isPlayerBannedFromRoom, isPlayerBannedFromRoom,
isRoomOwner,
MessageType,
modifySubRoom, modifySubRoom,
publishSubRoomSave, publishSubRoomSave,
removeCheer, removeCheer,
removeFavorite, removeFavorite,
Role,
roomNameRejection, roomNameRejection,
roomRoles,
saveSubRoomData, saveSubRoomData,
searchRooms, searchRooms,
setRoomDescription, setRoomDescription,
@@ -88,6 +96,7 @@ import {
ImageRequest, ImageRequest,
InteractionDto, InteractionDto,
intQuery, intQuery,
InviteRoleRequest,
IsBannedEnvelope, IsBannedEnvelope,
IsBannedPascalEnvelope, IsBannedPascalEnvelope,
json, json,
@@ -118,6 +127,7 @@ import {
roomIdParam, roomIdParam,
RoomLookup, RoomLookup,
RoomResultEnvelope, RoomResultEnvelope,
RoomRoleDto,
RoomSaveEnvelope, RoomSaveEnvelope,
saveIdParam, saveIdParam,
SaveSubRoomDataRequest, SaveSubRoomDataRequest,
@@ -131,6 +141,7 @@ import {
SubRoomPermissionsRequest, SubRoomPermissionsRequest,
SubRoomSavesNoUnityAssetsPage, SubRoomSavesNoUnityAssetsPage,
SubRoomSavesPage, SubRoomSavesPage,
SuccessEnvelope,
TagRequest, TagRequest,
TooManyLookupIds, TooManyLookupIds,
UNAUTHORIZED_EMPTY, UNAUTHORIZED_EMPTY,
@@ -141,6 +152,7 @@ import {
import type { Context } from 'hono' import type { Context } from 'hono'
import type { RoomBan, RoomPermission } from '@repo/domain' import type { RoomBan, RoomPermission } from '@repo/domain'
import type { MessageReceivedPayload } from '../../notify/src/notification-payloads'
import type { App } from './context' import type { App } from './context'
/** /**
@@ -501,6 +513,95 @@ async function pushRoomUpdate(
} }
} }
/**
* Push a `RoomUpdate` to everyone standing in a room right now, plus anyone in `alsoNotify`.
*
* The fan-out for a change to the ROOM rather than to one player's relationship with it — a
* role changing alters what the room lets people do, and every client in it is rendering that
* room. Read from live presence across all of the room's instances, so it is whoever is
* actually there; a room nobody is in pushes to nobody.
*
* `alsoNotify` is for the player the change is ABOUT, who may not be standing in the room at
* all — a role granted to someone across the map still changes what they can do when they
* arrive. De-duplicated against the occupants, so being both doesn't earn two pushes.
*
* Each push is independent and failures are swallowed by {@link pushRoomUpdate}, so one
* unreachable player doesn't cost the rest theirs.
*/
async function pushRoomUpdateToRoom(
c: Context<App>,
roomId: number,
room: Record<string, unknown>,
alsoNotify: number[] = []
): Promise<void> {
const playerIds = new Set(await getPlayerIdsInRoom(c.env.DB, roomId))
for (const playerId of alsoNotify) playerIds.add(playerId)
await Promise.all([...playerIds].map((playerId) => pushRoomUpdate(c, playerId, room)))
}
/**
* Store and deliver a room-role invite — a Message of type 62 (`RoomCoOwnerInvited`), which
* is what the invited player accepts or declines from. The `Roles` entry alone is silent:
* it records the offer, this delivers it.
*
* STORED first, then pushed from the stored record, so the frame carries the row's `Id` and
* the invite survives a failed or unheard push: the invited player reads it from
* `GET /api/messages/v2/get` on their next login instead of losing it to the hub's
* best-effort queue. The frame is typed as the hub's {@link MessageReceivedPayload} so a
* renamed key fails the build rather than vanishing on the wire.
*
* `Data` is the ROLE being offered, as a string (a Message's `Data` is always a string on
* the wire). It is what the invited player's client reads the offer off, and what it must
* send back as `role` when it accepts — the accept refuses anything that isn't the role
* standing on their entry, so an invite the client can't read the tier of is one it can't
* answer. `PlayerEventId` is null; the room is in `RoomId`. The hub drops null keys when it
* builds the frame, so it reaches the client absent rather than as null, which its decoder
* reads the same way.
*
* Best-effort on the PUSH only: the role entry and the message row have both committed, so
* a hub hiccup must not fail the request.
*/
async function pushRoleInvite(
c: Context<App>,
roomId: number,
fromAccountId: number,
toAccountId: number,
role: number
): Promise<void> {
const stored = await createNotification(c.env.DB, {
FromPlayerId: fromAccountId,
ToPlayerId: toAccountId,
Type: MessageType.RoomCoOwnerInvited,
// The role being offered, as a string — a Message's `Data` is a string on the wire.
Data: String(role),
RoomId: roomId,
})
const message: MessageReceivedPayload = {
Id: stored.Id,
FromPlayerId: stored.FromPlayerId,
SentTime: stored.SentTime,
Type: stored.Type,
Data: stored.Data,
RoomId: stored.RoomId,
PlayerEventId: stored.PlayerEventId,
}
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
toAccountId,
NotificationType.MessageReceived,
{ ...message, ToPlayerId: stored.ToPlayerId }
)
} catch (err) {
logger.error('failed to push RoomCoOwnerInvited MessageReceived notification', {
messageId: stored.Id,
roomId,
toAccountId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/** /**
* `reportCategory` on a moderation frame. -1 is "Moderator" — the category for an * `reportCategory` on a moderation frame. -1 is "Moderator" — the category for an
* action a person took rather than one the system inferred, which is what a room ban * action a person took rather than one the system inferred, which is what a room ban
@@ -1942,23 +2043,83 @@ const app = new Hono<App>()
} }
) )
// Set a member's role in a room (`Roles[].Role`). Auth-gated (401) and gated to // A room's role list — the same `Roles` the room DTO already carries, served on its own
// the room creator or a co-owner (403 otherwise) — the same owner/co-owner check // so the client can refresh the member list without re-reading the whole room. Public,
// the other room-admin actions use. Body is the `role` form field (an integer role // like the room itself, and a BARE array rather than the room-write envelope. An unknown
// tier). Updates the target account's existing role entry or adds one, notifies the // room is an empty list.
// affected member so their client refreshes permissions, and returns the updated .get(
// room in the lowercase `{ success, error, value }` envelope. '/rooms/:roomId{[0-9]+}/roles',
describeRoute({
tags: ['Room settings'],
summary: 'A room’s roles',
description: [
'Everyone with a role in the room — its creator plus whoever has been granted Host,',
'Moderator or CoOwner — as whole `RoomRole` records. This is the same array the',
'room DTO serves under `Roles`, on its own endpoint, so it is public exactly like the',
'room is: a role list says who runs a room, which the room page already shows.',
'',
'A bare array, NOT the `{ success, error, value }` envelope the role WRITE answers. An',
'unknown room is an empty list rather than an error — it reads the same as a room',
'nobody holds a role in.',
].join('\n'),
parameters: [roomIdParam],
responses: {
200: json(RoomRoleDto.array(), 'The room’s role assignments'),
},
}),
async (c) => {
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
return c.json(room ? roomRoles(room) : [])
}
)
// Set a player's role in a room (`Roles[].Role`). ONE path, two callers:
//
// - the room's owner/co-owner GRANTING a role to somebody else, which takes effect
// immediately — the helper tiers are theirs to hand out;
// - the invited player ANSWERING their own standing co-owner invite, accepting it by
// naming the offered tier or declining with `role=0`.
//
// Which one it is comes from whether the path account IS the caller. CoOwner is the one
// tier a grant cannot reach: it has to be offered (`…/invite`) and accepted, so the
// second path exists at all. Creator is refused for the same reason — it is strictly
// more than the tier that needs an invite.
//
// Auth-gated (401), and a 403 for a valid token with no standing to do either. Everyone
// in the room gets a `RoomUpdate` push, plus the affected player wherever they are.
.put( .put(
'/rooms/:roomId{[0-9]+}/roles/:accountId{[0-9]+}', '/rooms/:roomId{[0-9]+}/roles/:accountId{[0-9]+}',
describeRoute({ describeRoute({
tags: ['Room settings'], tags: ['Room settings'],
summary: 'Set a member’s role in a room', summary: 'Set a player’s role in a room',
description: [ description: [
'Updates the target account’s entry in the room’s `Roles` (or adds one). Gated to the', 'Two callers share this path, told apart by whether the `accountId` in it is the',
'room’s creator or a co-owner — a valid token from anyone else is a 403. The affected', 'caller’s own.',
'MEMBER gets the `RoomUpdate` push, not the caller, so their client refreshes the', '',
'permissions it just gained or lost.', '**The room’s owner or a co-owner, granting a role to someone else.** Takes effect',
].join(' '), 'immediately, updating the target’s entry in `Roles` or adding one. `Role` 30',
'(CoOwner) is refused: co-ownership is offered with `PUT …/roles/{accountId}/invite`',
'and accepted by the invited player, which is what the other half of this endpoint',
'is for. `Role` 255 (Creator) is refused too — it is strictly more than the tier',
'that needs an invite, so it cannot be the one grant that skips the ceremony.',
'Any pending invite on the entry is left standing.',
'',
'**The invited player, answering their own co-owner invite.** Accepting promotes',
'their entry from its pending `InvitedRole` to the real `Role` and clears the',
'pending one — an accepted entry reads `Role: 30, InvitedRole: 0`, so an invite',
'cannot be answered twice. The `role` body field must equal the `InvitedRole` the',
'owner offered: without that check the invited player could answer an invite to',
'Host by asking for Creator and award themselves the room. Declining is `role=0`',
'and drops the entry outright, leaving `Roles` as it was before the offer — note',
'that this takes any role they already held with it. No entry, no invite standing,',
'and the wrong role are all the same rejection, so probing tells the caller nothing.',
'',
'Everyone standing in the room right now gets the `RoomUpdate` push, and so does the',
'affected player wherever they are — a role change alters what the room lets people',
'do, and every client showing it re-renders from the room they are pushed. The',
'caller reads the same room out of the envelope.',
].join('\n'),
security: AUTHED, security: AUTHED,
parameters: [ parameters: [
roomIdParam, roomIdParam,
@@ -1966,11 +2127,11 @@ const app = new Hono<App>()
name: 'accountId', name: 'accountId',
in: 'path', in: 'path',
required: true, required: true,
description: 'The member whose role changes', description: 'The player whose role changes — the caller’s own id to answer an invite',
schema: { type: 'string', pattern: '^[0-9]+$' }, schema: { type: 'string', pattern: '^[0-9]+$' },
}, },
], ],
requestBody: form(RoleRequest, 'The role tier to grant'), requestBody: form(RoleRequest, 'The role tier, or 0 to decline an invite'),
responses: { responses: {
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'), 200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
@@ -1985,22 +2146,107 @@ const app = new Hono<App>()
const targetAccountId = Number.parseInt(c.req.param('accountId'), 10) const targetAccountId = Number.parseInt(c.req.param('accountId'), 10)
const room = await getRoomById(c.env.DB, roomId) const room = await getRoomById(c.env.DB, roomId)
if (!room) return roomEnvelope(c, null, 'This room does not exist!') if (!room) return roomEnvelope(c, null, 'This room does not exist!')
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
// already returned 401 for a missing/invalid token).
if (!canManageRoom(room, accountId)) return c.body(null, 403)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown> const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const role = typeof body.role === 'string' ? Number.parseInt(body.role, 10) : Number.NaN const role = typeof body.role === 'string' ? Number.parseInt(body.role, 10) : Number.NaN
if (Number.isNaN(role)) return roomEnvelope(c, null, 'You must provide a valid role!') if (Number.isNaN(role)) return roomEnvelope(c, null, 'You must provide a valid role!')
// Acting on your OWN entry is answering an invite — the only thing a player with
// no standing in the room may do here, and the only way a co-owner role is ever
// taken. An owner targeting themselves lands here too, and has no invite to answer.
if (targetAccountId === accountId) {
const answered = await answerRoomRoleInvite(c.env.DB, roomId, accountId, role, room)
if (!answered) return roomEnvelope(c, null, 'You have no such invite to this room!')
await pushRoomUpdateToRoom(c, roomId, answered)
return roomEnvelope(c, answered)
}
// Otherwise it is a grant, and the room's owner/co-owner gate applies.
if (!canManageRoom(room, accountId)) return c.body(null, 403)
// `role=0` is the invited player's decline, not an owner-side revoke — there is no
// revoke yet, and letting one in here would let a co-owner strip the other.
if (role === Role.None) return roomEnvelope(c, null, 'You must provide a valid role!')
// Null means the tier has to be ACCEPTED rather than handed over: nobody force-adds
// a co-owner. `setRoomRole` refuses it itself — the rule belongs to the room's
// `Roles`, not to this route — so there is no check here to forget to copy.
const updated = await setRoomRole(c.env.DB, roomId, targetAccountId, role, accountId, room) const updated = await setRoomRole(c.env.DB, roomId, targetAccountId, role, accountId, room)
// Notify the member whose role changed so their client refreshes the room if (!updated) return roomEnvelope(c, null, 'Co-ownership must be invited, not granted!')
// (and the permissions it grants them).
await pushRoomUpdate(c, targetAccountId, updated) await pushRoomUpdateToRoom(c, roomId, updated, [targetAccountId])
return roomEnvelope(c, updated) return roomEnvelope(c, updated)
} }
) )
// Invite a player to a room role (`Roles[].InvitedRole`). Auth-gated (401) and gated to
// the room's OWNER alone (403 otherwise) — narrower than the role WRITE above, which a
// co-owner may also use. Body is the `role` form field. Records the pending offer on the
// target's role entry (adding one at `Role` 0 when they have none), pushes the
// `RoomCoOwnerInvited` message that raises the invite on their client, and answers a
// bare `{ success: true }` — the inviter's client re-renders nothing from it.
.put(
'/rooms/:roomId{[0-9]+}/roles/:accountId{[0-9]+}/invite',
describeRoute({
tags: ['Room settings'],
summary: 'Invite a player to a room role',
description: [
'Sets `InvitedRole` on the target’s entry in the room’s `Roles` (adding an entry at',
'`Role` 0 when they have none) — the PENDING half of a role entry, held until they',
'accept, at which point the role proper is granted. Someone who already holds a role',
'keeps it while a higher one is pending.',
'',
'Gated to the room’s OWNER — its creator, or the holder of the Creator role. Narrower',
'than setting a role outright, which a co-owner may also do: otherwise a co-owner',
'could quietly grow the set of people who can change the room. A valid token from',
'anyone else is a 403.',
'',
'The INVITED player gets a durable `MessageReceived` frame carrying a Message of type',
'62 (`RoomCoOwnerInvited`), naming the inviter, the room, and the offered role as its',
'`Data` — that message, not the `Roles` entry, is what their client raises the invite',
'from, and the role in it is what they send back to accept.',
'',
'Answers a bare `{ success: true }`, NOT the `{ success, error, value }` room',
'envelope: nothing on the inviter’s screen re-renders from an invite.',
].join('\n'),
security: AUTHED,
parameters: [
roomIdParam,
{
name: 'accountId',
in: 'path',
required: true,
description: 'The player being invited',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
requestBody: form(InviteRoleRequest, 'The role tier being offered'),
responses: {
200: json(SuccessEnvelope, 'The invite was recorded and pushed'),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const targetAccountId = Number.parseInt(c.req.param('accountId'), 10)
const room = await getRoomById(c.env.DB, roomId)
// An unknown room and a room somebody else owns answer the same 403: the gate is
// the owner check, and there is no envelope here to carry a message in.
if (!room || !isRoomOwner(room, accountId)) return c.body(null, 403)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const role = typeof body.role === 'string' ? Number.parseInt(body.role, 10) : Number.NaN
if (Number.isNaN(role)) return c.body(null, 400)
await inviteRoomRole(c.env.DB, roomId, targetAccountId, role, accountId, room)
await pushRoleInvite(c, roomId, accountId, targetAccountId, role)
return c.json({ success: true })
}
)
// A room's ban list — the owner's view of who they've banned. Same gate as issuing a // A room's ban list — the owner's view of who they've banned. Same gate as issuing a
// ban: a ban list says who a room's owner has had trouble with, so it isn't public. // ban: a ban list says who a room's owner has had trouble with, so it isn't public.
// Answers a BARE array (not the room-write envelope), newest ban first. // Answers a BARE array (not the room-write envelope), newest ban first.
+360 -29
View File
@@ -6,6 +6,8 @@ import '../../rooms.app'
import { import {
createRoomInstance, createRoomInstance,
getRoomInstance, getRoomInstance,
MessageType,
NOTIFICATION_SCHEMA_DDL,
PRESENCE_SCHEMA_DDL, PRESENCE_SCHEMA_DDL,
ROOM_INSTANCE_SCHEMA_DDL, ROOM_INSTANCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL, ROOM_SCHEMA_DDL,
@@ -99,6 +101,9 @@ beforeAll(async () => {
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Presence table (read by the photon access-token handler). // Presence table (read by the photon access-token handler).
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Message store (owned by the api worker) — a room-role invite is written there before
// it is pushed, so the invited player can read it back from their inbox.
for (const stmt of NOTIFICATION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill). // Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill).
for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>) for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
@@ -1629,49 +1634,373 @@ describe('rooms endpoints', () => {
expect(interactions!.n).toBe(0) expect(interactions!.n).toBe(0)
}) })
it('PUT /rooms/:id/roles/:accountId is auth-gated, owner/co-owner-only, and persists', async () => { it('PUT /rooms/:id/roles/:accountId grants a helper role outright, but never co-owner', async () => {
const rolesOf = async (): Promise<Array<{ AccountId: number; Role: number }>> => { type Role = {
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { AccountId: number
Roles?: Array<{ AccountId: number; Role: number }> Role: number
InvitedRole: number
LastChangedByAccountId: number | null
} }
return room.Roles ?? [] const roleOf = async (accountId: number): Promise<Role | undefined> =>
((await (await SELF.fetch(`${ORIGIN}/rooms/2/roles`)).json()) as Role[]).find(
(r) => r.AccountId === accountId
)
// RecCenter (room 2) is owned by account 1, with account 2 as co-owner.
// No token → 401; a valid token with no standing in the room → 403.
expect((await putForm('/rooms/2/roles/860', { role: '20' })).status).toBe(401)
expect((await putForm('/rooms/2/roles/860', { role: '20' }, '999')).status).toBe(403)
// The owner grants Moderator, which takes effect immediately — no invite, no accept.
const ok = await putForm('/rooms/2/roles/860', { role: '20' }, '1')
expect(ok.status).toBe(200)
expect(await envOf(ok)).toMatchObject({ success: true, error: '' })
expect(await roleOf(860)).toMatchObject({
Role: 20,
InvitedRole: 0,
LastChangedByAccountId: 1,
})
// A co-owner may hand out the helper tiers too, updating the entry in place rather
// than adding a second.
expect((await putForm('/rooms/2/roles/860', { role: '10' }, '2')).status).toBe(200)
const roles = (await (await SELF.fetch(`${ORIGIN}/rooms/2/roles`)).json()) as Role[]
expect(roles.filter((r) => r.AccountId === 860)).toHaveLength(1)
expect(await roleOf(860)).toMatchObject({ Role: 10, LastChangedByAccountId: 2 })
// The two tiers a grant may never reach — co-ownership has to be accepted, and
// Creator is more than co-ownership. Neither writes anything.
for (const role of ['30', '255']) {
expect(await envOf(await putForm('/rooms/2/roles/860', { role }, '1'))).toMatchObject({
success: false,
error: 'Co-ownership must be invited, not granted!',
})
} }
// `role=0` is the invited player's decline, not an owner-side revoke.
expect(await envOf(await putForm('/rooms/2/roles/860', { role: '0' }, '1'))).toMatchObject({
success: false,
})
expect(await roleOf(860)).toMatchObject({ Role: 10 })
// A grant leaves a pending co-owner invite standing — the two are answered separately.
expect((await putForm('/rooms/2/roles/861/invite', { role: '30' }, '1')).status).toBe(200)
expect((await putForm('/rooms/2/roles/861', { role: '20' }, '1')).status).toBe(200)
expect(await roleOf(861)).toMatchObject({ Role: 20, InvitedRole: 30 })
})
it('a grant pushes a RoomUpdate to the room and to the affected player', async () => {
type Sent = { playerId: number; notificationType: string | number; data: { RoomId: number } }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sent = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
// One bystander in room 4, and the player being granted the role standing somewhere
// else entirely — a role they can use when they arrive, so they hear about it too.
await putInRoom(870, 4)
await putInRoom(871, 2)
await hub().fetch('http://do/all', { method: 'DELETE' })
expect((await putForm('/rooms/4/roles/871', { role: '20' }, '1')).status).toBe(200)
const pushed = await sent()
expect(pushed.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([870, 871])
expect(
pushed.every((n) => n.notificationType === NotificationType.SubscriptionUpdateRoom)
).toBe(true)
expect(pushed.every((n) => n.data.RoomId === 4)).toBe(true)
for (const id of [870, 871]) await clearPresence(id)
})
it('PUT /rooms/:id/roles/:accountId accepts a standing invite, and only that one', async () => {
type Role = {
AccountId: number
Role: number
InvitedRole: number
LastChangedByAccountId: number | null
}
const roleOf = async (accountId: number): Promise<Role | undefined> =>
((await (await SELF.fetch(`${ORIGIN}/rooms/2/roles`)).json()) as Role[]).find(
(r) => r.AccountId === accountId
)
// RecCenter (room 2) is owned by account 1. The owner invites 850 to co-ownership,
// leaving a pending offer and no role yet.
expect((await putForm('/rooms/2/roles/850/invite', { role: '30' }, '1')).status).toBe(200)
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 })
// No token → 401 (auth gate). // No token → 401 (auth gate).
expect((await putForm('/rooms/2/roles/5', { role: '20' })).status).toBe(401) expect((await putForm('/rooms/2/roles/850', { role: '30' })).status).toBe(401)
// A valid token but no role on the room (RecCenter is owned by account 1, with // Nobody can accept on 850's behalf. A stranger has no standing here at all (403);
// account 2 as co-owner) → 403. // the owner and the co-owner fall through to the GRANT path, where co-ownership is
expect((await putForm('/rooms/2/roles/5', { role: '20' }, '999')).status).toBe(403) // exactly the tier they may not hand over.
expect((await putForm('/rooms/2/roles/850', { role: '30' }, '999')).status).toBe(403)
for (const caller of ['1', '2']) {
expect(
await envOf(await putForm('/rooms/2/roles/850', { role: '30' }, caller))
).toMatchObject({ success: false, error: 'Co-ownership must be invited, not granted!' })
}
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 })
// Unknown room → failure envelope. // Unknown room → failure envelope.
expect(await envOf(await putForm('/rooms/99999/roles/5', { role: '20' }, '1'))).toMatchObject({ expect(
success: false, await envOf(await putForm('/rooms/99999/roles/850', { role: '30' }, '850'))
error: 'This room does not exist!', ).toMatchObject({ success: false, error: 'This room does not exist!' })
})
// Non-numeric role → failure envelope. // Non-numeric role → failure envelope.
expect(await envOf(await putForm('/rooms/2/roles/5', { role: 'nope' }, '1'))).toMatchObject({ expect(await envOf(await putForm('/rooms/2/roles/850', { role: 'nope' }, '850'))).toMatchObject(
{
success: false,
}
)
// Asking for a HIGHER role than the one offered is the attack this guards against:
// refused, and the entry is untouched — still no role, invite still standing.
expect(await envOf(await putForm('/rooms/2/roles/850', { role: '255' }, '850'))).toMatchObject({
success: false, success: false,
}) })
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 })
// Owner sets account 5's role to 20, adding a new Roles entry that persists. The // A player with no entry in the room at all is the same rejection — probing tells
// success envelope carries the updated room as `value`. // them nothing.
const ok = await putForm('/rooms/2/roles/5', { role: '20' }, '1') expect(await envOf(await putForm('/rooms/2/roles/833', { role: '30' }, '833'))).toMatchObject({
success: false,
})
expect(await roleOf(833)).toBeUndefined()
// Accepting the role actually offered promotes the entry and consumes the invite.
const ok = await putForm('/rooms/2/roles/850', { role: '30' }, '850')
expect(ok.status).toBe(200) expect(ok.status).toBe(200)
const okBody = await envOf(ok) const okBody = await envOf(ok)
expect(okBody).toMatchObject({ success: true, error: '' }) expect(okBody).toMatchObject({ success: true, error: '' })
expect(okBody.value?.Roles as Array<{ AccountId: number; Role: number }>).toContainEqual( expect(okBody.value?.Roles as Role[]).toContainEqual(
expect.objectContaining({ AccountId: 5, Role: 20 }) expect.objectContaining({ AccountId: 850, Role: 30, InvitedRole: 0 })
) )
expect(await rolesOf()).toContainEqual(expect.objectContaining({ AccountId: 5, Role: 20 })) expect(await roleOf(850)).toMatchObject({
Role: 30,
InvitedRole: 0,
// Left as the OWNER who offered the role, not restamped with the accepter.
LastChangedByAccountId: 1,
})
// The co-owner (account 2, Role 30) may also change it — updating the existing // The invite is spent: the same call again is refused, so it can't be replayed.
// entry in place rather than adding a duplicate. expect(await envOf(await putForm('/rooms/2/roles/850', { role: '30' }, '850'))).toMatchObject({
const byCoOwner = await putForm('/rooms/2/roles/5', { role: '10' }, '2') success: false,
expect(byCoOwner.status).toBe(200) })
const roles = await rolesOf() expect(await roleOf(850)).toMatchObject({ Role: 30, InvitedRole: 0 })
expect(roles.filter((r) => r.AccountId === 5)).toHaveLength(1) })
expect(roles).toContainEqual(expect.objectContaining({ AccountId: 5, Role: 10 }))
// The seeded co-owner (account 2) is left intact. it('PUT /rooms/:id/roles/:accountId with role=0 declines, dropping the entry', async () => {
expect(roles).toContainEqual(expect.objectContaining({ AccountId: 2, Role: 30 })) type Role = { AccountId: number; Role: number; InvitedRole: number }
const roleOf = async (accountId: number): Promise<Role | undefined> =>
((await (await SELF.fetch(`${ORIGIN}/rooms/2/roles`)).json()) as Role[]).find(
(r) => r.AccountId === accountId
)
// Declining with nothing standing is the same rejection as any other unanswerable
// invite — it can't be used to drop a role nobody offered to change.
expect(await envOf(await putForm('/rooms/2/roles/851', { role: '0' }, '851'))).toMatchObject({
success: false,
})
// Invited, then declined: the whole entry goes, leaving `Roles` as it was before the
// offer rather than an entry with nothing pending on it.
expect((await putForm('/rooms/2/roles/851/invite', { role: '30' }, '1')).status).toBe(200)
expect(await roleOf(851)).toMatchObject({ Role: 0, InvitedRole: 30 })
const declined = await putForm('/rooms/2/roles/851', { role: '0' }, '851')
expect(declined.status).toBe(200)
expect(await envOf(declined)).toMatchObject({ success: true, error: '' })
expect(await roleOf(851)).toBeUndefined()
// And the decline can't be replayed either — there is no entry left to answer.
expect(await envOf(await putForm('/rooms/2/roles/851', { role: '0' }, '851'))).toMatchObject({
success: false,
})
// Declining takes a role the player ALREADY held with it: the invite is answered as a
// whole, so a re-invited member who declines is left with nothing.
expect((await putForm('/rooms/2/roles/852/invite', { role: '10' }, '1')).status).toBe(200)
expect((await putForm('/rooms/2/roles/852', { role: '10' }, '852')).status).toBe(200)
expect(await roleOf(852)).toMatchObject({ Role: 10, InvitedRole: 0 })
expect((await putForm('/rooms/2/roles/852/invite', { role: '30' }, '1')).status).toBe(200)
expect((await putForm('/rooms/2/roles/852', { role: '0' }, '852')).status).toBe(200)
expect(await roleOf(852)).toBeUndefined()
})
it('accepting an invite pushes a RoomUpdate to everyone in the room', async () => {
type Sent = {
playerId: number
notificationType: string | number
data: { RoomId: number; Roles: Array<{ AccountId: number; Role: number }> }
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sent = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
// Room 3's owner invites 840, who is standing in the room with two others. A fourth
// player is in a DIFFERENT room, and a fifth's presence has lapsed — neither is there.
expect((await putForm('/rooms/3/roles/840/invite', { role: '20' }, '1')).status).toBe(200)
await putInRoom(840, 3)
await putInRoom(841, 3)
await putInRoom(842, 2)
await putInRoom(843, 3, { expired: true })
await hub().fetch('http://do/all', { method: 'DELETE' })
expect((await putForm('/rooms/3/roles/840', { role: '20' }, '840')).status).toBe(200)
// A role change alters what the room lets people do, so everyone standing in it
// re-renders — including the accepter, who is one of them.
const pushed = await sent()
expect(pushed.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([840, 841])
expect(
pushed.every((n) => n.notificationType === NotificationType.SubscriptionUpdateRoom)
).toBe(true)
// Each carries the room as it now stands, with the accepted role on it.
expect(pushed[0].data).toMatchObject({ RoomId: 3 })
expect(pushed[0].data.Roles).toContainEqual(
expect.objectContaining({ AccountId: 840, Role: 20, InvitedRole: 0 })
)
for (const id of [840, 841, 842, 843]) await clearPresence(id)
})
it('GET /rooms/:id/roles serves the room’s roles as a bare array', async () => {
// RecCenter (room 2) is seeded with its creator (255) and a co-owner (30). Public,
// like the room itself — no token.
const res = await SELF.fetch(`${ORIGIN}/rooms/2/roles`)
expect(res.status).toBe(200)
// Containment, not equality: the role-write test above grants account 5 a role on
// this same room, and the two tests share a database.
expect(await res.json()).toEqual(
expect.arrayContaining([
{ AccountId: 1, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
{ AccountId: 2, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 },
])
)
// An unknown room reads the same as a room nobody holds a role in.
expect((await SELF.fetch(`${ORIGIN}/rooms/99999/roles`)).status).toBe(200)
expect(await (await SELF.fetch(`${ORIGIN}/rooms/99999/roles`)).json()).toEqual([])
// A role written without `LastChangedByAccountId`/`InvitedRole` (the older rooms) is
// still served as a whole record — the client's parser wants every key.
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId: 30601,
Name: 'PartialRoles',
CreatorAccountId: 830,
SubRooms: [],
Roles: [{ AccountId: 830, Role: 255 }],
})
)
.run()
expect(await (await SELF.fetch(`${ORIGIN}/rooms/30601/roles`)).json()).toEqual([
{ AccountId: 830, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
])
// A room with no `Roles` key at all is an empty list, not a failure.
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(JSON.stringify({ RoomId: 30602, Name: 'NoRoles', CreatorAccountId: 830, SubRooms: [] }))
.run()
expect(await (await SELF.fetch(`${ORIGIN}/rooms/30602/roles`)).json()).toEqual([])
})
it('PUT /rooms/:id/roles/:accountId/invite records the offer and pushes the invite', async () => {
type Sent = {
playerId: number
notificationType: string | number
data: Record<string, unknown>
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sent = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
const rolesOf = async (roomId: number) =>
(await (await SELF.fetch(`${ORIGIN}/rooms/${roomId}/roles`)).json()) as Array<{
AccountId: number
Role: number
InvitedRole: number
LastChangedByAccountId: number | null
}>
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/roles/187/invite', { role: '30' })).status).toBe(401)
// A valid token with no standing on the room → 403...
expect((await putForm('/rooms/2/roles/187/invite', { role: '30' }, '999')).status).toBe(403)
// ...and so does the CO-OWNER (account 2, Role 30), who may set roles outright but
// may not hand out co-ownership. This is the gate that is narrower than the write.
expect((await putForm('/rooms/2/roles/187/invite', { role: '30' }, '2')).status).toBe(403)
// An unknown room answers the same 403 rather than leaking that it doesn't exist.
expect((await putForm('/rooms/99999/roles/187/invite', { role: '30' }, '1')).status).toBe(403)
// A non-numeric role is a bad request — there is no envelope here to carry a message.
expect((await putForm('/rooms/2/roles/187/invite', { role: 'nope' }, '1')).status).toBe(400)
// None of the refusals wrote a role entry.
expect((await rolesOf(2)).find((r) => r.AccountId === 187)).toBeUndefined()
// The owner invites 187 to co-ownership.
await hub().fetch('http://do/all', { method: 'DELETE' })
const res = await putForm('/rooms/2/roles/187/invite', { role: '30' }, '1')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true })
// A new entry, holding no role yet — only the pending offer, stamped with the inviter.
expect(await rolesOf(2)).toContainEqual({
AccountId: 187,
Role: 0,
InvitedRole: 30,
LastChangedByAccountId: 1,
})
// One MessageReceived to the INVITED player carrying a type 62 RoomCoOwnerInvited —
// the entry alone is silent, this is what raises the invite on their client.
const pushed = await sent()
expect(pushed).toHaveLength(1)
expect(pushed[0].playerId).toBe(187)
expect(pushed[0].notificationType).toBe(NotificationType.MessageReceived)
expect(pushed[0].data).toMatchObject({
FromPlayerId: 1,
Type: MessageType.RoomCoOwnerInvited,
// The offered role, as a string — what the client reads the tier off, and sends
// back as `role` to accept.
Data: '30',
RoomId: 2,
PlayerEventId: null,
})
// The invite is STORED before it is pushed, and the frame carries the row's id and
// timestamp — so the invited player reads the same invite from their inbox
// (`GET /api/messages/v2/get`) even if the push never reached them.
const row = await env.DB.prepare(
'SELECT notification_id, from_player_id, type, data, room_id, sent_time FROM notification WHERE to_player_id = 187'
).first<{
notification_id: number
from_player_id: number
type: number
data: string | null
room_id: number
sent_time: string
}>()
expect(row).toMatchObject({
from_player_id: 1,
type: MessageType.RoomCoOwnerInvited,
data: '30',
room_id: 2,
})
expect(pushed[0].data.Id).toBe(row!.notification_id)
expect(pushed[0].data.SentTime).toBe(row!.sent_time)
expect(Date.parse(row!.sent_time)).not.toBeNaN()
// Re-inviting someone who already has an entry sets the pending role in place rather
// than adding a second entry — and leaves the role they already hold alone. 187
// accepts the invite above first, so they hold a real role to be re-invited over.
expect((await putForm('/rooms/2/roles/187', { role: '30' }, '187')).status).toBe(200)
await putForm('/rooms/2/roles/187/invite', { role: '20' }, '1')
const after = await rolesOf(2)
expect(after.filter((r) => r.AccountId === 187)).toHaveLength(1)
expect(after).toContainEqual({
AccountId: 187,
Role: 30,
InvitedRole: 20,
LastChangedByAccountId: 1,
})
}) })
it('POST /rooms/:id/bans is gated to the room’s owners or staff, and persists', async () => { it('POST /rooms/:id/bans is gated to the room’s owners or staff, and persists', async () => {
@@ -4100,6 +4429,7 @@ describe('rooms endpoints', () => {
'GET /rooms/{roomId}/experience/player', 'GET /rooms/{roomId}/experience/player',
'GET /rooms/{roomId}/interactionby/me', 'GET /rooms/{roomId}/interactionby/me',
'GET /rooms/{roomId}/playerdata/me', 'GET /rooms/{roomId}/playerdata/me',
'GET /rooms/{roomId}/roles',
'GET /rooms/{roomId}/similar', 'GET /rooms/{roomId}/similar',
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves', 'GET /rooms/{roomId}/subrooms/{subRoomId}/saves',
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves/no_unity_assets', 'GET /rooms/{roomId}/subrooms/{subRoomId}/saves/no_unity_assets',
@@ -4124,6 +4454,7 @@ describe('rooms endpoints', () => {
'PUT /rooms/{roomId}/name', 'PUT /rooms/{roomId}/name',
'PUT /rooms/{roomId}/restrictions', 'PUT /rooms/{roomId}/restrictions',
'PUT /rooms/{roomId}/roles/{accountId}', 'PUT /rooms/{roomId}/roles/{accountId}',
'PUT /rooms/{roomId}/roles/{accountId}/invite',
'PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility', 'PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility',
'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify', 'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify',
'PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions', 'PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions',
+5
View File
@@ -203,8 +203,13 @@ export enum MatchmakingErrorCode {
* and Creator are the owner-level tiers that may manage the room (see * and Creator are the owner-level tiers that may manage the room (see
* {@link canManageRoom}). Creator is the room's owner (its `CreatorAccountId`) — * {@link canManageRoom}). Creator is the room's owner (its `CreatorAccountId`) —
* the max byte. * the max byte.
*
* `None` is a real value on the wire, not a placeholder: it is the `Role` on an entry that
* holds only a pending `InvitedRole` (invited, nothing granted yet), the `InvitedRole` on an
* entry with nothing pending, and what the invited player posts back to DECLINE an invite.
*/ */
export enum Role { export enum Role {
None = 0,
Host = 10, Host = 10,
Moderator = 20, Moderator = 20,
CoOwner = 30, CoOwner = 30,
+1
View File
@@ -19,6 +19,7 @@ export * from './stats-db'
export * from './gifts-db' export * from './gifts-db'
export * from './inventory-invention-db' export * from './inventory-invention-db'
export * from './lists-db' export * from './lists-db'
export * from './notifications-db'
export * from './outfits-db' export * from './outfits-db'
export * from './progression-db' export * from './progression-db'
export * from './relationships-db' export * from './relationships-db'
+227
View File
@@ -0,0 +1,227 @@
/**
* Stored notifications on the shared `recflare` D1 database — one row per notification a
* player has been sent, as `GET /api/messages/v2/get` serves them back on login.
*
* A notification is a WebSocket push, and this table is the delayed half of one. The live
* half is the `MessageReceived` frame the hub pushes, and for a long time it was the ONLY
* half: the inbox endpoint answered an empty list unconditionally, so anything sent to an
* offline player existed solely as a queued frame in the hub's Durable Object. That queue is
* best-effort by construction — dropped once sent rather than acked, bounded per player, and
* skipped entirely whenever the hub believes it delivered (a socket the client has already
* abandoned still counts as a delivery) — so a push could be lost with nothing left to show
* it ever existed. A row here outlives all of that: the player reads it on next login
* whether or not the socket ever carried it.
*
* The two halves are tied together by the ID. A stored notification's `Id` is the id carried
* on the frame that delivers it, so the client can recognise the push it received as the one
* it then reads from the inbox, and `POST /api/messages/v3/delete` has an id to match. A
* sender therefore WRITES THE ROW FIRST and builds its frame from the returned record —
* never the other way round, and never with an id of its own invention.
*
* Not everything pushed belongs here. Only what is still worth reading LATER: a direct
* message, a room-role invite, a coach message to one player. A broadcast (a maintenance
* notice to everyone online at once) would be a row per player for something nobody re-reads;
* a vote-to-kick prompt is about a session that has since ended; a cheer already has its
* durable record in the `reputation` counters.
*
* The table is `notification`, NOT `message`: the `chat` worker already owns a `message`
* table on this same shared database (its thread posts, a different thing entirely). The two
* were distinguishable only by name, and `CREATE TABLE IF NOT EXISTS` against a name that is
* already taken does nothing and says nothing — so the first version of this migration
* quietly adopted chat's table and failed only one statement later, on an index naming a
* column that table doesn't have. Every worker here shares one database; check a table name
* against the whole repo before taking it.
*
* The `api` worker owns this schema/migration (`apps/api/migrations/0019_notification.sql`,
* applied under its own `migrations_table` so it doesn't clash with the other workers'
* migrations that share the database). `NOTIFICATION_SCHEMA_DDL` mirrors that migration so
* tests can build the table directly.
*/
/**
* How many stored notifications one player keeps, newest-wins.
*
* Bounded for the same reason the hub's pending queue is: an inbox nothing ever deletes grows
* with every notification the server sends, and the read then loads the lot. The client shows
* an inbox rather than an archive, and a player returning to a screenful of them is served no
* better by the one below it.
*/
export const MAX_NOTIFICATIONS_PER_PLAYER = 20
/** Schema DDL (mirror of apps/api/migrations/0019_notification.sql). */
export const NOTIFICATION_SCHEMA_DDL: string[] = [
// `notification_id` is AUTOINCREMENT rather than a bare rowid alias: it is handed to the
// client (on the frame and in the inbox) and deleting a notification frees its rowid, so a
// reused id would point a client's stale row at somebody else's.
//
// `data` is nullable because a Message's `Data` is: the types that carry no payload of
// their own send null, distinct from the empty string a DM sends. Where it is set it is
// always a STRING, whatever it means to that type (a room-role invite puts the offered
// role tier in it). `room_id`/`player_event_id` are the optional context a notification
// can name.
//
// `sent_time` is an ISO-8601 UTC string, which is fixed-width and so orders correctly
// under SQLite's plain string comparison — though reads order by `notification_id`, which
// is monotonic and doesn't tie when two land in the same millisecond.
`CREATE TABLE IF NOT EXISTS notification (
notification_id INTEGER PRIMARY KEY AUTOINCREMENT,
from_player_id INTEGER NOT NULL,
to_player_id INTEGER NOT NULL,
type INTEGER NOT NULL,
data TEXT,
room_id INTEGER,
player_event_id INTEGER,
sent_time TEXT NOT NULL
)`,
// The inbox read and the newest-wins trim are both "this player's notifications, newest
// first", which is the whole access pattern.
`CREATE INDEX IF NOT EXISTS idx_notification_recipient ON notification (to_player_id, notification_id DESC)`,
]
/**
* One stored notification, in the shape the client reads 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 notification delivered two ways, and the client parses it with the same decoder either
* way. Hence `Id` here rather than a spelled-out `NotificationId` — the field names belong to
* the client, not to this table.
*/
export interface StoredNotification {
Id: number
FromPlayerId: number
ToPlayerId: number
/** ISO-8601 UTC. */
SentTime: string
Type: number
Data: string | null
RoomId: number | null
PlayerEventId: number | null
}
/** What a sender supplies; the rest of a {@link StoredNotification} is minted here. */
export interface NewNotification {
FromPlayerId: number
ToPlayerId: number
Type: number
Data?: string | null
RoomId?: number | null
PlayerEventId?: number | null
}
interface NotificationRow {
notification_id: number
from_player_id: number
to_player_id: number
type: number
data: string | null
room_id: number | null
player_event_id: number | null
sent_time: string
}
const SELECT_COLUMNS = `notification_id, from_player_id, to_player_id, type, data, room_id, player_event_id, sent_time`
const toNotification = (row: NotificationRow): StoredNotification => ({
Id: row.notification_id,
FromPlayerId: row.from_player_id,
ToPlayerId: row.to_player_id,
SentTime: row.sent_time,
Type: row.type,
Data: row.data,
RoomId: row.room_id,
PlayerEventId: row.player_event_id,
})
/**
* Store one notification and return it as the client reads it — including the `Id` the caller
* must then put on the `MessageReceived` frame it pushes.
*
* Trims the recipient's inbox to {@link MAX_NOTIFICATIONS_PER_PLAYER} on the way, oldest
* first, so the bound holds no matter how the inbox got long. Both statements go in one
* batch: they are one logical write, and an insert whose trim didn't run would leave the
* inbox over its bound until the next notification happened along.
*/
export async function createNotification(
db: D1Database,
notification: NewNotification
): Promise<StoredNotification> {
const [inserted] = await db.batch<NotificationRow>([
db
.prepare(
`INSERT INTO notification (from_player_id, to_player_id, type, data, room_id, player_event_id, sent_time)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
RETURNING ${SELECT_COLUMNS}`
)
.bind(
notification.FromPlayerId,
notification.ToPlayerId,
notification.Type,
notification.Data ?? null,
notification.RoomId ?? null,
notification.PlayerEventId ?? null,
new Date().toISOString()
),
db
.prepare(
`DELETE FROM notification
WHERE to_player_id = ?1 AND notification_id NOT IN (
SELECT notification_id FROM notification WHERE to_player_id = ?1
ORDER BY notification_id DESC LIMIT ?2
)`
)
.bind(notification.ToPlayerId, MAX_NOTIFICATIONS_PER_PLAYER),
])
return toNotification(inserted.results[0])
}
/**
* A player's inbox, newest first — everything sent TO them that they haven't deleted.
*
* Newest first because the client renders the list in the order it is given and the recent
* ones are what is still worth acting on. Ordered by `notification_id` rather than
* `sent_time`: ids are monotonic, so two that land in the same millisecond still have a
* definite order.
*/
export async function getNotificationsForPlayer(
db: D1Database,
playerId: number
): Promise<StoredNotification[]> {
const { results } = await db
.prepare(
`SELECT ${SELECT_COLUMNS} FROM notification
WHERE to_player_id = ?1 ORDER BY notification_id DESC LIMIT ?2`
)
.bind(playerId, MAX_NOTIFICATIONS_PER_PLAYER)
.all<NotificationRow>()
return results.map(toNotification)
}
/**
* Delete notifications from one player's inbox, returning how many rows went.
*
* Scoped to the recipient, not just the ids: an id names a notification globally, so deleting
* on id alone would let anyone holding one clear somebody else's inbox. An id that isn't
* theirs (or is already gone) simply matches nothing — the client removes its rows locally
* and re-reads the list either way, so a partial match is not an error.
*/
export async function deleteNotifications(
db: D1Database,
playerId: number,
notificationIds: number[]
): Promise<number> {
if (notificationIds.length === 0) return 0
const placeholders = notificationIds.map((_, i) => `?${i + 2}`).join(', ')
const { results } = await db
.prepare(
`DELETE FROM notification
WHERE to_player_id = ?1 AND notification_id IN (${placeholders})
RETURNING notification_id`
)
.bind(playerId, ...notificationIds)
.all<{ notification_id: number }>()
return results.length
}
+27
View File
@@ -204,6 +204,33 @@ export async function getPlayerIdsInInstance(
return results.map((r) => r.accountId) return results.map((r) => r.accountId)
} }
/**
* Everyone standing in a ROOM right now, across all of its instances — the fan-out set for
* something that changes the room itself rather than one session of it, such as a role
* being accepted: every client in the room re-renders it, whichever instance they are in.
*
* The room-wide counterpart to {@link getPlayerIdsInInstance}, which is the narrower set
* for something that happens in front of one session (a cheer). Reads only unexpired
* presence, so it is LIVE occupancy — a player who crashed out drops off it when their row
* lapses — and excludes lobby (null-instance) presence. Ordered by account id, and DISTINCT:
* a stale row in another instance of the same room must not earn a second push.
*/
export async function getPlayerIdsInRoom(
db: D1Database,
roomId: number,
now = nowSeconds()
): Promise<number[]> {
const { results } = await db
.prepare(
`SELECT DISTINCT account_id AS accountId FROM presence
WHERE room_id = ?1 AND expires_at > ?2 AND room_instance_id IS NOT NULL
ORDER BY account_id`
)
.bind(roomId, now)
.all<{ accountId: number }>()
return results.map((r) => r.accountId)
}
/** /**
* How many players are online right now, anywhere — one row per account, so this is * How many players are online right now, anywhere — one row per account, so this is
* the player count a status page means. Counts unexpired presence only: rows outlive * the player count a status page means. Counts unexpired presence only: rows outlive
+182 -14
View File
@@ -184,13 +184,30 @@ export const SUBROOM_SCHEMA_DDL: string[] = [
export type Room = Record<string, unknown> export type Room = Record<string, unknown>
/** A room role assignment (the client's RoomRole shape). */ /** A room role assignment (the client's RoomRole shape). */
interface RoomRole { export interface RoomRole {
AccountId: number AccountId: number
Role: number Role: number
LastChangedByAccountId: number | null LastChangedByAccountId: number | null
InvitedRole: number InvitedRole: number
} }
/**
* A room's `Roles`, always as whole {@link RoomRole} records. Roles live inside the room
* blob, and the older rooms either have no `Roles` key at all or carry entries written
* before `LastChangedByAccountId`/`InvitedRole` existed; every reader wants the complete
* shape, so the missing keys are filled in here rather than at each call site. Returns
* copies, so a caller that edits an entry must write the returned array back to the room.
*/
export function roomRoles(room: Room): RoomRole[] {
const roles = Array.isArray(room.Roles) ? (room.Roles as Array<Partial<RoomRole>>) : []
return roles.map((r) => ({
AccountId: Number(r.AccountId ?? 0),
Role: Number(r.Role ?? Role.None),
LastChangedByAccountId: r.LastChangedByAccountId ?? null,
InvitedRole: Number(r.InvitedRole ?? Role.None),
}))
}
/** /**
* Room roles that confer owner-level management of a room: Creator (255) and * Room roles that confer owner-level management of a room: Creator (255) and
* CoOwner (30). The reference gates its room-admin actions on this set. (Host and * CoOwner (30). The reference gates its room-admin actions on this set. (Host and
@@ -207,8 +224,7 @@ const MANAGE_ROLES: ReadonlySet<number> = new Set([Role.Creator, Role.CoOwner])
*/ */
export function canManageRoom(room: Room, accountId: number): boolean { export function canManageRoom(room: Room, accountId: number): boolean {
if (room.CreatorAccountId === accountId) return true if (room.CreatorAccountId === accountId) return true
const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : [] return roomRoles(room).some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role))
return roles.some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role))
} }
/** /**
@@ -223,8 +239,19 @@ export function canManageRoom(room: Room, accountId: number): boolean {
*/ */
export function canModerateRoom(room: Room, accountId: number): boolean { export function canModerateRoom(room: Room, accountId: number): boolean {
if (room.CreatorAccountId === accountId) return true if (room.CreatorAccountId === accountId) return true
const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : [] return roomRoles(room).some((r) => r.AccountId === accountId && r.Role >= Role.Moderator)
return roles.some((r) => r.AccountId === accountId && r.Role >= Role.Moderator) }
/**
* Whether an account OWNS a room — its `CreatorAccountId`, or the holder of the Creator
* (255) role. The narrowest of the three room gates: {@link canManageRoom} lets a CoOwner
* through and {@link canModerateRoom} a Moderator, but handing out co-ownership is the
* owner's alone, or a co-owner could quietly grow the set of people who can change the
* room, an offer the invited player then accepts themselves.
*/
export function isRoomOwner(room: Room, accountId: number): boolean {
if (room.CreatorAccountId === accountId) return true
return roomRoles(room).some((r) => r.AccountId === accountId && r.Role === Role.Creator)
} }
/** A player banned from a room (a `room_ban` row). */ /** A player banned from a room (a `room_ban` row). */
@@ -414,7 +441,12 @@ export async function cloneRoom(
// any co-owners, e.g. the seeded base-room roles for accounts 1/2) must NOT // any co-owners, e.g. the seeded base-room roles for accounts 1/2) must NOT
// carry over, or the clone would still list the template's owner as owner. // carry over, or the clone would still list the template's owner as owner.
const roles: RoomRole[] = [ const roles: RoomRole[] = [
{ AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 }, {
AccountId: accountId,
Role: Role.Creator,
LastChangedByAccountId: null,
InvitedRole: Role.None,
},
] ]
const cloned: Room = { const cloned: Room = {
@@ -509,10 +541,37 @@ export async function updateRoomFields(
} }
/** /**
* Set a target account's room `Role` — updating their existing `Roles` entry or * Whether a role tier can only be taken by INVITATION — offered with
* appending a new one — and stamp `LastChangedByAccountId` with the editor. The * {@link inviteRoomRole} and accepted by the player themselves via
* caller supplies the already-loaded room (after its owner/co-owner check) to avoid * {@link answerRoomRoleInvite} — rather than granted outright.
* a re-read; the whole room JSON is rewritten. Returns the updated room. *
* {@link Role.CoOwner} is the tier this rule exists for: nobody can force-add a co-owner,
* it is always an invite the player accepts. {@link Role.Creator} is included because it is
* strictly more than co-ownership — it would make no sense for the bigger grant to be the
* one that skips the ceremony. Expressed as `>=` so a tier added between or above them is
* covered by default: a new high-privilege role should have to be accepted until someone
* decides otherwise, not be grantable because nobody updated this list.
*/
export function roleRequiresInvite(role: number): boolean {
return role >= Role.CoOwner
}
/**
* GRANT a room role outright — updating the target account's existing `Roles` entry or
* appending one — and stamp `LastChangedByAccountId` with the editor. This is the owner's
* half of role management, and it takes effect immediately: the helper tiers
* ({@link Role.Host}, {@link Role.Moderator} and whatever else the client hands out) are the
* owner's to give.
*
* Returns null, writing nothing, for a tier that {@link roleRequiresInvite} — nobody can
* force-add a co-owner, whoever is asking. The rule lives HERE rather than in the route that
* currently calls this, because it is an invariant of the room's `Roles` and not a policy of
* one endpoint: the only way a `Role` of {@link Role.CoOwner} may ever appear on an entry is
* {@link answerRoomRoleInvite} promoting an `InvitedRole` the room's owner offered.
*
* The caller supplies the already-loaded room (after its gate) to avoid a re-read; the whole
* room JSON is rewritten. Any pending `InvitedRole` on the entry is left standing — a granted
* Host with a co-owner invite open still has it to answer.
*/ */
export async function setRoomRole( export async function setRoomRole(
db: D1Database, db: D1Database,
@@ -521,8 +580,10 @@ export async function setRoomRole(
role: number, role: number,
changedByAccountId: number, changedByAccountId: number,
room: Room room: Room
): Promise<Room> { ): Promise<Room | null> {
const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : [] if (roleRequiresInvite(role)) return null
const roles = roomRoles(room)
const existing = roles.find((r) => r.AccountId === targetAccountId) const existing = roles.find((r) => r.AccountId === targetAccountId)
if (existing) { if (existing) {
existing.Role = role existing.Role = role
@@ -532,7 +593,109 @@ export async function setRoomRole(
AccountId: targetAccountId, AccountId: targetAccountId,
Role: role, Role: role,
LastChangedByAccountId: changedByAccountId, LastChangedByAccountId: changedByAccountId,
InvitedRole: 0, InvitedRole: Role.None,
})
}
const updated: Room = { ...room, Roles: roles }
await db
.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1')
.bind(roomId, serializeRoom(updated))
.run()
return updated
}
/**
* ANSWER a standing room-role invite — accept it, or decline it with `role` 0.
*
* Accepting promotes the account's `Roles` entry from its pending `InvitedRole` to the real
* `Role` and clears the pending one, so an accepted entry reads `Role: 30, InvitedRole: 0`
* and the invite cannot be answered twice. `role` is what the ACCEPTING PLAYER asked for, and
* it must equal the `InvitedRole` already on their entry. That equality is the whole security
* of this call: the answer is made by the invited player themselves, so without it they could
* answer an invite to Host by asking for Creator and award themselves the room. The invited
* role is the server's own record of what the owner offered, written by
* {@link inviteRoomRole}, and nothing the caller sends can move it.
*
* Declining (`role` 0 — not a real tier, so it can't be confused for one) REMOVES the entry
* outright rather than just clearing `InvitedRole`: a declined invite should leave the room's
* `Roles` as it was before the offer. Note that this takes any role the player already held
* with it, so someone re-invited to a different tier who declines is left with nothing at
* all. That is deliberate — an invite is answered as a whole — but it means a decline is not
* a no-op for an existing member.
*
* Returns null — changing nothing — when there is no entry for the account, no invite
* standing on it, or (when accepting) the requested role isn't the one offered. The three are
* deliberately one answer: a caller probing for a higher role learns only that it didn't work.
*
* `LastChangedByAccountId` is left alone rather than restamped with the accepter: it names
* who put this role on the entry, and that is the owner who offered it. The caller supplies
* the already-loaded room to avoid a re-read; the whole room JSON is rewritten.
*/
export async function answerRoomRoleInvite(
db: D1Database,
roomId: number,
accountId: number,
role: number,
room: Room
): Promise<Room | null> {
const roles = roomRoles(room)
const index = roles.findIndex((r) => r.AccountId === accountId)
if (index === -1) return null
const entry = roles[index]
// `Role.None` is "nothing pending" — the resting state of every entry, and what an
// already-answered invite leaves behind, so neither answer can be replayed.
if (entry.InvitedRole === Role.None) return null
if (role === Role.None) {
roles.splice(index, 1)
} else if (entry.InvitedRole !== role) {
return null
} else {
entry.Role = entry.InvitedRole
entry.InvitedRole = Role.None
}
const updated: Room = { ...room, Roles: roles }
await db
.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1')
.bind(roomId, serializeRoom(updated))
.run()
return updated
}
/**
* INVITE an account to a room role — setting `InvitedRole` on their `Roles` entry (adding
* the entry when they have none) and stamping `LastChangedByAccountId` with the inviter.
*
* `InvitedRole` is the PENDING half of a role entry: an invited account holds it until
* they accept, at which point {@link answerRoomRoleInvite} promotes it to the real `Role`.
* So an entry added here starts at {@link Role.None} — no role yet, only an offer — and an
* account who already holds a role keeps it while a higher one is pending.
*
* Like {@link answerRoomRoleInvite}, the caller supplies the already-loaded room to
* avoid a re-read, and the whole room JSON is rewritten. Returns the updated room.
*/
export async function inviteRoomRole(
db: D1Database,
roomId: number,
targetAccountId: number,
invitedRole: number,
changedByAccountId: number,
room: Room
): Promise<Room> {
const roles = roomRoles(room)
const existing = roles.find((r) => r.AccountId === targetAccountId)
if (existing) {
existing.InvitedRole = invitedRole
existing.LastChangedByAccountId = changedByAccountId
} else {
roles.push({
AccountId: targetAccountId,
Role: Role.None,
LastChangedByAccountId: changedByAccountId,
InvitedRole: invitedRole,
}) })
} }
const updated: Room = { ...room, Roles: roles } const updated: Room = { ...room, Roles: roles }
@@ -2915,7 +3078,12 @@ export async function getOrCreateDormRoom(db: D1Database, accountId: number): Pr
CreatorAccountId: accountId, CreatorAccountId: accountId,
IsDorm: true, IsDorm: true,
Roles: [ Roles: [
{ AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 }, {
AccountId: accountId,
Role: Role.Creator,
LastChangedByAccountId: null,
InvitedRole: Role.None,
},
], ],
// Counters start at zero rather than inheriting the template dorm's (see cloneRoom). // Counters start at zero rather than inheriting the template dorm's (see cloneRoom).
Stats: storedStats(template?.Stats), Stats: storedStats(template?.Stats),
+3
View File
@@ -939,6 +939,9 @@ importers:
apps/notify: apps/notify:
dependencies: dependencies:
'@repo/domain':
specifier: workspace:*
version: link:../../packages/domain
'@repo/hono-helpers': '@repo/hono-helpers':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/hono-helpers version: link:../../packages/hono-helpers