From 4d6a88f20e0fe16cfe22243a15d34c7fc320f155 Mon Sep 17 00:00:00 2001 From: dukedotdev Date: Mon, 12 Oct 2026 14:23:15 -0400 Subject: [PATCH] [chat] Return the 2026 party message contract (#122) --- apps/chat/src/chat.app.ts | 21 ++++++++++++++------- apps/chat/src/openapi.ts | 7 +++++++ apps/chat/src/test/integration/api.test.ts | 16 ++++++++++++++-- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/apps/chat/src/chat.app.ts b/apps/chat/src/chat.app.ts index b7b7d15..6092e7e 100644 --- a/apps/chat/src/chat.app.ts +++ b/apps/chat/src/chat.app.ts @@ -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() }, ], 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() 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() 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) } ) diff --git a/apps/chat/src/openapi.ts b/apps/chat/src/openapi.ts index 794d265..e163233 100644 --- a/apps/chat/src/openapi.ts +++ b/apps/chat/src/openapi.ts @@ -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. diff --git a/apps/chat/src/test/integration/api.test.ts b/apps/chat/src/test/integration/api.test.ts index 7ee600c..88b7c3a 100644 --- a/apps/chat/src/test/integration/api.test.ts +++ b/apps/chat/src/test/integration/api.test.ts @@ -50,10 +50,10 @@ function b64url(input: ArrayBuffer | string): string { return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } -async function bearer(sub: number): Promise> { +async function bearer(sub: number, version?: string): Promise> { 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)