Compare commits

..

1 Commits

Author SHA1 Message Date
Devin Zuczek 7eab298ba1 [rooms] role refactor 2026-09-09 17:28:24 -04:00
21 changed files with 379 additions and 1552 deletions
-46
View File
@@ -1,46 +0,0 @@
-- Stored notifications — the delayed half of a WebSocket push, served back by
-- `GET /api/messages/v2/get` when a player logs in. Owned by the `api` worker; also written
-- by `rooms` (a room-role invite) and `notify` (a coach message to one player), which push
-- the matching `MessageReceived` frame. Generated from
-- packages/domain/src/notifications-db.ts (NOTIFICATION_SCHEMA_DDL) — keep in sync.
--
-- Until this table existed the inbox endpoint answered an empty list unconditionally and the
-- hub's queued frame was the whole of a notification sent to an offline player. That queue is
-- best-effort by construction — dropped once sent rather than acked, bounded per player, and
-- skipped whenever the hub believes it delivered (a socket the client has abandoned still
-- counts) — so a push could be lost with nothing left to show for it.
--
-- `notification_id` is what ties the two halves together: it is the `Id` on the frame that
-- delivers the notification, so the client can match what it was pushed to what it later
-- reads from the inbox, and `POST /api/messages/v3/delete` has an id to match. Senders write
-- the row FIRST and build the frame from it. AUTOINCREMENT rather than a bare rowid alias
-- because the id is handed to the client and deleting a row frees its rowid — a reused id
-- would point a client's stale entry at somebody else's notification.
--
-- Named `notification` and not `message`: the `chat` worker already owns a `message` table on
-- this same shared database (its thread posts). `CREATE TABLE IF NOT EXISTS` against a name
-- already taken does nothing and says nothing, so the first draft of this migration silently
-- adopted chat's table and failed only on the index below, over a column it doesn't have.
--
-- Deliberately NOT stored, because none of it is worth reading later: the coach BROADCAST (a
-- maintenance notice to everyone online at once — a row per player, thousands per send), the
-- vote-to-kick prompt (about a session that has since ended), and a cheer (its durable record
-- is the `reputation` counters; the Message is the part that plays in front of the room).
--
-- `data` is nullable because a Message's `Data` is: the types carrying no payload of their
-- own send null, distinct from the empty string a DM sends. Where set it is always a STRING,
-- whatever it means to that type (a room-role invite puts the offered role tier in it).
CREATE TABLE IF NOT EXISTS notification (
notification_id INTEGER PRIMARY KEY AUTOINCREMENT,
from_player_id INTEGER NOT NULL,
to_player_id INTEGER NOT NULL,
type INTEGER NOT NULL,
data TEXT,
room_id INTEGER,
player_event_id INTEGER,
sent_time TEXT NOT NULL
);
-- The inbox read and the newest-wins trim are both "this player's notifications, newest
-- first", which is the whole access pattern.
CREATE INDEX IF NOT EXISTS idx_notification_recipient ON notification (to_player_id, notification_id DESC);
-17
View File
@@ -234,23 +234,6 @@ 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.
+55 -111
View File
@@ -5,11 +5,8 @@ import {
acceptFriendRequest,
addFriend,
countOnlineFriends,
createNotification,
deleteNotifications,
getAccountsByIds,
getMutualFriendIds,
getNotificationsForPlayer,
getRelationshipsForPlayer,
MUTUAL_FRIENDS_LIMIT,
removeFriend,
@@ -33,7 +30,6 @@ import {
json,
JsonArray,
jsonBody,
MessageDto,
MutualFriendDto,
RelationshipDto,
SendMessageRequest,
@@ -47,7 +43,6 @@ import type {
RelationshipChange,
RelationshipFlag,
RelationshipResponse,
StoredNotification,
} from '@repo/domain'
import type { App } from '../context'
@@ -55,26 +50,31 @@ import type { App } from '../context'
const HUB_INSTANCE = 'global'
/**
* 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.
* The Message a `MessageReceived` frame carries. A type alias rather than an interface:
* `notifyPlayer` takes an index-signature record, which only aliases satisfy implicitly.
*/
async function pushMessage(c: Context<App>, message: StoredNotification): Promise<boolean> {
type Message = {
FromPlayerId: number
ToPlayerId: number
Type: number
Data: string
}
/**
* Push one `MessageReceived` frame, resolving false when the hub could not be reached.
* Unlike the relationship pushes, a failure here is NOT swallowed by the caller: there
* is no message store behind this, so the notification is the whole delivery.
*/
async function pushMessage(c: Context<App>, message: Message): Promise<boolean> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
message.ToPlayerId,
NotificationType.MessageReceived,
{ ...message }
message
)
return true
} catch (err) {
logger.error('failed to push MessageReceived notification', {
notificationId: message.Id,
toPlayerId: message.ToPlayerId,
error: err instanceof Error ? err.message : String(err),
})
@@ -296,8 +296,8 @@ export const socialRoutes = new Hono<App>({ strict: false })
)
// A message from one player to another — the "invite me!" style prompts the client
// sends. STORED as a notification, then pushed to the recipient's hub connection from
// the stored record, so it survives a push they never hear.
// 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).
.post(
'/api/messages/v2/send',
describeRoute({
@@ -309,16 +309,15 @@ export const socialRoutes = new Hono<App>({ strict: false })
'`coachMessageAll`), except `FromPlayerId` is the caller rather than the Coach ' +
'account and it goes to one player. The hub queues it when the recipient is ' +
'offline, so it arrives on their next connect.\n\n' +
'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' +
'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' +
'Answers the same `{ success, error }` envelope as the report / warning writes, ' +
'`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.',
'`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.',
security: AUTHED,
requestBody: form(SendMessageRequest, 'The message'),
responses: {
@@ -339,20 +338,15 @@ export const socialRoutes = new Hono<App>({ strict: false })
return c.json({ success: false, error: 'ToPlayerId is required' }, 400)
}
// 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, {
// 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, {
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)
}
@@ -373,16 +367,15 @@ export const socialRoutes = new Hono<App>({ strict: false })
'The bulk form of `POST /api/messages/v2/send`: pushes the same ' +
'`MessageReceived` frame to every id in `ToPlayerIds`, each addressed to its own ' +
'recipient (`ToPlayerId` differs per frame — the payload is not shared). Same ' +
'sender rule: the caller’s bearer token, never a body field. Stored the same way ' +
'too — one notification per recipient, each with its own id.\n\n' +
'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' +
'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. 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.',
'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.',
security: AUTHED,
requestBody: jsonBody(SendMultipleMessagesRequest, 'The message and its recipients'),
responses: {
@@ -417,19 +410,15 @@ export const socialRoutes = new Hono<App>({ strict: false })
const data = typeof body.Data === 'string' ? body.Data : ''
// Every recipient is attempted even if an earlier one fails — the reachable
// players get their message either way. One STORED message each, so every
// recipient's copy has its own id to be read and deleted by.
// players get their message either way.
const results = await Promise.all(
toPlayerIds.map(async (toPlayerId) =>
pushMessage(
c,
await createNotification(c.env.DB, {
FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId,
Type: type,
Data: data,
})
)
toPlayerIds.map((toPlayerId) =>
pushMessage(c, {
FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId,
Type: type,
Data: data,
})
)
)
if (results.includes(false)) {
@@ -631,30 +620,10 @@ export const socialRoutes = new Hono<App>({ strict: false })
describeRoute({
tags: ['Social'],
summary: 'Direct messages',
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,
},
description: 'There is no message store yet, so this is always an empty list.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getNotificationsForPlayer(c.env.DB, id))
}
(c) => c.json([])
)
// 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
@@ -665,43 +634,18 @@ export const socialRoutes = new Hono<App>({ strict: false })
tags: ['Social'],
summary: 'Delete messages',
description:
'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,
'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.',
requestBody: jsonBody(DeleteMessagesRequest, 'The messages to delete'),
responses: {
200: { description: 'Accepted (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
responses: { 200: { description: 'Accepted (empty body)' } },
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// The client batches a multi-select into one call. It posts a bare array; the
// documented `{ MessageIds }` object is accepted too rather than guessing which
// of the two a given build sends.
const body = await c.req.json<unknown>().catch(() => null)
const raw = Array.isArray(body)
? body
: Array.isArray((body as { MessageIds?: unknown } | null)?.MessageIds)
? (body as { MessageIds: unknown[] }).MessageIds
: []
const ids = raw
.map((v) => (typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : Number.NaN))
.filter((v) => Number.isInteger(v))
await deleteNotifications(c.env.DB, id, ids)
return c.body(null, 200)
}
(c) => 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
+36 -129
View File
@@ -15,7 +15,6 @@ import {
LEVEL_REWARDS,
MAX_LEVEL,
MessageType,
NOTIFICATION_SCHEMA_DDL,
OUTFIT_SCHEMA_DDL,
PRESENCE_SCHEMA_DDL,
PRESENCE_TTL_SECONDS,
@@ -178,10 +177,6 @@ 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
@@ -6001,17 +5996,7 @@ describe('messages', () => {
type Sent = {
playerId: number
notificationType: number
// 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
}
data: { FromPlayerId: number; ToPlayerId: number; Type: number; Data: string }
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const pushed = async (): Promise<Sent[]> =>
@@ -6029,60 +6014,26 @@ describe('messages', () => {
// NotificationType.MessageReceived — the same frame the Coach broadcast uses.
const MESSAGE_RECEIVED = 2
/** One player's inbox, as the client reads it on login. */
const inboxOf = async (playerId: string) =>
(await (
await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`, {
headers: await bearer(playerId),
})
).json()) as Array<{
Id: number
FromPlayerId: number
ToPlayerId: number
SentTime: string
Type: number
Data: string | null
}>
const del = async (body: unknown, headers?: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/messages/v3/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
})
test('POST /api/messages/v2/send pushes MessageReceived to the recipient', async () => {
const res = await send({ ToPlayerId: '2', Type: '10', Data: '' }, await bearer('42'))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
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: '' })
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: '' },
},
])
})
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).toMatchObject({
expect((await pushed())[0]?.data).toEqual({
FromPlayerId: 42,
ToPlayerId: 2,
Type: 0,
@@ -6122,17 +6073,18 @@ 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.
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' })
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' },
},
])
})
test('POST /api/messages/v1/sendMultiple defaults Type and Data, and de-duplicates ids', async () => {
@@ -6141,7 +6093,7 @@ describe('messages', () => {
const sent = await pushed()
expect(sent).toHaveLength(1)
expect(sent[0]?.data).toMatchObject({ FromPlayerId: 42, ToPlayerId: 205, Type: 0, Data: '' })
expect(sent[0]?.data).toEqual({ FromPlayerId: 42, ToPlayerId: 205, Type: 0, Data: '' })
})
test('POST /api/messages/v1/sendMultiple 400s with no usable recipient, pushing nothing', async () => {
@@ -6159,66 +6111,19 @@ describe('messages', () => {
expect(await pushed()).toEqual([])
})
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'))
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),
})
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', () => {
@@ -6835,7 +6740,9 @@ 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([])
-1
View File
@@ -15,7 +15,6 @@
"test": "run-vitest"
},
"dependencies": {
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"hono": "4.13.5",
-6
View File
@@ -5,12 +5,6 @@ import type { NotificationsHub } from './notifications-hub'
export type Env = SharedHonoEnv & {
/** Durable Object hosting the SignalR notifications hub. */
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
/**
* The shared `recflare` D1 database. Used only for the notification store — a coach
* message to one player is written there before it is pushed, so it survives the hub's
* best-effort queue. The hub DO itself keeps its own state in its own SQLite.
*/
DB: D1Database
/**
* HS256 JWT signing key, read with `await env.JWT_SECRET.get()`; all workers
* bind the same store so tokens signed by `auth` verify here. Used to gate the
+1 -6
View File
@@ -236,12 +236,7 @@ export interface MessageReceivedPayload {
/** ISO-8601. */
SentTime: string
Type: number
/**
* 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
Data: string
RoomId: number | null
PlayerEventId: number | null
}
+15 -25
View File
@@ -115,15 +115,11 @@ export interface HubState {
*/
export const MAX_PENDING_PER_PLAYER = 500
/**
* 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 Coach system account — the `FromPlayerId` on a coach message (see coachMessageAll). */
const COACH_PLAYER_ID = 1
/** The Message `Type` a coach/system message carries (a Message-model enum, not a NotificationType). */
export const COACH_MESSAGE_TYPE = 100
const COACH_MESSAGE_TYPE = 100
export class NotificationsHub extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
@@ -135,7 +131,7 @@ export class NotificationsHub extends DurableObject<Env> {
// 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
@@ -554,26 +550,20 @@ export class NotificationsHub extends DurableObject<Env> {
* 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 — and, for the same reason, nothing to store: a message written to
* a named player is worth keeping until they next connect.
* hold anything for; a message written to a named player is worth keeping until they
* next connect.
*/
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,
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,
})
}
+7 -23
View File
@@ -1,16 +1,10 @@
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 {
COACH_MESSAGE_TYPE,
COACH_PLAYER_ID,
NotificationsHub,
OWNER_HEADER,
} from './notifications-hub'
import { NotificationsHub, OWNER_HEADER } from './notifications-hub'
import type { Context, MiddlewareHandler } from 'hono'
import type { App } from './context'
@@ -234,13 +228,9 @@ const app = new Hono<App>()
return c.json({ success: true, ...result })
})
// 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.
// 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.
.post('/internal/coach-message', async (c) => {
const body = await c.req
.json<{ playerId?: number; messageContent?: string }>()
@@ -250,15 +240,9 @@ const app = new Hono<App>()
return c.json({ error: 'playerId is required' }, 400)
}
if (content === '') return c.json({ error: 'messageContent is required' }, 400)
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 })
const { playerId } = body
const result = await hubCall(c, (hub) => hub.coachMessage(playerId, content))
return c.json({ success: true, ...result })
})
// Read-only view of the hub's routing state, for working out why a notification
+1 -43
View File
@@ -2,13 +2,11 @@ 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' {
@@ -52,9 +50,6 @@ 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. */
@@ -407,43 +402,6 @@ 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)
-15
View File
@@ -8,21 +8,6 @@
"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.
+45
View File
@@ -0,0 +1,45 @@
-- Room roles as their own table, mirroring 0013's move of tags into `room_tag`. One row
-- per (room, account). Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) —
-- keep in sync.
--
-- The row is the client's `Roles` entry shape:
-- { "AccountId": …, "Role": …, "LastChangedByAccountId": …, "InvitedRole": … }
-- `role` is the member's CURRENT role tier (10 Host, 20 Moderator, 30 CoOwner,
-- 255 Creator); `invited_role` is the tier they have been OFFERED but not yet accepted —
-- usually higher than `role`, and 0 when no invitation is pending. `last_changed_by` is
-- who last touched the row; NULL for creator entries, matching the blob's
-- `LastChangedByAccountId: null`.
--
-- The table is AUTHORITATIVE and the blob's `Roles` key is removed below, the same
-- arrangement 0013 gave tags: `serializeRoom` drops `Roles` on write and the reads
-- re-attach it (attachRoles), so the room DTO the client sees is unchanged and the two
-- copies can't drift. `getContributedRooms` now matches on this table instead of running
-- `json_each` over every blob.
CREATE TABLE IF NOT EXISTS room_role (
room_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
role INTEGER NOT NULL DEFAULT 0,
last_changed_by INTEGER,
invited_role INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (room_id, account_id)
);
-- Backfill from the blobs, the way 0013 backfilled `room_tag`. `json_each` walks the
-- `Roles` array; a room with no array (or a null one) contributes nothing, which is why
-- this is a join rather than a correlated subquery. `INSERT OR IGNORE` collapses a blob
-- that somehow carries the same account twice — the primary key is (room, account).
INSERT OR IGNORE INTO room_role (room_id, account_id, role, last_changed_by, invited_role)
SELECT
r.room_id,
json_extract(t.value, '$.AccountId'),
COALESCE(json_extract(t.value, '$.Role'), 0),
json_extract(t.value, '$.LastChangedByAccountId'),
COALESCE(json_extract(t.value, '$.InvitedRole'), 0)
FROM room r, json_each(r.data, '$.Roles') t
WHERE json_extract(t.value, '$.AccountId') IS NOT NULL;
-- Single source of truth: the roles now live in `room_role`, so the copy in the blob
-- goes. Leaving it would be a second answer to "who has a role in this room" that only
-- the writes through setRoomRole keep current.
UPDATE room SET data = json_remove(data, '$.Roles');
+2 -22
View File
@@ -400,15 +400,6 @@ 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.
*
@@ -580,20 +571,9 @@ export const ImageRequest = z.object({
imageName: z.string().describe('A key from the storage upload, stored un-prefixed'),
})
/** `PUT /rooms/{roomId}/roles/{accountId}` — a grant, or the invited player's answer. */
/** `PUT /rooms/{roomId}/roles/{accountId}`. */
export const RoleRequest = z.object({
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'),
role: z.string().describe('The role tier: 10 Host, 20 Moderator, 30 CoOwner, 255 Creator'),
})
/** `POST /rooms/{roomId}/bans` — the player to ban from the room. */
+20 -266
View File
@@ -4,7 +4,6 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import {
Accessibility,
answerRoomRoleInvite,
applyRoomTagEdit,
areFriends,
autocompleteRoomSearch,
@@ -13,7 +12,6 @@ import {
cloneRoom,
cloneSubRoom,
countRoomsByCreator,
createNotification,
createSubRoom,
deleteRoom,
deleteRoomLeaderboard,
@@ -26,7 +24,6 @@ import {
getHotRooms,
getInteraction,
getOrCreateDormRoom,
getPlayerIdsInRoom,
getPresence,
getPublicRoomsByCreator,
getRecommendedRooms,
@@ -41,17 +38,12 @@ import {
getSubRoomSaves,
getTrendingRooms,
getVisitedRooms,
inviteRoomRole,
isPlayerBannedFromRoom,
isRoomOwner,
MessageType,
modifySubRoom,
publishSubRoomSave,
removeCheer,
removeFavorite,
Role,
roomNameRejection,
roomRoles,
saveSubRoomData,
searchRooms,
setRoomDescription,
@@ -96,7 +88,6 @@ import {
ImageRequest,
InteractionDto,
intQuery,
InviteRoleRequest,
IsBannedEnvelope,
IsBannedPascalEnvelope,
json,
@@ -127,7 +118,6 @@ import {
roomIdParam,
RoomLookup,
RoomResultEnvelope,
RoomRoleDto,
RoomSaveEnvelope,
saveIdParam,
SaveSubRoomDataRequest,
@@ -141,7 +131,6 @@ import {
SubRoomPermissionsRequest,
SubRoomSavesNoUnityAssetsPage,
SubRoomSavesPage,
SuccessEnvelope,
TagRequest,
TooManyLookupIds,
UNAUTHORIZED_EMPTY,
@@ -152,7 +141,6 @@ 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'
/**
@@ -513,95 +501,6 @@ async function pushRoomUpdate(
}
}
/**
* Push a `RoomUpdate` to everyone standing in a room right now, plus anyone in `alsoNotify`.
*
* The fan-out for a change to the ROOM rather than to one player's relationship with it — a
* role changing alters what the room lets people do, and every client in it is rendering that
* room. Read from live presence across all of the room's instances, so it is whoever is
* actually there; a room nobody is in pushes to nobody.
*
* `alsoNotify` is for the player the change is ABOUT, who may not be standing in the room at
* all — a role granted to someone across the map still changes what they can do when they
* arrive. De-duplicated against the occupants, so being both doesn't earn two pushes.
*
* Each push is independent and failures are swallowed by {@link pushRoomUpdate}, so one
* unreachable player doesn't cost the rest theirs.
*/
async function pushRoomUpdateToRoom(
c: Context<App>,
roomId: number,
room: Record<string, unknown>,
alsoNotify: number[] = []
): Promise<void> {
const playerIds = new Set(await getPlayerIdsInRoom(c.env.DB, roomId))
for (const playerId of alsoNotify) playerIds.add(playerId)
await Promise.all([...playerIds].map((playerId) => pushRoomUpdate(c, playerId, room)))
}
/**
* Store and deliver a room-role invite — a Message of type 62 (`RoomCoOwnerInvited`), which
* is what the invited player accepts or declines from. The `Roles` entry alone is silent:
* it records the offer, this delivers it.
*
* STORED first, then pushed from the stored record, so the frame carries the row's `Id` and
* the invite survives a failed or unheard push: the invited player reads it from
* `GET /api/messages/v2/get` on their next login instead of losing it to the hub's
* best-effort queue. The frame is typed as the hub's {@link MessageReceivedPayload} so a
* renamed key fails the build rather than vanishing on the wire.
*
* `Data` is the ROLE being offered, as a string (a Message's `Data` is always a string on
* the wire). It is what the invited player's client reads the offer off, and what it must
* send back as `role` when it accepts — the accept refuses anything that isn't the role
* standing on their entry, so an invite the client can't read the tier of is one it can't
* answer. `PlayerEventId` is null; the room is in `RoomId`. The hub drops null keys when it
* builds the frame, so it reaches the client absent rather than as null, which its decoder
* reads the same way.
*
* Best-effort on the PUSH only: the role entry and the message row have both committed, so
* a hub hiccup must not fail the request.
*/
async function pushRoleInvite(
c: Context<App>,
roomId: number,
fromAccountId: number,
toAccountId: number,
role: number
): Promise<void> {
const stored = await createNotification(c.env.DB, {
FromPlayerId: fromAccountId,
ToPlayerId: toAccountId,
Type: MessageType.RoomCoOwnerInvited,
// The role being offered, as a string — a Message's `Data` is a string on the wire.
Data: String(role),
RoomId: roomId,
})
const message: MessageReceivedPayload = {
Id: stored.Id,
FromPlayerId: stored.FromPlayerId,
SentTime: stored.SentTime,
Type: stored.Type,
Data: stored.Data,
RoomId: stored.RoomId,
PlayerEventId: stored.PlayerEventId,
}
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
toAccountId,
NotificationType.MessageReceived,
{ ...message, ToPlayerId: stored.ToPlayerId }
)
} catch (err) {
logger.error('failed to push RoomCoOwnerInvited MessageReceived notification', {
messageId: stored.Id,
roomId,
toAccountId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* `reportCategory` on a moderation frame. -1 is "Moderator" — the category for an
* action a person took rather than one the system inferred, which is what a room ban
@@ -2043,83 +1942,23 @@ const app = new Hono<App>()
}
)
// 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.
// 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.
.put(
'/rooms/:roomId{[0-9]+}/roles/:accountId{[0-9]+}',
describeRoute({
tags: ['Room settings'],
summary: 'Set a player’s role in a room',
summary: 'Set a member’s role in a room',
description: [
'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'),
'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(' '),
security: AUTHED,
parameters: [
roomIdParam,
@@ -2127,11 +1966,11 @@ const app = new Hono<App>()
name: 'accountId',
in: 'path',
required: true,
description: 'The player whose role changes — the caller’s own id to answer an invite',
description: 'The member whose role changes',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
requestBody: form(RoleRequest, 'The role tier, or 0 to decline an invite'),
requestBody: form(RoleRequest, 'The role tier to grant'),
responses: {
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
401: UNAUTHORIZED_RESPONSE,
@@ -2146,107 +1985,22 @@ const app = new Hono<App>()
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<string, unknown>
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)
if (!updated) return roomEnvelope(c, null, 'Co-ownership must be invited, not granted!')
await pushRoomUpdateToRoom(c, roomId, updated, [targetAccountId])
// Notify the member whose role changed so their client refreshes the room
// (and the permissions it grants them).
await pushRoomUpdate(c, targetAccountId, updated)
return roomEnvelope(c, updated)
}
)
// Invite a player to a room role (`Roles[].InvitedRole`). Auth-gated (401) and gated to
// the room's OWNER alone (403 otherwise) — narrower than the role WRITE above, which a
// co-owner may also use. Body is the `role` form field. Records the pending offer on the
// target's role entry (adding one at `Role` 0 when they have none), pushes the
// `RoomCoOwnerInvited` message that raises the invite on their client, and answers a
// bare `{ success: true }` — the inviter's client re-renders nothing from it.
.put(
'/rooms/:roomId{[0-9]+}/roles/:accountId{[0-9]+}/invite',
describeRoute({
tags: ['Room settings'],
summary: 'Invite a player to a room role',
description: [
'Sets `InvitedRole` on the target’s entry in the room’s `Roles` (adding an entry at',
'`Role` 0 when they have none) — the PENDING half of a role entry, held until they',
'accept, at which point the role proper is granted. Someone who already holds a role',
'keeps it while a higher one is pending.',
'',
'Gated to the room’s OWNER — its creator, or the holder of the Creator role. Narrower',
'than setting a role outright, which a co-owner may also do: otherwise a co-owner',
'could quietly grow the set of people who can change the room. A valid token from',
'anyone else is a 403.',
'',
'The INVITED player gets a durable `MessageReceived` frame carrying a Message of type',
'62 (`RoomCoOwnerInvited`), naming the inviter, the room, and the offered role as its',
'`Data` — that message, not the `Roles` entry, is what their client raises the invite',
'from, and the role in it is what they send back to accept.',
'',
'Answers a bare `{ success: true }`, NOT the `{ success, error, value }` room',
'envelope: nothing on the inviter’s screen re-renders from an invite.',
].join('\n'),
security: AUTHED,
parameters: [
roomIdParam,
{
name: 'accountId',
in: 'path',
required: true,
description: 'The player being invited',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
requestBody: form(InviteRoleRequest, 'The role tier being offered'),
responses: {
200: json(SuccessEnvelope, 'The invite was recorded and pushed'),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const targetAccountId = Number.parseInt(c.req.param('accountId'), 10)
const room = await getRoomById(c.env.DB, roomId)
// An unknown room and a room somebody else owns answer the same 403: the gate is
// the owner check, and there is no envelope here to carry a message in.
if (!room || !isRoomOwner(room, accountId)) return c.body(null, 403)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const role = typeof body.role === 'string' ? Number.parseInt(body.role, 10) : Number.NaN
if (Number.isNaN(role)) return c.body(null, 400)
await inviteRoomRole(c.env.DB, roomId, targetAccountId, role, accountId, room)
await pushRoleInvite(c, roomId, accountId, targetAccountId, role)
return c.json({ success: true })
}
)
// A room's ban list — the owner's view of who they've banned. Same gate as issuing a
// 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.
+47 -375
View File
@@ -6,8 +6,6 @@ import '../../rooms.app'
import {
createRoomInstance,
getRoomInstance,
MessageType,
NOTIFICATION_SCHEMA_DDL,
PRESENCE_SCHEMA_DDL,
ROOM_INSTANCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
@@ -101,9 +99,6 @@ 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<string, unknown>)
@@ -447,6 +442,11 @@ describe('rooms endpoints', () => {
it('GET /rooms/contributedby/me lists rooms the caller owns or has a role in', async () => {
const seed = (data: Record<string, unknown>) =>
env.DB.prepare('INSERT INTO room (data) VALUES (?1)').bind(JSON.stringify(data)).run()
// Roles live in `room_role`, not the blob — the table the query's EXISTS matches on.
const grant = (roomId: number, accountId: number, role: number) =>
env.DB.prepare('INSERT INTO room_role (room_id, account_id, role) VALUES (?1, ?2, ?3)')
.bind(roomId, accountId, role)
.run()
// A room somebody else made, where 820 is a co-owner...
await seed({
@@ -455,11 +455,9 @@ describe('rooms endpoints', () => {
CreatorAccountId: 821,
Accessibility: 1,
SubRooms: [],
Roles: [
{ AccountId: 821, Role: 255 },
{ AccountId: 820, Role: 30 },
],
})
await grant(30401, 821, 255)
await grant(30401, 820, 30)
// ...one where they're only a host (every tier counts, not just owner-level)...
await seed({
RoomId: 30402,
@@ -469,20 +467,20 @@ describe('rooms endpoints', () => {
// accessibility is not filtered here.
Accessibility: 0,
SubRooms: [],
Roles: [{ AccountId: 820, Role: 10 }],
})
// ...one they created themselves, whose Roles name them as Creator (matched by BOTH
// halves of the query, so it must still appear exactly once)...
await grant(30402, 820, 10)
// ...one they created themselves, whose role rows name them as Creator (matched by
// BOTH halves of the query, so it must still appear exactly once)...
await seed({
RoomId: 30403,
Name: 'ContribOwn',
CreatorAccountId: 820,
Accessibility: 1,
SubRooms: [],
Roles: [{ AccountId: 820, Role: 255 }],
})
// ...one they created that names nobody in Roles at all — the older rooms have no
// Roles key, and those reach the list on the creator half alone...
await grant(30403, 820, 255)
// ...one they created that has no role rows at all — the older rooms never got
// any, and those reach the list on the creator half alone...
await seed({
RoomId: 30406,
Name: 'ContribOwnNoRoles',
@@ -498,18 +496,17 @@ describe('rooms endpoints', () => {
IsDorm: true,
Accessibility: 2,
SubRooms: [],
Roles: [{ AccountId: 820, Role: 255 }],
})
// ...one they have nothing to do with, and one with no Roles key at all (the older
// seeded rooms have none — json_each must drop them, not error).
await grant(30407, 820, 255)
// ...one they have nothing to do with, and one with no role rows at all.
await seed({
RoomId: 30404,
Name: 'ContribOther',
CreatorAccountId: 821,
Accessibility: 1,
SubRooms: [],
Roles: [{ AccountId: 822, Role: 30 }],
})
await grant(30404, 822, 30)
await seed({ RoomId: 30405, Name: 'ContribNoRoles', CreatorAccountId: 821, SubRooms: [] })
const res = await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`, {
@@ -537,6 +534,7 @@ describe('rooms endpoints', () => {
// The DB is shared across this file, and these are the only player-made public rooms
// in it — leaving them behind changes what the `new`/`community` room feeds serve.
await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30407').run()
await env.DB.prepare('DELETE FROM room_role WHERE room_id BETWEEN 30401 AND 30407').run()
})
it('GET /rooms/:roomId/experience serves the fixed XP settings, no auth', async () => {
@@ -1634,373 +1632,49 @@ describe('rooms endpoints', () => {
expect(interactions!.n).toBe(0)
})
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
it('PUT /rooms/:id/roles/:accountId is auth-gated, owner/co-owner-only, and persists', async () => {
const rolesOf = async (): Promise<Array<{ AccountId: number; Role: number }>> => {
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
Roles?: Array<{ AccountId: number; Role: number }>
}
return room.Roles ?? []
}
const roleOf = async (accountId: number): Promise<Role | undefined> =>
((await (await SELF.fetch(`${ORIGIN}/rooms/2/roles`)).json()) as Role[]).find(
(r) => r.AccountId === accountId
)
// RecCenter (room 2) is owned by account 1, with account 2 as co-owner.
// No token → 401; a valid token with no standing in the room → 403.
expect((await putForm('/rooms/2/roles/860', { role: '20' })).status).toBe(401)
expect((await putForm('/rooms/2/roles/860', { role: '20' }, '999')).status).toBe(403)
// The owner grants Moderator, which takes effect immediately — no invite, no accept.
const ok = await putForm('/rooms/2/roles/860', { role: '20' }, '1')
expect(ok.status).toBe(200)
expect(await envOf(ok)).toMatchObject({ success: true, error: '' })
expect(await roleOf(860)).toMatchObject({
Role: 20,
InvitedRole: 0,
LastChangedByAccountId: 1,
})
// A co-owner may hand out the helper tiers too, updating the entry in place rather
// than adding a second.
expect((await putForm('/rooms/2/roles/860', { role: '10' }, '2')).status).toBe(200)
const roles = (await (await SELF.fetch(`${ORIGIN}/rooms/2/roles`)).json()) as Role[]
expect(roles.filter((r) => r.AccountId === 860)).toHaveLength(1)
expect(await roleOf(860)).toMatchObject({ Role: 10, LastChangedByAccountId: 2 })
// The two tiers a grant may never reach — co-ownership has to be accepted, and
// Creator is more than co-ownership. Neither writes anything.
for (const role of ['30', '255']) {
expect(await envOf(await putForm('/rooms/2/roles/860', { role }, '1'))).toMatchObject({
success: false,
error: 'Co-ownership must be invited, not granted!',
})
}
// `role=0` is the invited player's decline, not an owner-side revoke.
expect(await envOf(await putForm('/rooms/2/roles/860', { role: '0' }, '1'))).toMatchObject({
success: false,
})
expect(await roleOf(860)).toMatchObject({ Role: 10 })
// A grant leaves a pending co-owner invite standing — the two are answered separately.
expect((await putForm('/rooms/2/roles/861/invite', { role: '30' }, '1')).status).toBe(200)
expect((await putForm('/rooms/2/roles/861', { role: '20' }, '1')).status).toBe(200)
expect(await roleOf(861)).toMatchObject({ Role: 20, InvitedRole: 30 })
})
it('a grant pushes a RoomUpdate to the room and to the affected player', async () => {
type Sent = { playerId: number; notificationType: string | number; data: { RoomId: number } }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sent = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
// One bystander in room 4, and the player being granted the role standing somewhere
// else entirely — a role they can use when they arrive, so they hear about it too.
await putInRoom(870, 4)
await putInRoom(871, 2)
await hub().fetch('http://do/all', { method: 'DELETE' })
expect((await putForm('/rooms/4/roles/871', { role: '20' }, '1')).status).toBe(200)
const pushed = await sent()
expect(pushed.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([870, 871])
expect(
pushed.every((n) => n.notificationType === NotificationType.SubscriptionUpdateRoom)
).toBe(true)
expect(pushed.every((n) => n.data.RoomId === 4)).toBe(true)
for (const id of [870, 871]) await clearPresence(id)
})
it('PUT /rooms/:id/roles/:accountId accepts a standing invite, and only that one', async () => {
type Role = {
AccountId: number
Role: number
InvitedRole: number
LastChangedByAccountId: number | null
}
const roleOf = async (accountId: number): Promise<Role | undefined> =>
((await (await SELF.fetch(`${ORIGIN}/rooms/2/roles`)).json()) as Role[]).find(
(r) => r.AccountId === accountId
)
// RecCenter (room 2) is owned by account 1. The owner invites 850 to co-ownership,
// leaving a pending offer and no role yet.
expect((await putForm('/rooms/2/roles/850/invite', { role: '30' }, '1')).status).toBe(200)
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 })
// No token → 401 (auth gate).
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 })
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)
// Unknown room → failure envelope.
expect(
await envOf(await putForm('/rooms/99999/roles/850', { role: '30' }, '850'))
).toMatchObject({ success: false, error: 'This room does not exist!' })
expect(await envOf(await putForm('/rooms/99999/roles/5', { role: '20' }, '1'))).toMatchObject({
success: false,
error: 'This room does not exist!',
})
// Non-numeric role → failure envelope.
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({
expect(await envOf(await putForm('/rooms/2/roles/5', { role: 'nope' }, '1'))).toMatchObject({
success: false,
})
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 })
// 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')
// 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')
expect(ok.status).toBe(200)
const okBody = await envOf(ok)
expect(okBody).toMatchObject({ success: true, error: '' })
expect(okBody.value?.Roles as Role[]).toContainEqual(
expect.objectContaining({ AccountId: 850, Role: 30, InvitedRole: 0 })
expect(okBody.value?.Roles as Array<{ AccountId: number; Role: number }>).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,
})
expect(await rolesOf()).toContainEqual(expect.objectContaining({ AccountId: 5, Role: 20 }))
// 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<Role | undefined> =>
((await (await SELF.fetch(`${ORIGIN}/rooms/2/roles`)).json()) as Role[]).find(
(r) => r.AccountId === accountId
)
// Declining with nothing standing is the same rejection as any other unanswerable
// invite — it can't be used to drop a role nobody offered to change.
expect(await envOf(await putForm('/rooms/2/roles/851', { role: '0' }, '851'))).toMatchObject({
success: false,
})
// Invited, then declined: the whole entry goes, leaving `Roles` as it was before the
// offer rather than an entry with nothing pending on it.
expect((await putForm('/rooms/2/roles/851/invite', { role: '30' }, '1')).status).toBe(200)
expect(await roleOf(851)).toMatchObject({ Role: 0, InvitedRole: 30 })
const declined = await putForm('/rooms/2/roles/851', { role: '0' }, '851')
expect(declined.status).toBe(200)
expect(await envOf(declined)).toMatchObject({ success: true, error: '' })
expect(await roleOf(851)).toBeUndefined()
// And the decline can't be replayed either — there is no entry left to answer.
expect(await envOf(await putForm('/rooms/2/roles/851', { role: '0' }, '851'))).toMatchObject({
success: false,
})
// Declining takes a role the player ALREADY held with it: the invite is answered as a
// whole, so a re-invited member who declines is left with nothing.
expect((await putForm('/rooms/2/roles/852/invite', { role: '10' }, '1')).status).toBe(200)
expect((await putForm('/rooms/2/roles/852', { role: '10' }, '852')).status).toBe(200)
expect(await roleOf(852)).toMatchObject({ Role: 10, InvitedRole: 0 })
expect((await putForm('/rooms/2/roles/852/invite', { role: '30' }, '1')).status).toBe(200)
expect((await putForm('/rooms/2/roles/852', { role: '0' }, '852')).status).toBe(200)
expect(await roleOf(852)).toBeUndefined()
})
it('accepting an invite pushes a RoomUpdate to everyone in the room', async () => {
type Sent = {
playerId: number
notificationType: string | number
data: { RoomId: number; Roles: Array<{ AccountId: number; Role: number }> }
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sent = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
// Room 3's owner invites 840, who is standing in the room with two others. A fourth
// player is in a DIFFERENT room, and a fifth's presence has lapsed — neither is there.
expect((await putForm('/rooms/3/roles/840/invite', { role: '20' }, '1')).status).toBe(200)
await putInRoom(840, 3)
await putInRoom(841, 3)
await putInRoom(842, 2)
await putInRoom(843, 3, { expired: true })
await hub().fetch('http://do/all', { method: 'DELETE' })
expect((await putForm('/rooms/3/roles/840', { role: '20' }, '840')).status).toBe(200)
// A role change alters what the room lets people do, so everyone standing in it
// re-renders — including the accepter, who is one of them.
const pushed = await sent()
expect(pushed.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([840, 841])
expect(
pushed.every((n) => n.notificationType === NotificationType.SubscriptionUpdateRoom)
).toBe(true)
// Each carries the room as it now stands, with the accepted role on it.
expect(pushed[0].data).toMatchObject({ RoomId: 3 })
expect(pushed[0].data.Roles).toContainEqual(
expect.objectContaining({ AccountId: 840, Role: 20, InvitedRole: 0 })
)
for (const id of [840, 841, 842, 843]) await clearPresence(id)
})
it('GET /rooms/:id/roles serves the room’s roles as a bare array', async () => {
// RecCenter (room 2) is seeded with its creator (255) and a co-owner (30). Public,
// like the room itself — no token.
const res = await SELF.fetch(`${ORIGIN}/rooms/2/roles`)
expect(res.status).toBe(200)
// Containment, not equality: the role-write test above grants account 5 a role on
// this same room, and the two tests share a database.
expect(await res.json()).toEqual(
expect.arrayContaining([
{ AccountId: 1, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
{ AccountId: 2, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 },
])
)
// An unknown room reads the same as a room nobody holds a role in.
expect((await SELF.fetch(`${ORIGIN}/rooms/99999/roles`)).status).toBe(200)
expect(await (await SELF.fetch(`${ORIGIN}/rooms/99999/roles`)).json()).toEqual([])
// A role written without `LastChangedByAccountId`/`InvitedRole` (the older rooms) is
// still served as a whole record — the client's parser wants every key.
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId: 30601,
Name: 'PartialRoles',
CreatorAccountId: 830,
SubRooms: [],
Roles: [{ AccountId: 830, Role: 255 }],
})
)
.run()
expect(await (await SELF.fetch(`${ORIGIN}/rooms/30601/roles`)).json()).toEqual([
{ AccountId: 830, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
])
// A room with no `Roles` key at all is an empty list, not a failure.
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(JSON.stringify({ RoomId: 30602, Name: 'NoRoles', CreatorAccountId: 830, SubRooms: [] }))
.run()
expect(await (await SELF.fetch(`${ORIGIN}/rooms/30602/roles`)).json()).toEqual([])
})
it('PUT /rooms/:id/roles/:accountId/invite records the offer and pushes the invite', async () => {
type Sent = {
playerId: number
notificationType: string | number
data: Record<string, unknown>
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sent = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
const rolesOf = async (roomId: number) =>
(await (await SELF.fetch(`${ORIGIN}/rooms/${roomId}/roles`)).json()) as Array<{
AccountId: number
Role: number
InvitedRole: number
LastChangedByAccountId: number | null
}>
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/roles/187/invite', { role: '30' })).status).toBe(401)
// A valid token with no standing on the room → 403...
expect((await putForm('/rooms/2/roles/187/invite', { role: '30' }, '999')).status).toBe(403)
// ...and so does the CO-OWNER (account 2, Role 30), who may set roles outright but
// may not hand out co-ownership. This is the gate that is narrower than the write.
expect((await putForm('/rooms/2/roles/187/invite', { role: '30' }, '2')).status).toBe(403)
// An unknown room answers the same 403 rather than leaking that it doesn't exist.
expect((await putForm('/rooms/99999/roles/187/invite', { role: '30' }, '1')).status).toBe(403)
// A non-numeric role is a bad request — there is no envelope here to carry a message.
expect((await putForm('/rooms/2/roles/187/invite', { role: 'nope' }, '1')).status).toBe(400)
// None of the refusals wrote a role entry.
expect((await rolesOf(2)).find((r) => r.AccountId === 187)).toBeUndefined()
// The owner invites 187 to co-ownership.
await hub().fetch('http://do/all', { method: 'DELETE' })
const res = await putForm('/rooms/2/roles/187/invite', { role: '30' }, '1')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true })
// A new entry, holding no role yet — only the pending offer, stamped with the inviter.
expect(await rolesOf(2)).toContainEqual({
AccountId: 187,
Role: 0,
InvitedRole: 30,
LastChangedByAccountId: 1,
})
// One MessageReceived to the INVITED player carrying a type 62 RoomCoOwnerInvited —
// the entry alone is silent, this is what raises the invite on their client.
const pushed = await sent()
expect(pushed).toHaveLength(1)
expect(pushed[0].playerId).toBe(187)
expect(pushed[0].notificationType).toBe(NotificationType.MessageReceived)
expect(pushed[0].data).toMatchObject({
FromPlayerId: 1,
Type: MessageType.RoomCoOwnerInvited,
// The offered role, as a string — what the client reads the tier off, and sends
// back as `role` to accept.
Data: '30',
RoomId: 2,
PlayerEventId: null,
})
// The invite is STORED before it is pushed, and the frame carries the row's id and
// timestamp — so the invited player reads the same invite from their inbox
// (`GET /api/messages/v2/get`) even if the push never reached them.
const row = await env.DB.prepare(
'SELECT notification_id, from_player_id, type, data, room_id, sent_time FROM notification WHERE to_player_id = 187'
).first<{
notification_id: number
from_player_id: number
type: number
data: string | null
room_id: number
sent_time: string
}>()
expect(row).toMatchObject({
from_player_id: 1,
type: MessageType.RoomCoOwnerInvited,
data: '30',
room_id: 2,
})
expect(pushed[0].data.Id).toBe(row!.notification_id)
expect(pushed[0].data.SentTime).toBe(row!.sent_time)
expect(Date.parse(row!.sent_time)).not.toBeNaN()
// Re-inviting someone who already has an entry sets the pending role in place rather
// than adding a second entry — and leaves the role they already hold alone. 187
// accepts the invite above first, so they hold a real role to be re-invited over.
expect((await putForm('/rooms/2/roles/187', { role: '30' }, '187')).status).toBe(200)
await putForm('/rooms/2/roles/187/invite', { role: '20' }, '1')
const after = await rolesOf(2)
expect(after.filter((r) => r.AccountId === 187)).toHaveLength(1)
expect(after).toContainEqual({
AccountId: 187,
Role: 30,
InvitedRole: 20,
LastChangedByAccountId: 1,
})
// 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 }))
})
it('POST /rooms/:id/bans is gated to the room’s owners or staff, and persists', async () => {
@@ -4429,7 +4103,6 @@ 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',
@@ -4454,7 +4127,6 @@ 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',
-5
View File
@@ -203,13 +203,8 @@ 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,
-1
View File
@@ -19,7 +19,6 @@ 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'
-227
View File
@@ -1,227 +0,0 @@
/**
* Stored notifications on the shared `recflare` D1 database — one row per notification a
* player has been sent, as `GET /api/messages/v2/get` serves them back on login.
*
* A notification is a WebSocket push, and this table is the delayed half of one. The live
* half is the `MessageReceived` frame the hub pushes, and for a long time it was the ONLY
* half: the inbox endpoint answered an empty list unconditionally, so anything sent to an
* offline player existed solely as a queued frame in the hub's Durable Object. That queue is
* best-effort by construction — dropped once sent rather than acked, bounded per player, and
* skipped entirely whenever the hub believes it delivered (a socket the client has already
* abandoned still counts as a delivery) — so a push could be lost with nothing left to show
* it ever existed. A row here outlives all of that: the player reads it on next login
* whether or not the socket ever carried it.
*
* The two halves are tied together by the ID. A stored notification's `Id` is the id carried
* on the frame that delivers it, so the client can recognise the push it received as the one
* it then reads from the inbox, and `POST /api/messages/v3/delete` has an id to match. A
* sender therefore WRITES THE ROW FIRST and builds its frame from the returned record —
* never the other way round, and never with an id of its own invention.
*
* Not everything pushed belongs here. Only what is still worth reading LATER: a direct
* message, a room-role invite, a coach message to one player. A broadcast (a maintenance
* notice to everyone online at once) would be a row per player for something nobody re-reads;
* a vote-to-kick prompt is about a session that has since ended; a cheer already has its
* durable record in the `reputation` counters.
*
* The table is `notification`, NOT `message`: the `chat` worker already owns a `message`
* table on this same shared database (its thread posts, a different thing entirely). The two
* were distinguishable only by name, and `CREATE TABLE IF NOT EXISTS` against a name that is
* already taken does nothing and says nothing — so the first version of this migration
* quietly adopted chat's table and failed only one statement later, on an index naming a
* column that table doesn't have. Every worker here shares one database; check a table name
* against the whole repo before taking it.
*
* The `api` worker owns this schema/migration (`apps/api/migrations/0019_notification.sql`,
* applied under its own `migrations_table` so it doesn't clash with the other workers'
* migrations that share the database). `NOTIFICATION_SCHEMA_DDL` mirrors that migration so
* tests can build the table directly.
*/
/**
* How many stored notifications one player keeps, newest-wins.
*
* Bounded for the same reason the hub's pending queue is: an inbox nothing ever deletes grows
* with every notification the server sends, and the read then loads the lot. The client shows
* an inbox rather than an archive, and a player returning to a screenful of them is served no
* better by the one below it.
*/
export const MAX_NOTIFICATIONS_PER_PLAYER = 20
/** Schema DDL (mirror of apps/api/migrations/0019_notification.sql). */
export const NOTIFICATION_SCHEMA_DDL: string[] = [
// `notification_id` is AUTOINCREMENT rather than a bare rowid alias: it is handed to the
// client (on the frame and in the inbox) and deleting a notification frees its rowid, so a
// reused id would point a client's stale row at somebody else's.
//
// `data` is nullable because a Message's `Data` is: the types that carry no payload of
// their own send null, distinct from the empty string a DM sends. Where it is set it is
// always a STRING, whatever it means to that type (a room-role invite puts the offered
// role tier in it). `room_id`/`player_event_id` are the optional context a notification
// can name.
//
// `sent_time` is an ISO-8601 UTC string, which is fixed-width and so orders correctly
// under SQLite's plain string comparison — though reads order by `notification_id`, which
// is monotonic and doesn't tie when two land in the same millisecond.
`CREATE TABLE IF NOT EXISTS notification (
notification_id INTEGER PRIMARY KEY AUTOINCREMENT,
from_player_id INTEGER NOT NULL,
to_player_id INTEGER NOT NULL,
type INTEGER NOT NULL,
data TEXT,
room_id INTEGER,
player_event_id INTEGER,
sent_time TEXT NOT NULL
)`,
// The inbox read and the newest-wins trim are both "this player's notifications, newest
// first", which is the whole access pattern.
`CREATE INDEX IF NOT EXISTS idx_notification_recipient ON notification (to_player_id, notification_id DESC)`,
]
/**
* One stored notification, in the shape the client reads it — the client's Message model,
* which is also what the `MessageReceived` frame carries (see the `notify` worker's
* `MessageReceivedPayload`). Same shape in the inbox and on the wire, deliberately: they are
* one notification delivered two ways, and the client parses it with the same decoder either
* way. Hence `Id` here rather than a spelled-out `NotificationId` — the field names belong to
* the client, not to this table.
*/
export interface StoredNotification {
Id: number
FromPlayerId: number
ToPlayerId: number
/** ISO-8601 UTC. */
SentTime: string
Type: number
Data: string | null
RoomId: number | null
PlayerEventId: number | null
}
/** What a sender supplies; the rest of a {@link StoredNotification} is minted here. */
export interface NewNotification {
FromPlayerId: number
ToPlayerId: number
Type: number
Data?: string | null
RoomId?: number | null
PlayerEventId?: number | null
}
interface NotificationRow {
notification_id: number
from_player_id: number
to_player_id: number
type: number
data: string | null
room_id: number | null
player_event_id: number | null
sent_time: string
}
const SELECT_COLUMNS = `notification_id, from_player_id, to_player_id, type, data, room_id, player_event_id, sent_time`
const toNotification = (row: NotificationRow): StoredNotification => ({
Id: row.notification_id,
FromPlayerId: row.from_player_id,
ToPlayerId: row.to_player_id,
SentTime: row.sent_time,
Type: row.type,
Data: row.data,
RoomId: row.room_id,
PlayerEventId: row.player_event_id,
})
/**
* Store one notification and return it as the client reads it — including the `Id` the caller
* must then put on the `MessageReceived` frame it pushes.
*
* Trims the recipient's inbox to {@link MAX_NOTIFICATIONS_PER_PLAYER} on the way, oldest
* first, so the bound holds no matter how the inbox got long. Both statements go in one
* batch: they are one logical write, and an insert whose trim didn't run would leave the
* inbox over its bound until the next notification happened along.
*/
export async function createNotification(
db: D1Database,
notification: NewNotification
): Promise<StoredNotification> {
const [inserted] = await db.batch<NotificationRow>([
db
.prepare(
`INSERT INTO notification (from_player_id, to_player_id, type, data, room_id, player_event_id, sent_time)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
RETURNING ${SELECT_COLUMNS}`
)
.bind(
notification.FromPlayerId,
notification.ToPlayerId,
notification.Type,
notification.Data ?? null,
notification.RoomId ?? null,
notification.PlayerEventId ?? null,
new Date().toISOString()
),
db
.prepare(
`DELETE FROM notification
WHERE to_player_id = ?1 AND notification_id NOT IN (
SELECT notification_id FROM notification WHERE to_player_id = ?1
ORDER BY notification_id DESC LIMIT ?2
)`
)
.bind(notification.ToPlayerId, MAX_NOTIFICATIONS_PER_PLAYER),
])
return toNotification(inserted.results[0])
}
/**
* A player's inbox, newest first — everything sent TO them that they haven't deleted.
*
* Newest first because the client renders the list in the order it is given and the recent
* ones are what is still worth acting on. Ordered by `notification_id` rather than
* `sent_time`: ids are monotonic, so two that land in the same millisecond still have a
* definite order.
*/
export async function getNotificationsForPlayer(
db: D1Database,
playerId: number
): Promise<StoredNotification[]> {
const { results } = await db
.prepare(
`SELECT ${SELECT_COLUMNS} FROM notification
WHERE to_player_id = ?1 ORDER BY notification_id DESC LIMIT ?2`
)
.bind(playerId, MAX_NOTIFICATIONS_PER_PLAYER)
.all<NotificationRow>()
return results.map(toNotification)
}
/**
* Delete notifications from one player's inbox, returning how many rows went.
*
* Scoped to the recipient, not just the ids: an id names a notification globally, so deleting
* on id alone would let anyone holding one clear somebody else's inbox. An id that isn't
* theirs (or is already gone) simply matches nothing — the client removes its rows locally
* and re-reads the list either way, so a partial match is not an error.
*/
export async function deleteNotifications(
db: D1Database,
playerId: number,
notificationIds: number[]
): Promise<number> {
if (notificationIds.length === 0) return 0
const placeholders = notificationIds.map((_, i) => `?${i + 2}`).join(', ')
const { results } = await db
.prepare(
`DELETE FROM notification
WHERE to_player_id = ?1 AND notification_id IN (${placeholders})
RETURNING notification_id`
)
.bind(playerId, ...notificationIds)
.all<{ notification_id: number }>()
return results.length
}
-27
View File
@@ -204,33 +204,6 @@ 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<number[]> {
const { results } = await db
.prepare(
`SELECT DISTINCT account_id AS accountId FROM presence
WHERE room_id = ?1 AND expires_at > ?2 AND room_instance_id IS NOT NULL
ORDER BY account_id`
)
.bind(roomId, now)
.all<{ accountId: number }>()
return results.map((r) => r.accountId)
}
/**
* How many players are online right now, anywhere — one row per account, so this is
* the player count a status page means. Counts unexpired presence only: rows outlive
+150 -204
View File
@@ -118,6 +118,25 @@ export const ROOM_SCHEMA_DDL: string[] = [
sort_ascending INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (room_id, leaderboard_id)
)`,
// Per-room role grants and invitations (migrations/0017_room_role.sql), one row per
// (room, account) — the client's `Roles` entry shape ({@link RoomRole}): `role` is the
// member's CURRENT role tier, `invited_role` the tier they've been offered but not yet
// accepted (so it's usually the higher of the two; 0 when no offer is pending), and
// `last_changed_by` who last touched the row (NULL for the seeded creator entries,
// matching the blob's `LastChangedByAccountId: null`).
//
// The table is AUTHORITATIVE, the same arrangement as `room_tag`: `serializeRoom`
// strips `Roles` from the blob and the reads re-attach it (see {@link attachRoles}),
// so there is exactly one place a role is stored — and `getContributedRooms` matches
// on an indexed table instead of a `json_each` over every blob.
`CREATE TABLE IF NOT EXISTS room_role (
room_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
role INTEGER NOT NULL DEFAULT 0,
last_changed_by INTEGER,
invited_role INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (room_id, account_id)
)`,
]
/**
@@ -184,30 +203,13 @@ export const SUBROOM_SCHEMA_DDL: string[] = [
export type Room = Record<string, unknown>
/** A room role assignment (the client's RoomRole shape). */
export interface RoomRole {
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<Partial<RoomRole>>) : []
return roles.map((r) => ({
AccountId: Number(r.AccountId ?? 0),
Role: Number(r.Role ?? Role.None),
LastChangedByAccountId: r.LastChangedByAccountId ?? null,
InvitedRole: Number(r.InvitedRole ?? Role.None),
}))
}
/**
* Room roles that confer owner-level management of a room: Creator (255) and
* CoOwner (30). The reference gates its room-admin actions on this set. (Host and
@@ -224,7 +226,8 @@ const MANAGE_ROLES: ReadonlySet<number> = new Set([Role.Creator, Role.CoOwner])
*/
export function canManageRoom(room: Room, accountId: number): boolean {
if (room.CreatorAccountId === accountId) return true
return roomRoles(room).some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role))
const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : []
return roles.some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role))
}
/**
@@ -239,19 +242,8 @@ export function canManageRoom(room: Room, accountId: number): boolean {
*/
export function canModerateRoom(room: Room, accountId: number): boolean {
if (room.CreatorAccountId === accountId) return true
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)
const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : []
return roles.some((r) => r.AccountId === accountId && r.Role >= Role.Moderator)
}
/** A player banned from a room (a `room_ban` row). */
@@ -439,14 +431,10 @@ export async function cloneRoom(
// Ownership is reset to the cloner — the source room's Roles (its creator and
// any co-owners, e.g. the seeded base-room roles for accounts 1/2) must NOT
// carry over, or the clone would still list the template's owner as owner.
// carry over, or the clone would still list the template's owner as owner. The
// roles live in `room_role` (inserted below); this array is the response copy.
const roles: RoomRole[] = [
{
AccountId: accountId,
Role: Role.Creator,
LastChangedByAccountId: null,
InvitedRole: Role.None,
},
{ AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 },
]
const cloned: Room = {
@@ -472,9 +460,11 @@ export async function cloneRoom(
CreatedAt: new Date().toISOString(),
}
// serializeRoom drops the hydrated SubRooms from the blob; the clone's subrooms are
// inserted into the subroom table below with fresh globally-unique ids.
// serializeRoom drops the hydrated SubRooms from the blob (and Roles — those go to
// their own table); the clone's subrooms are inserted into the subroom table below
// with fresh globally-unique ids.
await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(serializeRoom(cloned)).run()
await insertCreatorRole(db, newRoomId, accountId)
const sourceSubRooms = Array.isArray(source.SubRooms) ? (source.SubRooms as SubRoom[]) : []
const clonedSubRooms: SubRoom[] = []
for (const sub of sourceSubRooms) {
@@ -541,37 +531,12 @@ export async function updateRoomFields(
}
/**
* 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.
* Set a target account's room `Role` — updating their existing `room_role` row or
* inserting one — and stamp `last_changed_by` with the editor. One row per
* (room, account), so the call is idempotent; a pending `invited_role` is left alone
* (setting someone's role is not answering their invitation). The caller supplies the
* already-loaded (hydrated) room, and gets it back with `Roles` updated to match the
* table — the blob is not touched, roles don't live there.
*/
export async function setRoomRole(
db: D1Database,
@@ -580,10 +545,19 @@ export async function setRoomRole(
role: number,
changedByAccountId: number,
room: Room
): Promise<Room | null> {
if (roleRequiresInvite(role)) return null
): Promise<Room> {
await db
.prepare(
`INSERT INTO room_role (room_id, account_id, role, last_changed_by, invited_role)
VALUES (?1, ?2, ?3, ?4, 0)
ON CONFLICT(room_id, account_id) DO UPDATE SET role = ?3, last_changed_by = ?4`
)
.bind(roomId, targetAccountId, role, changedByAccountId)
.run()
const roles = roomRoles(room)
// Mirror the write onto the hydrated room so the caller can serve it without a
// re-read — the same entry shape {@link attachRoles} would produce.
const roles = Array.isArray(room.Roles) ? [...(room.Roles as RoomRole[])] : []
const existing = roles.find((r) => r.AccountId === targetAccountId)
if (existing) {
existing.Role = role
@@ -593,117 +567,10 @@ export async function setRoomRole(
AccountId: targetAccountId,
Role: role,
LastChangedByAccountId: changedByAccountId,
InvitedRole: Role.None,
InvitedRole: 0,
})
}
const updated: Room = { ...room, Roles: roles }
await db
.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1')
.bind(roomId, serializeRoom(updated))
.run()
return updated
}
/**
* ANSWER a standing room-role invite — accept it, or decline it with `role` 0.
*
* Accepting promotes the account's `Roles` entry from its pending `InvitedRole` to the real
* `Role` and clears the pending one, so an accepted entry reads `Role: 30, InvitedRole: 0`
* and the invite cannot be answered twice. `role` is what the ACCEPTING PLAYER asked for, and
* it must equal the `InvitedRole` already on their entry. That equality is the whole security
* of this call: the answer is made by the invited player themselves, so without it they could
* answer an invite to Host by asking for Creator and award themselves the room. The invited
* role is the server's own record of what the owner offered, written by
* {@link inviteRoomRole}, and nothing the caller sends can move it.
*
* Declining (`role` 0 — not a real tier, so it can't be confused for one) REMOVES the entry
* outright rather than just clearing `InvitedRole`: a declined invite should leave the room's
* `Roles` as it was before the offer. Note that this takes any role the player already held
* with it, so someone re-invited to a different tier who declines is left with nothing at
* all. That is deliberate — an invite is answered as a whole — but it means a decline is not
* a no-op for an existing member.
*
* Returns null — changing nothing — when there is no entry for the account, no invite
* standing on it, or (when accepting) the requested role isn't the one offered. The three are
* deliberately one answer: a caller probing for a higher role learns only that it didn't work.
*
* `LastChangedByAccountId` is left alone rather than restamped with the accepter: it names
* who put this role on the entry, and that is the owner who offered it. The caller supplies
* the already-loaded room to avoid a re-read; the whole room JSON is rewritten.
*/
export async function answerRoomRoleInvite(
db: D1Database,
roomId: number,
accountId: number,
role: number,
room: Room
): Promise<Room | null> {
const roles = roomRoles(room)
const index = roles.findIndex((r) => r.AccountId === accountId)
if (index === -1) return null
const entry = roles[index]
// `Role.None` is "nothing pending" — the resting state of every entry, and what an
// already-answered invite leaves behind, so neither answer can be replayed.
if (entry.InvitedRole === Role.None) return null
if (role === Role.None) {
roles.splice(index, 1)
} else if (entry.InvitedRole !== role) {
return null
} else {
entry.Role = entry.InvitedRole
entry.InvitedRole = Role.None
}
const updated: Room = { ...room, Roles: roles }
await db
.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1')
.bind(roomId, serializeRoom(updated))
.run()
return updated
}
/**
* INVITE an account to a room role — setting `InvitedRole` on their `Roles` entry (adding
* the entry when they have none) and stamping `LastChangedByAccountId` with the inviter.
*
* `InvitedRole` is the PENDING half of a role entry: an invited account holds it until
* they accept, at which point {@link answerRoomRoleInvite} promotes it to the real `Role`.
* So an entry added here starts at {@link Role.None} — no role yet, only an offer — and an
* account who already holds a role keeps it while a higher one is pending.
*
* Like {@link answerRoomRoleInvite}, the caller supplies the already-loaded room to
* avoid a re-read, and the whole room JSON is rewritten. Returns the updated room.
*/
export async function inviteRoomRole(
db: D1Database,
roomId: number,
targetAccountId: number,
invitedRole: number,
changedByAccountId: number,
room: Room
): Promise<Room> {
const roles = roomRoles(room)
const existing = roles.find((r) => r.AccountId === targetAccountId)
if (existing) {
existing.InvitedRole = invitedRole
existing.LastChangedByAccountId = changedByAccountId
} else {
roles.push({
AccountId: targetAccountId,
Role: Role.None,
LastChangedByAccountId: changedByAccountId,
InvitedRole: invitedRole,
})
}
const updated: Room = { ...room, Roles: roles }
await db
.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1')
.bind(roomId, serializeRoom(updated))
.run()
return updated
return { ...room, Roles: roles }
}
/**
@@ -1437,16 +1304,17 @@ const serializeSubRoom = (sub: SubRoom, roomId: number): string => {
/**
* Serialize a room for a full-blob write, dropping the parts that belong to another table
* so the blob can never hold a stale copy: hydrated `SubRooms` (the `subroom` table's job),
* `Tags` (the `room_tag` table's job — see {@link setRoomTags}), and the derived engagement
* counters, which are zeroed rather than dropped so the key stays present (the
* `interaction` table's job — see {@link attachStats}).
* `Tags` (the `room_tag` table's job — see {@link setRoomTags}), `Roles` (the `room_role`
* table's job — see {@link setRoomRole}), and the derived engagement counters, which are
* zeroed rather than dropped so the key stays present (the `interaction` table's job —
* see {@link attachStats}).
*
* Because `Tags` is dropped here, a write that means to CHANGE a room's tags has to write
* the table itself; passing a room with a new `Tags` array through this silently discards
* Because `Tags` and `Roles` are dropped here, a write that means to CHANGE them has to
* write the table itself; passing a room with a new array through this silently discards
* it.
*/
const serializeRoom = (room: Room): string => {
const { SubRooms: _subRooms, Tags: _tags, Stats: stats, ...rest } = room
const { SubRooms: _subRooms, Tags: _tags, Roles: _roles, Stats: stats, ...rest } = room
return JSON.stringify({ ...rest, Stats: storedStats(stats) })
}
@@ -1652,6 +1520,70 @@ function roomsByTagsQuery(tagSets: string[][], where = ''): { sql: string; binds
return { sql: `SELECT ${ROOM_COLUMNS} FROM room r ${joins.join(' ')}${filter}`, binds }
}
// ---- Room roles -----------------------------------------------------------
// A room's roles live in `room_role`, not in the room blob (see ROOM_SCHEMA_DDL). The
// blob is stripped on write and the array is re-attached on read, the same arrangement
// as `room_tag`, so there is exactly one place a role is stored.
interface RoomRoleRow {
room_id: number
account_id: number
role: number
last_changed_by: number | null
invited_role: number
}
const toRoomRole = (row: RoomRoleRow): RoomRole => ({
AccountId: row.account_id,
Role: row.role,
LastChangedByAccountId: row.last_changed_by,
InvitedRole: row.invited_role,
})
/** The given rooms' role entries, grouped by room id (one query for the batch). */
async function rolesByRoom(db: D1Database, ids: number[]): Promise<Map<number, RoomRole[]>> {
const byRoom = new Map<number, RoomRole[]>()
if (ids.length === 0) return byRoom
const results = await selectInChunks<RoomRoleRow>(
db,
ids,
(placeholders) =>
`SELECT room_id, account_id, role, last_changed_by, invited_role FROM room_role
WHERE room_id IN (${placeholders}) ORDER BY account_id`
)
for (const row of results) {
const list = byRoom.get(row.room_id) ?? []
list.push(toRoomRole(row))
byRoom.set(row.room_id, list)
}
return byRoom
}
/**
* Fill in each room's `Roles` from `room_role`. Every room ends up with the key PRESENT —
* an empty array when it carries none — because {@link canManageRoom} and the client both
* read it directly and the blob no longer supplies one.
*/
async function attachRoles(db: D1Database, rooms: Room[]): Promise<void> {
const byRoom = await rolesByRoom(db, [...new Set(rooms.map(roomIdOf))])
for (const room of rooms) room.Roles = byRoom.get(roomIdOf(room)) ?? []
}
/**
* Seed a brand-new room's creator entry (Role 255, `last_changed_by` NULL like the
* imported creator rows) — the row every room minted by this module starts with.
*/
async function insertCreatorRole(db: D1Database, roomId: number, accountId: number): Promise<void> {
await db
.prepare(
`INSERT INTO room_role (room_id, account_id, role, last_changed_by, invited_role)
VALUES (?1, ?2, ?3, NULL, 0)
ON CONFLICT(room_id, account_id) DO UPDATE SET role = ?3`
)
.bind(roomId, accountId, Role.Creator)
.run()
}
/** Parse subroom rows and resolve their `CurrentSave` in one batched query. */
async function parseSubRoomRows(db: D1Database, rows: SubRoomRow[]): Promise<SubRoom[]> {
const subs = rows.map(parseSubRoomRow)
@@ -1801,7 +1733,8 @@ async function hydrateRoom(db: D1Database, room: Room | null): Promise<Room | nu
}
/**
* Hydrate many rooms' `SubRooms`, `Tags` and derived `Stats` (one batched query each).
* Hydrate many rooms' `SubRooms`, `Tags`, `Roles` and derived `Stats` (one batched query
* each).
*
* `Tags` is re-attached even for the feeds that already did so before ranking
* ({@link parseAllWithTags}) — it is one query for the page slice and it guarantees the key
@@ -1815,6 +1748,7 @@ async function hydrateRooms(
await Promise.all([
attachSubRooms(db, rooms),
attachTags(db, rooms),
attachRoles(db, rooms),
attachStats(db, rooms, stats),
])
return rooms
@@ -2103,6 +2037,19 @@ export async function seedRoomWithSubRooms(db: D1Database, room: Room): Promise<
if (Array.isArray(room.Tags) && room.Tags.length > 0) {
await setRoomTags(db, roomId, room.Tags as RoomTag[])
}
// Same for `Roles` — mirrors 0017's backfill of `room_role` from the blobs.
if (Array.isArray(room.Roles) && room.Roles.length > 0) {
await db.batch(
(room.Roles as RoomRole[]).map((r) =>
db
.prepare(
`INSERT OR IGNORE INTO room_role (room_id, account_id, role, last_changed_by, invited_role)
VALUES (?1, ?2, ?3, ?4, ?5)`
)
.bind(roomId, r.AccountId, r.Role ?? 0, r.LastChangedByAccountId, r.InvitedRole ?? 0)
)
)
}
for (const sub of subRooms) {
const subRoomId = Number(sub.SubRoomId)
await db
@@ -2144,6 +2091,8 @@ export async function deleteRoom(db: D1Database, roomId: number): Promise<void>
// Tag rows outlive the blob otherwise, and would keep answering `#tag` searches and
// category rows for a room nobody can open.
db.prepare('DELETE FROM room_tag WHERE room_id = ?1').bind(roomId),
// Role rows likewise — they'd keep the room in everyone's contributed list.
db.prepare('DELETE FROM room_role WHERE room_id = ?1').bind(roomId),
// Saves and permission overrides first — both are keyed by subroom, so they'd be
// unreachable once the subrooms themselves are gone.
db
@@ -2234,9 +2183,9 @@ export async function countRoomsByCreator(db: D1Database, accountId: number): Pr
* room the player made. A room matching BOTH halves appears once — the roles half is an
* EXISTS, not a join.
*
* Roles live inside the room blob rather than in a table of their own, so the match is a
* `json_each` over `$.Roles`. A room with no `Roles` key (or a null one) simply yields no
* rows there rather than erroring, so it only reaches the list if the account created it.
* The roles half matches on `room_role` (the table is authoritative — see ROOM_SCHEMA_DDL),
* so it is an indexed probe per room rather than the `json_each` over every blob it was
* when roles lived serialized in the room.
*/
export async function getContributedRooms(db: D1Database, accountId: number): Promise<Room[]> {
const { results } = await db
@@ -2244,8 +2193,8 @@ export async function getContributedRooms(db: D1Database, accountId: number): Pr
`SELECT ${ROOM_COLUMNS} FROM room
WHERE creator_account_id = ?1
OR EXISTS (
SELECT 1 FROM json_each(room.data, '$.Roles') AS role
WHERE json_extract(role.value, '$.AccountId') = ?1
SELECT 1 FROM room_role WHERE room_role.room_id = room.room_id
AND room_role.account_id = ?1
)`
)
.bind(accountId)
@@ -3078,20 +3027,17 @@ export async function getOrCreateDormRoom(db: D1Database, accountId: number): Pr
CreatorAccountId: accountId,
IsDorm: true,
Roles: [
{
AccountId: accountId,
Role: Role.Creator,
LastChangedByAccountId: null,
InvitedRole: Role.None,
},
{ AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 },
],
// Counters start at zero rather than inheriting the template dorm's (see cloneRoom).
Stats: storedStats(template?.Stats),
CreatedAt: new Date().toISOString(),
}
// serializeRoom drops any SubRooms carried over from the template; the dorm's own
// subroom is inserted into the subroom table below with a fresh globally-unique id.
// serializeRoom drops any SubRooms carried over from the template (and Roles — those
// go to their own table); the dorm's own subroom is inserted into the subroom table
// below with a fresh globally-unique id.
await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(serializeRoom(room)).run()
await insertCreatorRole(db, roomId, accountId)
const subRoom = await insertSubRoom(db, roomId, { ...templateSub, CreatorAccountId: accountId })
room.SubRooms = [subRoom]
// The template carries these (it was parsed), but a dorm minted without one wouldn't.
-3
View File
@@ -939,9 +939,6 @@ importers:
apps/notify:
dependencies:
'@repo/domain':
specifier: workspace:*
version: link:../../packages/domain
'@repo/hono-helpers':
specifier: workspace:*
version: link:../../packages/hono-helpers