[chat] Return the 2026 party message contract (#122)

This commit is contained in:
dukedotdev
2026-10-12 14:23:15 -04:00
committed by GitHub
parent aa5e83764e
commit 4d6a88f20e
3 changed files with 35 additions and 9 deletions
+14 -7
View File
@@ -4,7 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { mergePlayerSettings as mergeStoredPlayerSettings, readPlayerSettings } from '@repo/domain'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { validateAndGetAccountId, validateAndGetVersion } from '@repo/jwt'
import { censorSwears } from '../../api/src/sanitize'
import { NotificationType } from '../../notify/src/notification-types'
@@ -25,7 +25,7 @@ import {
json,
messageCountParam,
NOT_A_MEMBER_RESPONSE,
PartyChatThread,
PartyChatReadResponse,
PartyInviteSettings,
RenameThreadRequest,
SendMessageRequest,
@@ -891,7 +891,10 @@ const app = new Hono<App>()
},
],
responses: {
200: json(PartyChatThread, 'The caller’s party, or `{}` when they have none'),
200: json(
PartyChatReadResponse,
'2026: a list of party threads; older builds: a thread or {}'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -900,19 +903,22 @@ const app = new Hono<App>()
if (id === null) return c.body(null, 401)
// Path 1: already in a party. One query, no settings read.
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
const listResponse = version !== null && version >= '20260323'
const noParty = () => (listResponse ? c.json([]) : c.json({}))
let thread = await getPartyThreadForPlayer(c.env.DB, id)
// Path 2: not in one — the key is the only thing that can name a party to join.
if (thread === null) {
const chatThreadId = await readLatestPartyChatId(c.env, id)
if (chatThreadId === null) return c.json({})
if (chatThreadId === null) return noParty()
// Checked BEFORE the join: a gone thread, one of some other type, or a party
// too old to still be taking people isn't something to put anybody on —
// whoever wrote the key.
const meta = await getThreadMeta(c.env.DB, chatThreadId)
if (meta === null || meta.chatThreadType !== ChatThreadType.Party) return c.json({})
if (!isPartyJoinable(meta.createdAt)) return c.json({})
if (meta === null || meta.chatThreadType !== ChatThreadType.Party) return noParty()
if (!isPartyJoinable(meta.createdAt)) return noParty()
await addThreadMember(c.env.DB, chatThreadId, id)
// The same announcement the add-member route makes: the party learns someone
@@ -925,7 +931,8 @@ const app = new Hono<App>()
const messages = await getThreadMessages(c.env.DB, thread.chatThreadId, {
limit: DEFAULT_THREAD_MESSAGE_COUNT,
})
return c.json(toPartyChatThread(thread, messages))
const party = toPartyChatThread(thread, messages)
return listResponse ? c.json([party]) : c.json(party)
}
)
+7
View File
@@ -274,6 +274,13 @@ export const PartyChatThread = z.object({
ClubId: z.int().nullable().describe('Always null here — this worker serves no club threads'),
})
/** 2026 reads a thread list; older clients read a thread or an empty object. */
export const PartyChatReadResponse = z.union([
PartyChatThread,
z.array(PartyChatThread),
z.object({}).strict(),
])
/**
* `POST /thread/party` — the client's CreatePartyChat. A bare two-key wrapper, PascalCase
* like the thread inside it, with no `{ success, error, value }` envelope around it.
+14 -2
View File
@@ -50,10 +50,10 @@ function b64url(input: ArrayBuffer | string): string {
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function bearer(sub: number): Promise<Record<string, string>> {
async function bearer(sub: number, version?: string): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub: String(sub), exp: now + 3600 })
JSON.stringify({ sub: String(sub), exp: now + 3600, 'rn.ver': version })
)}`
const key = await crypto.subtle.importKey(
'raw',
@@ -74,6 +74,18 @@ beforeAll(async () => {
})
describe('chat endpoints', () => {
it('2026 party reads return a list, including when the caller has no party', async () => {
const empty = await SELF.fetch(`${ORIGIN}/thread/party`, {
headers: await bearer(42601, '20260323.14'),
})
expect(empty.status).toBe(200)
expect(await empty.json()).toEqual([])
const threadId = await createThread(env.DB, [42601], null, undefined, ChatThreadType.Party)
const present = await SELF.fetch(`${ORIGIN}/thread/party`, {
headers: await bearer(42601, '20260323.14'),
})
expect(await present.json()).toMatchObject([{ ChatThreadId: threadId }])
})
it('GET / reports service status', async () => {
const res = await SELF.fetch(`${ORIGIN}/`)
expect(res.status).toBe(200)