From 4b4db04069c2b7d9df1f42e8c9df57ee092cdf2a Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 14 Sep 2026 13:05:08 -0400 Subject: [PATCH] [rooms] roles --- apps/api/migrations/0019_notification.sql | 46 +++ apps/api/src/openapi.ts | 17 + apps/api/src/routes/social.ts | 166 +++++--- apps/api/src/test/integration/api.test.ts | 165 ++++++-- apps/notify/package.json | 1 + apps/notify/src/context.ts | 6 + apps/notify/src/notification-payloads.ts | 7 +- apps/notify/src/notifications-hub.ts | 40 +- apps/notify/src/notify.app.ts | 30 +- apps/notify/src/test/integration/api.test.ts | 44 ++- apps/notify/wrangler.jsonc | 15 + apps/rooms/src/openapi.ts | 24 +- apps/rooms/src/rooms.app.ts | 286 +++++++++++++- apps/rooms/src/test/integration/api.test.ts | 391 +++++++++++++++++-- packages/domain/src/enums.ts | 5 + packages/domain/src/index.ts | 1 + packages/domain/src/notifications-db.ts | 227 +++++++++++ packages/domain/src/presence-db.ts | 27 ++ packages/domain/src/rooms-db.ts | 196 +++++++++- pnpm-lock.yaml | 3 + 20 files changed, 1516 insertions(+), 181 deletions(-) create mode 100644 apps/api/migrations/0019_notification.sql create mode 100644 packages/domain/src/notifications-db.ts diff --git a/apps/api/migrations/0019_notification.sql b/apps/api/migrations/0019_notification.sql new file mode 100644 index 0000000..6e468e0 --- /dev/null +++ b/apps/api/migrations/0019_notification.sql @@ -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); diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index f670782..c694e80 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -234,6 +234,23 @@ export const DeleteMessagesRequest = z.object({ MessageIds: z.array(z.int()).describe('Ids of the messages to delete'), }) +/** + * One stored message, as `GET /api/messages/v2/get` serves it — the client's Message model, + * which is also what the `MessageReceived` frame carries (see the `notify` worker's + * `MessageReceivedPayload`). Same shape in the inbox and on the wire, deliberately: they are + * one message, and `Id` is the same id in both so the client can match them up. + */ +export const MessageDto = z.object({ + Id: z.int().describe('The message’s id — also the `Id` on the frame that delivered it'), + FromPlayerId: z.int(), + ToPlayerId: z.int(), + SentTime: z.string().describe('ISO-8601 UTC'), + Type: z.int().describe('The Message-model type (a different enum from `NotificationType`)'), + Data: z.string().nullable().describe('Null on the types carrying no payload of their own'), + RoomId: z.int().nullable(), + PlayerEventId: z.int().nullable(), +}) + /** * `POST /api/messages/v1/friendOnlineStatus` — how many of the caller's friends are * online, wrapped in the client's `{ success, value }` envelope. diff --git a/apps/api/src/routes/social.ts b/apps/api/src/routes/social.ts index 56ea126..b32300e 100644 --- a/apps/api/src/routes/social.ts +++ b/apps/api/src/routes/social.ts @@ -5,8 +5,11 @@ import { acceptFriendRequest, addFriend, countOnlineFriends, + createNotification, + deleteNotifications, getAccountsByIds, getMutualFriendIds, + getNotificationsForPlayer, getRelationshipsForPlayer, MUTUAL_FRIENDS_LIMIT, removeFriend, @@ -30,6 +33,7 @@ import { json, JsonArray, jsonBody, + MessageDto, MutualFriendDto, RelationshipDto, SendMessageRequest, @@ -43,6 +47,7 @@ import type { RelationshipChange, RelationshipFlag, RelationshipResponse, + StoredNotification, } from '@repo/domain' import type { App } from '../context' @@ -50,31 +55,26 @@ import type { App } from '../context' const HUB_INSTANCE = 'global' /** - * The Message a `MessageReceived` frame carries. A type alias rather than an interface: - * `notifyPlayer` takes an index-signature record, which only aliases satisfy implicitly. + * Push the `MessageReceived` frame for an ALREADY-STORED message, resolving false when the + * hub could not be reached. + * + * Takes the stored record rather than building one, so the frame carries the row's `Id` and + * `SentTime` — the client matches the message it is pushed to the one it later reads from + * `GET /api/messages/v2/get` by that id, and the delete endpoint has an id to match. + * Spread into a plain record because `notifyPlayer` takes an index-signature type, which an + * interface doesn't satisfy implicitly. */ -type Message = { - FromPlayerId: number - ToPlayerId: number - Type: number - Data: string -} - -/** - * Push one `MessageReceived` frame, resolving false when the hub could not be reached. - * Unlike the relationship pushes, a failure here is NOT swallowed by the caller: there - * is no message store behind this, so the notification is the whole delivery. - */ -async function pushMessage(c: Context, message: Message): Promise { +async function pushMessage(c: Context, message: StoredNotification): Promise { try { await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( message.ToPlayerId, NotificationType.MessageReceived, - message + { ...message } ) return true } catch (err) { logger.error('failed to push MessageReceived notification', { + notificationId: message.Id, toPlayerId: message.ToPlayerId, error: err instanceof Error ? err.message : String(err), }) @@ -296,8 +296,8 @@ export const socialRoutes = new Hono({ strict: false }) ) // A message from one player to another — the "invite me!" style prompts the client - // sends. Nothing is stored: the message IS the notification, pushed to the - // recipient's hub connection (and queued by the hub if they're offline). + // sends. STORED as a notification, then pushed to the recipient's hub connection from + // the stored record, so it survives a push they never hear. .post( '/api/messages/v2/send', describeRoute({ @@ -309,15 +309,16 @@ export const socialRoutes = new Hono({ strict: false }) '`coachMessageAll`), except `FromPlayerId` is the caller rather than the Coach ' + 'account and it goes to one player. The hub queues it when the recipient is ' + 'offline, so it arrives on their next connect.\n\n' + - 'Nothing is persisted here — there is no message store, the notification is the ' + - 'whole delivery. The sender is the caller (from the bearer token), NOT a body ' + - 'field. `Type` is a Message-model type (a different enum from `NotificationType`) ' + - 'passed through unmapped, defaulting to 0; `Data` is the payload and is commonly ' + - 'empty.\n\n' + + 'Persisted first, then pushed from the stored record, so the frame carries the ' + + 'stored id and the recipient reads the same message back from ' + + '`GET /api/messages/v2/get` whether or not the push reached them. The sender is ' + + 'the caller (from the bearer token), NOT a body field. `Type` is a Message-model ' + + 'type (a different enum from `NotificationType`) passed through unmapped, ' + + 'defaulting to 0; `Data` is the payload and is commonly empty.\n\n' + 'Answers the same `{ success, error }` envelope as the report / warning writes, ' + - '`error` an empty string on success. A hub failure is reported honestly as a 500 ' + - 'with `success: false` — with no store behind it, a swallowed error would be a ' + - 'silently dropped message.', + '`error` an empty string on success. A hub failure is still reported honestly as ' + + 'a 500 with `success: false` — the message is in their inbox, but the caller ' + + 'asked to send one and nothing was delivered.', security: AUTHED, requestBody: form(SendMessageRequest, 'The message'), responses: { @@ -338,15 +339,20 @@ export const socialRoutes = new Hono({ strict: false }) return c.json({ success: false, error: 'ToPlayerId is required' }, 400) } - // The Message the notification carries. Mirrors the coach message's shape with - // a real sender and recipient; `Data` stays a string, empty included (the hub - // drops only null/undefined from the frame). - const delivered = await pushMessage(c, { + // Stored FIRST, then pushed from the stored record: the row is the durable copy + // and its id is what goes on the frame. A hub failure after this point leaves + // the message in the recipient's inbox, which is the point of the store — they + // read it on next login instead of losing it. + const message = await createNotification(c.env.DB, { FromPlayerId: fromPlayerId, ToPlayerId: toPlayerId, Type: Number.parseInt(str(body.Type) ?? '', 10) || 0, + // `Data` stays a string, empty included — distinct from the null the types + // carrying no payload of their own send. Data: str(body.Data) ?? '', }) + + const delivered = await pushMessage(c, message) if (!delivered) { return c.json({ success: false, error: 'Failed to deliver message' }, 500) } @@ -367,15 +373,16 @@ export const socialRoutes = new Hono({ strict: false }) 'The bulk form of `POST /api/messages/v2/send`: pushes the same ' + '`MessageReceived` frame to every id in `ToPlayerIds`, each addressed to its own ' + 'recipient (`ToPlayerId` differs per frame — the payload is not shared). Same ' + - 'sender rule: the caller’s bearer token, never a body field. Same non-store: the ' + - 'notification is the whole delivery, queued by the hub for whoever is offline.\n\n' + + 'sender rule: the caller’s bearer token, never a body field. Stored the same way ' + + 'too — one notification per recipient, each with its own id.\n\n' + 'The body is JSON rather than the single send’s form encoding, so `Type` is a ' + 'number (still an unmapped Message-model type, defaulting to 0) and `Data` a ' + 'string, commonly empty. Repeated ids are delivered once.\n\n' + 'Answers the same `{ success, error }` envelope. Delivery is attempted for every ' + 'recipient even after one fails, but a hub failure for ANY of them is reported ' + - 'honestly as a 500 — the envelope has no room to say which, and with no store ' + - 'behind it a swallowed error would be a silently dropped message.', + 'honestly as a 500 — the envelope has no room to say which. Each recipient gets ' + + 'a stored notification of their own, with its own id, so an undelivered one is ' + + 'still waiting in that player’s inbox.', security: AUTHED, requestBody: jsonBody(SendMultipleMessagesRequest, 'The message and its recipients'), responses: { @@ -410,15 +417,19 @@ export const socialRoutes = new Hono({ strict: false }) const data = typeof body.Data === 'string' ? body.Data : '' // Every recipient is attempted even if an earlier one fails — the reachable - // players get their message either way. + // players get their message either way. One STORED message each, so every + // recipient's copy has its own id to be read and deleted by. const results = await Promise.all( - toPlayerIds.map((toPlayerId) => - pushMessage(c, { - FromPlayerId: fromPlayerId, - ToPlayerId: toPlayerId, - Type: type, - Data: data, - }) + toPlayerIds.map(async (toPlayerId) => + pushMessage( + c, + await createNotification(c.env.DB, { + FromPlayerId: fromPlayerId, + ToPlayerId: toPlayerId, + Type: type, + Data: data, + }) + ) ) ) if (results.includes(false)) { @@ -620,10 +631,30 @@ export const socialRoutes = new Hono({ strict: false }) describeRoute({ tags: ['Social'], summary: 'Direct messages', - description: 'There is no message store yet, so this is always an empty list.', - responses: { 200: json(JsonArray, 'An empty list') }, + description: + 'The caller’s inbox — everything sent to them that they haven’t deleted, newest ' + + 'first. The client reads this on login, which is what makes it the DURABLE half of ' + + 'a message: the `MessageReceived` frame the hub pushes is best-effort (dropped ' + + 'once sent rather than acked, bounded per player, and skipped whenever the hub ' + + 'believes it delivered), so a message that only ever existed as a frame could be ' + + 'lost with nothing left to show for it.\n\n' + + 'Each entry is the same Message the frame carries, `Id` included and equal — one ' + + 'message, delivered twice, which is how the client recognises the two as the ' + + 'same.\n\n' + + 'Auth-gated, unlike the empty list this used to answer: an inbox is the caller’s ' + + 'own. Capped at the newest `MAX_NOTIFICATIONS_PER_PLAYER`, which is also what the store ' + + 'keeps — the client shows an inbox, not an archive.', + security: AUTHED, + responses: { + 200: json(MessageDto.array(), 'The caller’s messages, newest first'), + 401: UNAUTHORIZED_RESPONSE, + }, }), - (c) => c.json([]) + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json(await getNotificationsForPlayer(c.env.DB, id)) + } ) // The inbox's delete button. A POST rather than a DELETE because the ids arrive as // a JSON array body — the client batches a multi-select into one call, and its HTTP @@ -634,18 +665,43 @@ export const socialRoutes = new Hono({ strict: false }) tags: ['Social'], summary: 'Delete messages', description: - 'Drops the given messages from the caller’s inbox. Nothing happens: there is no ' + - 'message store behind `GET /api/messages/v2/get` (the `MessageReceived` ' + - 'notification is the whole delivery — see `POST /api/messages/v2/send`), so ' + - 'there are no ids to match and nothing to remove.\n\n' + - 'Accepted unconditionally and answered 200 with an empty body, which is what the ' + - 'client wants: it removes the rows locally and re-reads the empty list either ' + - 'way. Not auth-gated, for the same reason `GET /api/messages/v2/get` isn’t — ' + - 'the call reaches no state to protect.', + 'Drops the given messages from the caller’s inbox — the ids being the `Id` on ' + + 'each message `GET /api/messages/v2/get` served.\n\n' + + 'Scoped to the CALLER’s inbox, not just to the ids: an id names a message ' + + 'globally, so matching on id alone would let anyone holding one clear somebody ' + + 'else’s inbox. An id that isn’t theirs, or is already gone, matches nothing — ' + + 'not an error, since the client removes its rows locally and re-reads the list ' + + 'either way.\n\n' + + 'Auth-gated now that there is a store behind it. The ids arrive either as a bare ' + + 'JSON array or under `MessageIds`; both are read. Answered 200 with an empty ' + + 'body, which is what the client wants.', + security: AUTHED, requestBody: jsonBody(DeleteMessagesRequest, 'The messages to delete'), - responses: { 200: { description: 'Accepted (empty body)' } }, + responses: { + 200: { description: 'Accepted (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + }, }), - (c) => c.body(null, 200) + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + // The client batches a multi-select into one call. It posts a bare array; the + // documented `{ MessageIds }` object is accepted too rather than guessing which + // of the two a given build sends. + const body = await c.req.json().catch(() => null) + const raw = Array.isArray(body) + ? body + : Array.isArray((body as { MessageIds?: unknown } | null)?.MessageIds) + ? (body as { MessageIds: unknown[] }).MessageIds + : [] + const ids = raw + .map((v) => (typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : Number.NaN)) + .filter((v) => Number.isInteger(v)) + + await deleteNotifications(c.env.DB, id, ids) + return c.body(null, 200) + } ) // How many of the caller's friends are online — the friends panel's header count. // Answered from the friend graph joined to live presence, so it agrees with the diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 305acc0..8bd3e87 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -15,6 +15,7 @@ import { LEVEL_REWARDS, MAX_LEVEL, MessageType, + NOTIFICATION_SCHEMA_DDL, OUTFIT_SCHEMA_DDL, PRESENCE_SCHEMA_DDL, PRESENCE_TTL_SECONDS, @@ -177,6 +178,10 @@ beforeAll(async () => { // Custom avatar items (owned by the api worker). for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run() + + // The message store (owned by the api worker) — the inbox reads it and every send + // writes to it. + for (const stmt of NOTIFICATION_SCHEMA_DDL) await env.DB.prepare(stmt).run() }) // Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the @@ -5996,7 +6001,17 @@ describe('messages', () => { type Sent = { playerId: number notificationType: number - data: { FromPlayerId: number; ToPlayerId: number; Type: number; Data: string } + // The frame carries the STORED message, so `Id` and `SentTime` come off the row. + data: { + Id: number + FromPlayerId: number + ToPlayerId: number + SentTime: string + Type: number + Data: string | null + RoomId: number | null + PlayerEventId: number | null + } } const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') const pushed = async (): Promise => @@ -6014,26 +6029,60 @@ describe('messages', () => { // NotificationType.MessageReceived — the same frame the Coach broadcast uses. const MESSAGE_RECEIVED = 2 + /** One player's inbox, as the client reads it on login. */ + const inboxOf = async (playerId: string) => + (await ( + await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`, { + headers: await bearer(playerId), + }) + ).json()) as Array<{ + Id: number + FromPlayerId: number + ToPlayerId: number + SentTime: string + Type: number + Data: string | null + }> + + const del = async (body: unknown, headers?: Record) => + exports.default.fetch(`${ORIGIN}/api/messages/v3/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body), + }) + test('POST /api/messages/v2/send pushes MessageReceived to the recipient', async () => { const res = await send({ ToPlayerId: '2', Type: '10', Data: '' }, await bearer('42')) expect(res.status).toBe(200) expect(await res.json()).toEqual({ success: true, error: '' }) - expect(await pushed()).toEqual([ - { - // Delivered to the recipient, not the sender. - playerId: 2, - notificationType: MESSAGE_RECEIVED, - // FromPlayerId is the token's subject, not a body field. - data: { FromPlayerId: 42, ToPlayerId: 2, Type: 10, Data: '' }, - }, - ]) + const sent = await pushed() + expect(sent).toHaveLength(1) + // Delivered to the recipient, not the sender. + expect(sent[0]?.playerId).toBe(2) + expect(sent[0]?.notificationType).toBe(MESSAGE_RECEIVED) + // FromPlayerId is the token's subject, not a body field. The frame carries the + // STORED message, so it has the row's Id and SentTime on it too. + expect(sent[0]?.data).toMatchObject({ + FromPlayerId: 42, + ToPlayerId: 2, + Type: 10, + Data: '', + RoomId: null, + PlayerEventId: null, + }) + const frameId = sent[0]!.data.Id + expect(frameId).toBeGreaterThan(0) + + // ...and the recipient reads the same message, under the same id, from their inbox. + const inbox = await inboxOf('2') + expect(inbox[0]).toMatchObject({ Id: frameId, FromPlayerId: 42, Type: 10, Data: '' }) }) test('POST /api/messages/v2/send defaults Type and Data when omitted', async () => { const res = await send({ ToPlayerId: '2' }, await bearer('42')) expect(res.status).toBe(200) - expect((await pushed())[0]?.data).toEqual({ + expect((await pushed())[0]?.data).toMatchObject({ FromPlayerId: 42, ToPlayerId: 2, Type: 0, @@ -6073,18 +6122,17 @@ describe('messages', () => { expect(await res.json()).toEqual({ success: true, error: '' }) // Each frame is addressed to its own recipient; the sender is the token's subject. - expect(await pushed()).toEqual([ - { - playerId: 205, - notificationType: MESSAGE_RECEIVED, - data: { FromPlayerId: 42, ToPlayerId: 205, Type: 20, Data: 'hi' }, - }, - { - playerId: 206, - notificationType: MESSAGE_RECEIVED, - data: { FromPlayerId: 42, ToPlayerId: 206, Type: 20, Data: 'hi' }, - }, - ]) + const sent = await pushed() + expect(sent.map((n) => n.playerId)).toEqual([205, 206]) + expect(sent.map((n) => n.notificationType)).toEqual([MESSAGE_RECEIVED, MESSAGE_RECEIVED]) + expect(sent[0]?.data).toMatchObject({ FromPlayerId: 42, ToPlayerId: 205, Type: 20, Data: 'hi' }) + expect(sent[1]?.data).toMatchObject({ FromPlayerId: 42, ToPlayerId: 206, Type: 20, Data: 'hi' }) + + // One STORED message each, with ids of their own — not one message shared. + const ids = sent.map((n) => n.data.Id) + expect(new Set(ids).size).toBe(2) + expect((await inboxOf('205'))[0]).toMatchObject({ Id: ids[0], Data: 'hi' }) + expect((await inboxOf('206'))[0]).toMatchObject({ Id: ids[1], Data: 'hi' }) }) test('POST /api/messages/v1/sendMultiple defaults Type and Data, and de-duplicates ids', async () => { @@ -6093,7 +6141,7 @@ describe('messages', () => { const sent = await pushed() expect(sent).toHaveLength(1) - expect(sent[0]?.data).toEqual({ FromPlayerId: 42, ToPlayerId: 205, Type: 0, Data: '' }) + expect(sent[0]?.data).toMatchObject({ FromPlayerId: 42, ToPlayerId: 205, Type: 0, Data: '' }) }) test('POST /api/messages/v1/sendMultiple 400s with no usable recipient, pushing nothing', async () => { @@ -6111,19 +6159,66 @@ describe('messages', () => { expect(await pushed()).toEqual([]) }) - test('POST /api/messages/v3/delete accepts anything with an empty 200', async () => { - // No message store, so no id can be real and nothing is gated — an unknown id, an - // empty list and a missing body all land the same way. - for (const body of [{ MessageIds: [1787377235629] }, { MessageIds: [] }, {}]) { - const res = await exports.default.fetch(`${ORIGIN}/api/messages/v3/delete`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) + test('GET /api/messages/v2/get serves the caller’s inbox, newest first', async () => { + // Two messages to 9600, one to somebody else — an inbox is the caller's own. + await send({ ToPlayerId: '9600', Data: 'first' }, await bearer('42')) + await send({ ToPlayerId: '9600', Data: 'second' }, await bearer('43')) + await send({ ToPlayerId: '9601', Data: 'not yours' }, await bearer('42')) + + const inbox = await inboxOf('9600') + expect(inbox.map((m) => m.Data)).toEqual(['second', 'first']) + expect(inbox[0]).toMatchObject({ FromPlayerId: 43, ToPlayerId: 9600, Type: 0 }) + // Every message carries the full Message model, ids and all. + expect(Date.parse(inbox[0].SentTime)).not.toBeNaN() + expect(inbox.every((m) => m.Id > 0)).toBe(true) + // The other player's message is not in it. + expect(inbox.some((m) => m.Data === 'not yours')).toBe(false) + }) + + test('GET /api/messages/v2/get is auth-gated', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`) + expect(res.status).toBe(401) + }) + + test('POST /api/messages/v3/delete removes only the caller’s messages', async () => { + await send({ ToPlayerId: '9610', Data: 'keep' }, await bearer('42')) + await send({ ToPlayerId: '9610', Data: 'drop' }, await bearer('42')) + await send({ ToPlayerId: '9611', Data: 'someone else' }, await bearer('42')) + + const before = await inboxOf('9610') + const drop = before.find((m) => m.Data === 'drop')! + const theirs = (await inboxOf('9611'))[0] + + // The caller deletes their own message, and takes a swing at another player's id + // in the same call — an id names a message globally, so this must not reach it. + const res = await del({ MessageIds: [drop.Id, theirs.Id] }, await bearer('9610')) + expect(res.status).toBe(200) + expect(await res.text()).toBe('') + + expect((await inboxOf('9610')).map((m) => m.Data)).toEqual(['keep']) + expect((await inboxOf('9611')).map((m) => m.Data)).toEqual(['someone else']) + }) + + test('POST /api/messages/v3/delete takes a bare array, and shrugs off the rest', async () => { + await send({ ToPlayerId: '9620', Data: 'bare' }, await bearer('42')) + const message = (await inboxOf('9620'))[0] + + // The client posts a bare array; the documented `{ MessageIds }` object works too. + expect((await del([message.Id], await bearer('9620'))).status).toBe(200) + expect(await inboxOf('9620')).toEqual([]) + + // An unknown id, an empty list and a missing body are all an empty 200 — the client + // removes its rows locally and re-reads the list either way. + for (const body of [{ MessageIds: [1787377235629] }, { MessageIds: [] }, {}, []]) { + const res = await del(body, await bearer('9620')) expect(res.status).toBe(200) expect(await res.text()).toBe('') } }) + + test('POST /api/messages/v3/delete is auth-gated', async () => { + expect((await del({ MessageIds: [1] })).status).toBe(401) + }) }) describe('mutual friends', () => { @@ -6740,9 +6835,7 @@ describe('player events', () => { upcoming.PlayerEventId, ]) // The same base projection the POST serves: one path, one shape. - expect(events.find((e) => e.PlayerEventId === upcoming.PlayerEventId)).toEqual( - asBase(upcoming) - ) + expect(events.find((e) => e.PlayerEventId === upcoming.PlayerEventId)).toEqual(asBase(upcoming)) // No ids is an empty list, not every event. expect(await (await get('/api/playerevents/v1/bulk')).json()).toEqual([]) diff --git a/apps/notify/package.json b/apps/notify/package.json index 1574289..31f0382 100644 --- a/apps/notify/package.json +++ b/apps/notify/package.json @@ -15,6 +15,7 @@ "test": "run-vitest" }, "dependencies": { + "@repo/domain": "workspace:*", "@repo/hono-helpers": "workspace:*", "@repo/jwt": "workspace:*", "hono": "4.13.5", diff --git a/apps/notify/src/context.ts b/apps/notify/src/context.ts index 31f265c..5163acf 100644 --- a/apps/notify/src/context.ts +++ b/apps/notify/src/context.ts @@ -5,6 +5,12 @@ import type { NotificationsHub } from './notifications-hub' export type Env = SharedHonoEnv & { /** Durable Object hosting the SignalR notifications hub. */ RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace + /** + * 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 * bind the same store so tokens signed by `auth` verify here. Used to gate the diff --git a/apps/notify/src/notification-payloads.ts b/apps/notify/src/notification-payloads.ts index f7bff86..3452e97 100644 --- a/apps/notify/src/notification-payloads.ts +++ b/apps/notify/src/notification-payloads.ts @@ -236,7 +236,12 @@ export interface MessageReceivedPayload { /** ISO-8601. */ SentTime: string 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 PlayerEventId: number | null } diff --git a/apps/notify/src/notifications-hub.ts b/apps/notify/src/notifications-hub.ts index ae70fe7..73abd4f 100644 --- a/apps/notify/src/notifications-hub.ts +++ b/apps/notify/src/notifications-hub.ts @@ -115,11 +115,15 @@ export interface HubState { */ 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). */ -const COACH_MESSAGE_TYPE = 100 +export const COACH_MESSAGE_TYPE = 100 export class NotificationsHub extends DurableObject { constructor(ctx: DurableObjectState, env: Env) { @@ -131,7 +135,7 @@ export class NotificationsHub extends DurableObject { // way — it just leaves a trace when it happens. ctx .blockConcurrencyWhile(async () => { - this.ctx.storage.sql.exec(` + this.ctx.storage.sql.exec(` CREATE TABLE IF NOT EXISTS subscriptions ( connectionId TEXT NOT NULL, playerId INTEGER NOT NULL @@ -550,20 +554,26 @@ export class NotificationsHub extends DurableObject { * the Coach account (player 1), addressed to one player with a `ToPlayerId` — the shape * `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 * {@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 - * next connect. + * hold anything for — and, for the same reason, nothing to store: a message written to + * a named player is worth keeping until they next connect. */ - async coachMessage( - playerId: number, - content: string - ): Promise<{ delivered: number; queued: boolean }> { - return this.notifyPlayer(playerId, NotificationType.MessageReceived, { - FromPlayerId: COACH_PLAYER_ID, - ToPlayerId: playerId, - Type: COACH_MESSAGE_TYPE, - Data: content, + async coachMessage(message: { + Id: number + FromPlayerId: number + ToPlayerId: number + SentTime: string + Type: number + Data: string | null + }): Promise<{ delivered: number; queued: boolean }> { + return this.notifyPlayer(message.ToPlayerId, NotificationType.MessageReceived, { + ...message, }) } diff --git a/apps/notify/src/notify.app.ts b/apps/notify/src/notify.app.ts index ba3f872..a866f8a 100644 --- a/apps/notify/src/notify.app.ts +++ b/apps/notify/src/notify.app.ts @@ -1,10 +1,16 @@ import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' +import { createNotification } from '@repo/domain' import { logger, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers' 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 { App } from './context' @@ -228,9 +234,13 @@ const app = new Hono() return c.json({ success: true, ...result }) }) - // The targeted form of the broadcast above: one coach message to ONE player. Queued - // by the hub when they're offline (unlike coach-message-all, which reaches only - // whoever is connected), so this arrives either way. + // The targeted form of the broadcast above: one coach message to ONE player. + // + // 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) => { const body = await c.req .json<{ playerId?: number; messageContent?: string }>() @@ -240,9 +250,15 @@ const app = new Hono() return c.json({ error: 'playerId 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)) - return c.json({ success: true, ...result }) + + const notification = await createNotification(c.env.DB, { + 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 diff --git a/apps/notify/src/test/integration/api.test.ts b/apps/notify/src/test/integration/api.test.ts index ddb63f7..9916cda 100644 --- a/apps/notify/src/test/integration/api.test.ts +++ b/apps/notify/src/test/integration/api.test.ts @@ -2,11 +2,13 @@ import { adminSecretsStore, env, runInDurableObject } from 'cloudflare:test' import { exports } from 'cloudflare:workers' import { beforeAll, describe, expect, test } from 'vitest' +import { NOTIFICATION_SCHEMA_DDL } from '@repo/domain' + import '../../notify.app' -import type { Env } from '../../context' import { MAX_PENDING_PER_PLAYER } from '../../notifications-hub' +import type { Env } from '../../context' import type { HubState } from '../../notifications-hub' 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. beforeAll(async () => { 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. */ @@ -402,6 +407,43 @@ describe('notification delivery', () => { 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 () => { expect((await post('/internal/coach-message', { messageContent: 'nobody' })).status).toBe(400) expect((await post('/internal/coach-message', { playerId: 9012 })).status).toBe(400) diff --git a/apps/notify/wrangler.jsonc b/apps/notify/wrangler.jsonc index f354acd..b3c8471 100644 --- a/apps/notify/wrangler.jsonc +++ b/apps/notify/wrangler.jsonc @@ -8,6 +8,21 @@ "bindings": [{ "name": "RECFLARE_NOTIFICATIONS_HUB", "class_name": "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 // 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. diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index 9426ecb..eeb29d9 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -400,6 +400,15 @@ export const MissingLookupParam = z .string() .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. * @@ -571,9 +580,20 @@ export const ImageRequest = z.object({ 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({ - 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. */ diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 32c4a25..d363658 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { Accessibility, + answerRoomRoleInvite, applyRoomTagEdit, areFriends, autocompleteRoomSearch, @@ -12,6 +13,7 @@ import { cloneRoom, cloneSubRoom, countRoomsByCreator, + createNotification, createSubRoom, deleteRoom, deleteRoomLeaderboard, @@ -24,6 +26,7 @@ import { getHotRooms, getInteraction, getOrCreateDormRoom, + getPlayerIdsInRoom, getPresence, getPublicRoomsByCreator, getRecommendedRooms, @@ -38,12 +41,17 @@ import { getSubRoomSaves, getTrendingRooms, getVisitedRooms, + inviteRoomRole, isPlayerBannedFromRoom, + isRoomOwner, + MessageType, modifySubRoom, publishSubRoomSave, removeCheer, removeFavorite, + Role, roomNameRejection, + roomRoles, saveSubRoomData, searchRooms, setRoomDescription, @@ -88,6 +96,7 @@ import { ImageRequest, InteractionDto, intQuery, + InviteRoleRequest, IsBannedEnvelope, IsBannedPascalEnvelope, json, @@ -118,6 +127,7 @@ import { roomIdParam, RoomLookup, RoomResultEnvelope, + RoomRoleDto, RoomSaveEnvelope, saveIdParam, SaveSubRoomDataRequest, @@ -131,6 +141,7 @@ import { SubRoomPermissionsRequest, SubRoomSavesNoUnityAssetsPage, SubRoomSavesPage, + SuccessEnvelope, TagRequest, TooManyLookupIds, UNAUTHORIZED_EMPTY, @@ -141,6 +152,7 @@ import { import type { Context } from 'hono' import type { RoomBan, RoomPermission } from '@repo/domain' +import type { MessageReceivedPayload } from '../../notify/src/notification-payloads' 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, + roomId: number, + room: Record, + alsoNotify: number[] = [] +): Promise { + 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, + roomId: number, + fromAccountId: number, + toAccountId: number, + role: number +): Promise { + 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 * action a person took rather than one the system inferred, which is what a room ban @@ -1942,23 +2043,83 @@ const app = new Hono() } ) - // Set a member's role in a room (`Roles[].Role`). Auth-gated (401) and gated to - // the room creator or a co-owner (403 otherwise) — the same owner/co-owner check - // the other room-admin actions use. Body is the `role` form field (an integer role - // tier). Updates the target account's existing role entry or adds one, notifies the - // affected member so their client refreshes permissions, and returns the updated - // room in the lowercase `{ success, error, value }` envelope. + // A room's role list — the same `Roles` the room DTO already carries, served on its own + // so the client can refresh the member list without re-reading the whole room. Public, + // like the room itself, and a BARE array rather than the room-write envelope. An unknown + // room is an empty list. + .get( + '/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( '/rooms/:roomId{[0-9]+}/roles/:accountId{[0-9]+}', describeRoute({ tags: ['Room settings'], - summary: 'Set a member’s role in a room', + summary: 'Set a player’s role in a room', description: [ - 'Updates the target account’s entry in the room’s `Roles` (or adds one). Gated to the', - 'room’s creator or a co-owner — a valid token from anyone else is a 403. The affected', - 'MEMBER gets the `RoomUpdate` push, not the caller, so their client refreshes the', - 'permissions it just gained or lost.', - ].join(' '), + 'Two callers share this path, told apart by whether the `accountId` in it is the', + 'caller’s own.', + '', + '**The room’s owner or a co-owner, granting a role to someone else.** Takes effect', + '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, parameters: [ roomIdParam, @@ -1966,11 +2127,11 @@ const app = new Hono() name: 'accountId', in: 'path', 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]+$' }, }, ], - requestBody: form(RoleRequest, 'The role tier to grant'), + requestBody: form(RoleRequest, 'The role tier, or 0 to decline an invite'), responses: { 200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'), 401: UNAUTHORIZED_RESPONSE, @@ -1985,22 +2146,107 @@ const app = new Hono() const targetAccountId = Number.parseInt(c.req.param('accountId'), 10) const room = await getRoomById(c.env.DB, roomId) 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 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!') + // 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) - // Notify the member whose role changed so their client refreshes the room - // (and the permissions it grants them). - await pushRoomUpdate(c, targetAccountId, updated) + if (!updated) return roomEnvelope(c, null, 'Co-ownership must be invited, not granted!') + + await pushRoomUpdateToRoom(c, roomId, updated, [targetAccountId]) 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 + 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 // 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. diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index aeaaa32..c031e8c 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -6,6 +6,8 @@ import '../../rooms.app' import { createRoomInstance, getRoomInstance, + MessageType, + NOTIFICATION_SCHEMA_DDL, PRESENCE_SCHEMA_DDL, ROOM_INSTANCE_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() // Presence table (read by the photon access-token handler). 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). for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record) @@ -1629,49 +1634,373 @@ describe('rooms endpoints', () => { expect(interactions!.n).toBe(0) }) - it('PUT /rooms/:id/roles/:accountId is auth-gated, owner/co-owner-only, and persists', async () => { - const rolesOf = async (): Promise> => { - const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { - Roles?: Array<{ AccountId: number; Role: number }> - } - return room.Roles ?? [] + it('PUT /rooms/:id/roles/:accountId grants a helper role outright, but never co-owner', async () => { + type Role = { + AccountId: number + Role: number + InvitedRole: number + LastChangedByAccountId: number | null } + const roleOf = async (accountId: number): Promise => + ((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 => + (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 => + ((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). - expect((await putForm('/rooms/2/roles/5', { role: '20' })).status).toBe(401) - // A valid token but no role on the room (RecCenter is owned by account 1, with - // account 2 as co-owner) → 403. - expect((await putForm('/rooms/2/roles/5', { role: '20' }, '999')).status).toBe(403) + expect((await putForm('/rooms/2/roles/850', { role: '30' })).status).toBe(401) + // Nobody can accept on 850's behalf. A stranger has no standing here at all (403); + // the owner and the co-owner fall through to the GRANT path, where co-ownership is + // 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. - expect(await envOf(await putForm('/rooms/99999/roles/5', { role: '20' }, '1'))).toMatchObject({ - success: false, - error: 'This room does not exist!', - }) + expect( + await envOf(await putForm('/rooms/99999/roles/850', { role: '30' }, '850')) + ).toMatchObject({ success: false, error: 'This room does not exist!' }) // 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, }) + 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 - // success envelope carries the updated room as `value`. - const ok = await putForm('/rooms/2/roles/5', { role: '20' }, '1') + // A player with no entry in the room at all is the same rejection — probing tells + // them nothing. + 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) const okBody = await envOf(ok) expect(okBody).toMatchObject({ success: true, error: '' }) - expect(okBody.value?.Roles as Array<{ AccountId: number; Role: number }>).toContainEqual( - expect.objectContaining({ AccountId: 5, Role: 20 }) + expect(okBody.value?.Roles as Role[]).toContainEqual( + 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 - // entry in place rather than adding a duplicate. - const byCoOwner = await putForm('/rooms/2/roles/5', { role: '10' }, '2') - expect(byCoOwner.status).toBe(200) - const roles = await rolesOf() - 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. - expect(roles).toContainEqual(expect.objectContaining({ AccountId: 2, Role: 30 })) + // The invite is spent: the same call again is refused, so it can't be replayed. + expect(await envOf(await putForm('/rooms/2/roles/850', { role: '30' }, '850'))).toMatchObject({ + success: false, + }) + expect(await roleOf(850)).toMatchObject({ Role: 30, InvitedRole: 0 }) + }) + + it('PUT /rooms/:id/roles/:accountId with role=0 declines, dropping the entry', async () => { + type Role = { AccountId: number; Role: number; InvitedRole: number } + const roleOf = async (accountId: number): Promise => + ((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 => + (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 + } + const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + const sent = async (): Promise => + (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 () => { @@ -4100,6 +4429,7 @@ describe('rooms endpoints', () => { 'GET /rooms/{roomId}/experience/player', 'GET /rooms/{roomId}/interactionby/me', 'GET /rooms/{roomId}/playerdata/me', + 'GET /rooms/{roomId}/roles', 'GET /rooms/{roomId}/similar', 'GET /rooms/{roomId}/subrooms/{subRoomId}/saves', 'GET /rooms/{roomId}/subrooms/{subRoomId}/saves/no_unity_assets', @@ -4124,6 +4454,7 @@ describe('rooms endpoints', () => { 'PUT /rooms/{roomId}/name', 'PUT /rooms/{roomId}/restrictions', 'PUT /rooms/{roomId}/roles/{accountId}', + 'PUT /rooms/{roomId}/roles/{accountId}/invite', 'PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility', 'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify', 'PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions', diff --git a/packages/domain/src/enums.ts b/packages/domain/src/enums.ts index 0167214..957fd61 100644 --- a/packages/domain/src/enums.ts +++ b/packages/domain/src/enums.ts @@ -203,8 +203,13 @@ export enum MatchmakingErrorCode { * and Creator are the owner-level tiers that may manage the room (see * {@link canManageRoom}). Creator is the room's owner (its `CreatorAccountId`) — * 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 { + None = 0, Host = 10, Moderator = 20, CoOwner = 30, diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 5c5af58..65f2af1 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -19,6 +19,7 @@ export * from './stats-db' export * from './gifts-db' export * from './inventory-invention-db' export * from './lists-db' +export * from './notifications-db' export * from './outfits-db' export * from './progression-db' export * from './relationships-db' diff --git a/packages/domain/src/notifications-db.ts b/packages/domain/src/notifications-db.ts new file mode 100644 index 0000000..79d2da9 --- /dev/null +++ b/packages/domain/src/notifications-db.ts @@ -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 { + const [inserted] = await db.batch([ + 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 { + 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() + + 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 { + 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 +} diff --git a/packages/domain/src/presence-db.ts b/packages/domain/src/presence-db.ts index d61929e..874e9e4 100644 --- a/packages/domain/src/presence-db.ts +++ b/packages/domain/src/presence-db.ts @@ -204,6 +204,33 @@ export async function getPlayerIdsInInstance( 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 { + 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 * the player count a status page means. Counts unexpired presence only: rows outlive diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 10cd012..6d2c8f1 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -184,13 +184,30 @@ export const SUBROOM_SCHEMA_DDL: string[] = [ export type Room = Record /** A room role assignment (the client's RoomRole shape). */ -interface RoomRole { +export interface RoomRole { AccountId: number Role: number LastChangedByAccountId: number | null 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>) : [] + 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 * CoOwner (30). The reference gates its room-admin actions on this set. (Host and @@ -207,8 +224,7 @@ const MANAGE_ROLES: ReadonlySet = new Set([Role.Creator, Role.CoOwner]) */ export function canManageRoom(room: Room, accountId: number): boolean { if (room.CreatorAccountId === accountId) return true - const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : [] - return roles.some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role)) + return roomRoles(room).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 { if (room.CreatorAccountId === accountId) return true - const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : [] - return roles.some((r) => r.AccountId === accountId && r.Role >= Role.Moderator) + return roomRoles(room).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). */ @@ -414,7 +441,12 @@ export async function cloneRoom( // 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. 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 = { @@ -509,10 +541,37 @@ export async function updateRoomFields( } /** - * Set a target account's room `Role` — updating their existing `Roles` entry or - * appending a new one — and stamp `LastChangedByAccountId` with the editor. The - * caller supplies the already-loaded room (after its owner/co-owner check) to avoid - * a re-read; the whole room JSON is rewritten. Returns the updated room. + * Whether a role tier can only be taken by INVITATION — offered with + * {@link inviteRoomRole} and accepted by the player themselves via + * {@link answerRoomRoleInvite} — rather than granted outright. + * + * {@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( db: D1Database, @@ -521,8 +580,10 @@ export async function setRoomRole( role: number, changedByAccountId: number, room: Room -): Promise { - const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : [] +): Promise { + if (roleRequiresInvite(role)) return null + + const roles = roomRoles(room) const existing = roles.find((r) => r.AccountId === targetAccountId) if (existing) { existing.Role = role @@ -532,7 +593,109 @@ export async function setRoomRole( AccountId: targetAccountId, Role: role, 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 { + 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 { + 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 } @@ -2915,7 +3078,12 @@ export async function getOrCreateDormRoom(db: D1Database, accountId: number): Pr CreatorAccountId: accountId, IsDorm: true, 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). Stats: storedStats(template?.Stats), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6645d09..fcab0c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -939,6 +939,9 @@ importers: apps/notify: dependencies: + '@repo/domain': + specifier: workspace:* + version: link:../../packages/domain '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers