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