mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
Fix #13 add basic admin panel for now
This commit is contained in:
@@ -15,10 +15,13 @@ instance holds the shared hub state (one process across all connections).
|
||||
- `GET /hub/v1` (WebSocket upgrade) — the hub. Forwarded to the Durable Object.
|
||||
Non-upgrade requests get `426`.
|
||||
- `POST /internal/notify` — `{ playerId, notificationType, data? }`. Send to a
|
||||
player's connections, queueing if they're offline. **Unauthenticated; internal
|
||||
use only (TODO: protect).**
|
||||
player's connections, queueing if they're offline. **Admin-gated**: requires a
|
||||
Bearer token carrying an admin role (`developer`/`moderator`) in its `role` claim.
|
||||
- `POST /internal/broadcast` — `{ notificationType, data? }`. Send to every
|
||||
connected client.
|
||||
connected client. Same admin-role gate as `/internal/notify`.
|
||||
- `POST /internal/coach-message-all` — `{ messageContent }`. Broadcast a coach/system
|
||||
`MessageReceived` to every connected client (online-only; not persisted). Same
|
||||
admin-role gate.
|
||||
|
||||
## Hub protocol
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { DurableObject } from 'cloudflare:workers'
|
||||
|
||||
import { NotificationType } from './notification-types'
|
||||
|
||||
import type { Env } from './context'
|
||||
|
||||
/**
|
||||
@@ -34,6 +36,12 @@ interface HubMessage {
|
||||
arguments?: unknown[]
|
||||
}
|
||||
|
||||
/** The Coach system account — the `FromPlayerId` on a coach message (see coachMessageAll). */
|
||||
const COACH_PLAYER_ID = 1
|
||||
|
||||
/** The Message `Type` a coach/system message carries (a Message-model enum, not a NotificationType). */
|
||||
const COACH_MESSAGE_TYPE = 100
|
||||
|
||||
export class NotificationsHub extends DurableObject<Env> {
|
||||
constructor(ctx: DurableObjectState, env: Env) {
|
||||
super(ctx, env)
|
||||
@@ -227,7 +235,52 @@ export class NotificationsHub extends DurableObject<Env> {
|
||||
data?: Record<string, unknown>
|
||||
): Promise<{ delivered: number; queued: boolean }> {
|
||||
const payload = this.buildNotificationPayload(notificationType, data)
|
||||
const delivered = this.deliverToPlayer(playerId, payload)
|
||||
|
||||
if (delivered === 0) {
|
||||
this.ctx.storage.sql.exec(
|
||||
'INSERT INTO pending (playerId, payload) VALUES (?, ?)',
|
||||
playerId,
|
||||
payload
|
||||
)
|
||||
return { delivered: 0, queued: true }
|
||||
}
|
||||
return { delivered, queued: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a "coach" message to every connected client (mirrors the reference
|
||||
* `SendCoachMessageAll`, using the hub's live connections as the online set): each
|
||||
* handshaken socket gets a `MessageReceived` notification carrying a Message from
|
||||
* the Coach account (player 1). Online-only — nothing is queued or persisted, and
|
||||
* it's a broadcast, so the Message has no per-recipient `ToPlayerId`. Returns how
|
||||
* many connected clients were messaged.
|
||||
*/
|
||||
async coachMessageAll(content: string): Promise<{ sent: number }> {
|
||||
const payload = this.buildNotificationPayload(NotificationType.MessageReceived, {
|
||||
FromPlayerId: COACH_PLAYER_ID,
|
||||
Type: COACH_MESSAGE_TYPE,
|
||||
Data: content,
|
||||
})
|
||||
return { sent: this.broadcastToConnected(payload) }
|
||||
}
|
||||
|
||||
/** Broadcast a notification to every connected (handshaken) client. */
|
||||
async broadcast(
|
||||
notificationType: string | number,
|
||||
data?: Record<string, unknown>
|
||||
): Promise<{ delivered: number }> {
|
||||
return { delivered: this.broadcastToConnected(this.buildNotificationPayload(notificationType, data)) }
|
||||
}
|
||||
|
||||
// ---- Helpers -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Send an already-built `Notification` payload to every live socket of a player's
|
||||
* subscribed connections; returns how many sockets received it (0 = offline). The
|
||||
* shared send path for {@link notifyPlayer} and {@link coachMessageAll}.
|
||||
*/
|
||||
private deliverToPlayer(playerId: number, payload: string): number {
|
||||
const connectionIds = this.ctx.storage.sql
|
||||
.exec<{ connectionId: string }>(
|
||||
'SELECT DISTINCT connectionId FROM subscriptions WHERE playerId = ?',
|
||||
@@ -243,24 +296,15 @@ export class NotificationsHub extends DurableObject<Env> {
|
||||
delivered++
|
||||
}
|
||||
}
|
||||
|
||||
if (delivered === 0) {
|
||||
this.ctx.storage.sql.exec(
|
||||
'INSERT INTO pending (playerId, payload) VALUES (?, ?)',
|
||||
playerId,
|
||||
payload
|
||||
)
|
||||
return { delivered: 0, queued: true }
|
||||
}
|
||||
return { delivered, queued: false }
|
||||
return delivered
|
||||
}
|
||||
|
||||
/** Broadcast a notification to every connected (handshaken) client. */
|
||||
async broadcast(
|
||||
notificationType: string | number,
|
||||
data?: Record<string, unknown>
|
||||
): Promise<{ delivered: number }> {
|
||||
const payload = this.buildNotificationPayload(notificationType, data)
|
||||
/**
|
||||
* Send an already-built `Notification` payload to every connected (handshaken)
|
||||
* socket; returns how many received it. Shared by {@link broadcast} and
|
||||
* {@link coachMessageAll}.
|
||||
*/
|
||||
private broadcastToConnected(payload: string): number {
|
||||
let delivered = 0
|
||||
for (const ws of this.ctx.getWebSockets()) {
|
||||
const state = ws.deserializeAttachment() as SocketState | null
|
||||
@@ -268,11 +312,9 @@ export class NotificationsHub extends DurableObject<Env> {
|
||||
ws.send(this.invocation('Notification', [payload]))
|
||||
delivered++
|
||||
}
|
||||
return { delivered }
|
||||
return delivered
|
||||
}
|
||||
|
||||
// ---- Helpers -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the `Notification` argument: a JSON string `{ Id, Msg }`
|
||||
* (null values are dropped from `Msg`). `Id` is a client-defined tag — a
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Hono } from 'hono'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
import { validateAndGetRoles } from '@repo/jwt'
|
||||
|
||||
import { NotificationsHub } from './notifications-hub'
|
||||
|
||||
@@ -29,21 +29,23 @@ function isNotificationType(value: unknown): value is string | number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Account ids allowed to call the internal send/broadcast endpoints. Temporary
|
||||
* lockdown until these are properly gated — for now only these admins can push
|
||||
* notifications through the shared hub.
|
||||
* Roles allowed to call the internal send/broadcast endpoints. These are the
|
||||
* operator-granted elevated roles (see the auth worker's `role` claim, set from an
|
||||
* account's isDeveloper/isModerator flags via the admin CLI) — so a staffer grants
|
||||
* themselves the role and can then push notifications through the shared hub, e.g.
|
||||
* from the accounts web UI's maintenance control.
|
||||
*/
|
||||
const ADMIN_ACCOUNT_IDS = new Set([1, 2])
|
||||
const ADMIN_ROLES = new Set(['developer', 'moderator'])
|
||||
|
||||
/**
|
||||
* Gates the `/internal/*` endpoints on a valid Bearer token whose `sub` is an
|
||||
* allowed admin account. 401 for a missing/invalid token, 403 for a valid token
|
||||
* that isn't an admin.
|
||||
* Gates the `/internal/*` endpoints on a valid Bearer token that carries one of the
|
||||
* {@link ADMIN_ROLES} in its `role` claim. 401 for a missing/invalid token, 403 for a
|
||||
* valid token that lacks an admin role.
|
||||
*/
|
||||
const requireAdmin: MiddlewareHandler<App> = async (c, next) => {
|
||||
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
if (accountId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!ADMIN_ACCOUNT_IDS.has(accountId)) return c.json({ error: 'Forbidden' }, 403)
|
||||
const roles = await validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
if (roles === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!roles.some((role) => ADMIN_ROLES.has(role))) return c.json({ error: 'Forbidden' }, 403)
|
||||
await next()
|
||||
}
|
||||
|
||||
@@ -122,5 +124,15 @@ const app = new Hono<App>()
|
||||
return c.json({ success: true, ...result })
|
||||
})
|
||||
|
||||
// Send a coach/system direct message to every currently-online player.
|
||||
.post('/internal/coach-message-all', async (c) => {
|
||||
const body = await c.req.json<{ messageContent?: string }>().catch(() => null)
|
||||
const content = typeof body?.messageContent === 'string' ? body.messageContent.trim() : ''
|
||||
if (content === '') return c.json({ error: 'messageContent is required' }, 400)
|
||||
const result =
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).coachMessageAll(content)
|
||||
return c.json({ success: true, ...result })
|
||||
})
|
||||
|
||||
export { NotificationsHub }
|
||||
export default app
|
||||
|
||||
@@ -22,10 +22,12 @@ function b64url(input: ArrayBuffer | string): string {
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
async function bearer(sub: string): Promise<Record<string, string>> {
|
||||
async function bearer(sub: string, roles?: string[]): Promise<Record<string, string>> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const claims: Record<string, unknown> = { sub, exp: now + 3600 }
|
||||
if (roles) claims.role = roles
|
||||
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
||||
JSON.stringify({ sub, exp: now + 3600 })
|
||||
JSON.stringify(claims)
|
||||
)}`
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
@@ -104,14 +106,14 @@ async function connect(
|
||||
|
||||
const send = (ws: WebSocket, msg: HubRecord) => ws.send(JSON.stringify(msg) + RS)
|
||||
|
||||
// The /internal/* endpoints are admin-gated, so default to an admin (account 1)
|
||||
// Bearer token; pass `auth` to override (e.g. to test the 401/403 paths).
|
||||
// The /internal/* endpoints are admin-gated (a token carrying an admin role), so
|
||||
// default to a moderator token; pass `auth` to override (e.g. the 401/403 paths).
|
||||
const post = async (path: string, body: unknown, auth?: Record<string, string>) =>
|
||||
exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(auth ?? (await bearer('1'))),
|
||||
...(auth ?? (await bearer('1', ['gameClient', 'moderator']))),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
@@ -150,14 +152,18 @@ describe('internal endpoint auth', () => {
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('403 for a valid token that is not an admin', async () => {
|
||||
const res = await post('/internal/notify', body, await bearer('3'))
|
||||
test('403 for a valid token without an admin role', async () => {
|
||||
const res = await post('/internal/notify', body, await bearer('3', ['gameClient']))
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
test('admin accounts 1 and 2 are allowed', async () => {
|
||||
for (const sub of ['1', '2']) {
|
||||
const res = await post('/internal/broadcast', { notificationType: 1 }, await bearer(sub))
|
||||
test('tokens carrying an admin role are allowed', async () => {
|
||||
for (const role of ['developer', 'moderator']) {
|
||||
const res = await post(
|
||||
'/internal/broadcast',
|
||||
{ notificationType: 1 },
|
||||
await bearer('3', ['gameClient', role])
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
}
|
||||
})
|
||||
@@ -251,6 +257,35 @@ describe('notification delivery', () => {
|
||||
ws.close()
|
||||
})
|
||||
|
||||
test('coach-message-all messages every connected client', async () => {
|
||||
const a = await connect('coach-a')
|
||||
const b = await connect('coach-b')
|
||||
|
||||
const res = await post('/internal/coach-message-all', { messageContent: 'hello all' })
|
||||
expect(res.status).toBe(200)
|
||||
expect(((await res.json()) as { sent: number }).sent).toBeGreaterThanOrEqual(2)
|
||||
|
||||
const noteA = await a.waitFor((r) => r.type === 1 && r.target === 'Notification')
|
||||
const payloadA = JSON.parse((noteA.arguments as string[])[0]) as {
|
||||
Id: string
|
||||
Msg: Record<string, unknown>
|
||||
}
|
||||
expect(payloadA.Id).toBe('2') // MessageReceived
|
||||
expect(payloadA.Msg).toMatchObject({ FromPlayerId: 1, Type: 100, Data: 'hello all' })
|
||||
|
||||
const noteB = await b.waitFor((r) => r.type === 1 && r.target === 'Notification')
|
||||
const payloadB = JSON.parse((noteB.arguments as string[])[0]) as { Msg: Record<string, unknown> }
|
||||
expect(payloadB.Msg).toMatchObject({ Data: 'hello all' })
|
||||
|
||||
a.ws.close()
|
||||
b.ws.close()
|
||||
})
|
||||
|
||||
test('coach-message-all 400s on an empty message', async () => {
|
||||
const res = await post('/internal/coach-message-all', { messageContent: ' ' })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
test('emits a numeric notificationType as a string Id', async () => {
|
||||
// The client dispatches on a string Id, so numeric codes (e.g. econ's
|
||||
// NotificationType enum) must be serialized as strings or they're dropped.
|
||||
|
||||
Reference in New Issue
Block a user