diff --git a/apps/web/src/components/game/GameLog.test.ts b/apps/web/src/components/game/GameLog.test.ts new file mode 100644 index 0000000..53bb04d --- /dev/null +++ b/apps/web/src/components/game/GameLog.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { parseLogEntries } from "./GameLog"; + +function makeBatch(command: string, seat: "host" | "away", version: number) { + return { + command, + events: "[]", + seat, + version, + createdAt: Date.now(), + }; +} + +describe("parseLogEntries", () => { + it("renders redacted hidden setup commands with the expected label", () => { + const entries = parseLogEntries( + [makeBatch(JSON.stringify({ type: "SET_MONSTER" }), "away", 1)], + "host", + ); + + expect(entries).toHaveLength(1); + expect(entries[0]?.text).toBe("SET A STEREOTYPE"); + expect(entries[0]?.actor).toBe("opponent"); + }); + + it("ignores malformed or unknown commands without crashing", () => { + const batches = [ + makeBatch("{", "away", 1), + makeBatch(JSON.stringify({ type: "UNKNOWN" }), "away", 2), + makeBatch(JSON.stringify({ type: "END_TURN" }), "host", 3), + ]; + + expect(() => parseLogEntries(batches, "host")).not.toThrow(); + + const entries = parseLogEntries(batches, "host"); + expect(entries).toHaveLength(1); + expect(entries[0]?.text).toBe("END TURN"); + expect(entries[0]?.actor).toBe("you"); + }); +}); diff --git a/apps/web/src/components/game/GameLog.tsx b/apps/web/src/components/game/GameLog.tsx index e5b2c68..09236ea 100644 --- a/apps/web/src/components/game/GameLog.tsx +++ b/apps/web/src/components/game/GameLog.tsx @@ -37,7 +37,7 @@ const COMMAND_LABELS: Record // Cycle through bubble images const BUBBLE_IMAGES = [BUBBLE_SPEECH, BUBBLE_BURST, BUBBLE_WAVY]; -function parseLogEntries(batches: EventBatch[], mySeat: Seat): LogEntry[] { +export function parseLogEntries(batches: EventBatch[], mySeat: Seat): LogEntry[] { const entries: LogEntry[] = []; let bubbleIdx = 0; diff --git a/convex/__tests__/agentHttpRoutes.test.ts b/convex/__tests__/agentHttpRoutes.test.ts index 4fbc302..5c08ef3 100644 --- a/convex/__tests__/agentHttpRoutes.test.ts +++ b/convex/__tests__/agentHttpRoutes.test.ts @@ -25,4 +25,12 @@ describe("agent HTTP routes", () => { /path:\s*"\/api\/agent\/game\/view"[\s\S]*?ctx\.runQuery\(\s*internal\.game\.getPlayerViewAsActor\s*,\s*\{[\s\S]*?actorUserId:\s*agent\.userId/, ); }); + + it("resolves agent match metadata through internal getMatchMetaAsActor", () => { + const httpSource = readSource("convex/http.ts"); + + expect(httpSource).toMatch( + /resolveMatchAndSeat[\s\S]*?ctx\.runQuery\(\s*internalApi\.game\.getMatchMetaAsActor\s*,\s*\{[\s\S]*?actorUserId:\s*agentUserId/, + ); + }); }); diff --git a/convex/__tests__/game.integration.test.ts b/convex/__tests__/game.integration.test.ts index 8e4c267..d972dcb 100644 --- a/convex/__tests__/game.integration.test.ts +++ b/convex/__tests__/game.integration.test.ts @@ -6,6 +6,7 @@ import { getDeckCardIdsFromDeckData, findStageByNumber, normalizeFirstClearBonus, + __test, } from "../game"; // ═══════════════════════════════════════════════════════════════════════ @@ -392,10 +393,10 @@ describe("story queries", () => { }); }); -// ── Match Meta (public query) ──────────────────────────────────────── +// ── Match Meta (participant-only query) ─────────────────────────────── describe("getMatchMeta", () => { - test("returns match metadata for a PvP lobby match", async () => { + test("returns match metadata for a PvP lobby match participant", async () => { const t = setupTestConvex(); await t.mutation(api.seed.seedAll, {}); @@ -406,7 +407,7 @@ describe("getMatchMeta", () => { }); const lobby = await asAlice.mutation(api.game.createPvpLobby, {}); - const meta = await t.query(api.game.getMatchMeta, { + const meta = await asAlice.query(api.game.getMatchMeta, { matchId: lobby.matchId, }); @@ -1311,6 +1312,65 @@ describe("submitAction and match views", () => { ).rejects.toThrow(/only access your own seat/); }); + test("getMatchMeta rejects unauthenticated query", async () => { + const t = setupTestConvex(); + await t.mutation(api.seed.seedAll, {}); + const { matchId } = await createActivePvpMatch(t); + + await expect( + t.query(api.game.getMatchMeta, { matchId }), + ).rejects.toThrow(); + }); + + test("getMatchMeta rejects authenticated non-participant", async () => { + const t = setupTestConvex(); + await t.mutation(api.seed.seedAll, {}); + const { matchId } = await createActivePvpMatch(t); + const asCharlie = await seedUser(t, CHARLIE, api); + + await expect( + asCharlie.query(api.game.getMatchMeta, { matchId }), + ).rejects.toThrow(/participant/); + }); + + test("getMatchMeta succeeds for participant", async () => { + const t = setupTestConvex(); + await t.mutation(api.seed.seedAll, {}); + const { asAlice, matchId } = await createActivePvpMatch(t); + + const meta = await asAlice.query(api.game.getMatchMeta, { matchId }); + expect(meta).toBeTruthy(); + expect((meta as any).hostId).toBeTruthy(); + expect((meta as any).awayId).toBeTruthy(); + }); + + test("getRecentEvents rejects unauthenticated query", async () => { + const t = setupTestConvex(); + await t.mutation(api.seed.seedAll, {}); + const { matchId } = await createActivePvpMatch(t); + + await expect( + t.query(api.game.getRecentEvents, { + matchId, + sinceVersion: 0, + }), + ).rejects.toThrow(); + }); + + test("getRecentEvents rejects authenticated non-participant", async () => { + const t = setupTestConvex(); + await t.mutation(api.seed.seedAll, {}); + const { matchId } = await createActivePvpMatch(t); + const asCharlie = await seedUser(t, CHARLIE, api); + + await expect( + asCharlie.query(api.game.getRecentEvents, { + matchId, + sinceVersion: 0, + }), + ).rejects.toThrow(/participant/); + }); + test("getRecentEvents returns event batches", async () => { const t = setupTestConvex(); await t.mutation(api.seed.seedAll, {}); @@ -1323,7 +1383,7 @@ describe("submitAction and match views", () => { seat: "host", }); - const events = await t.query(api.game.getRecentEvents, { + const events = await asAlice.query(api.game.getRecentEvents, { matchId, sinceVersion: 0, }); @@ -1333,6 +1393,133 @@ describe("submitAction and match views", () => { } }); + test("getRecentEvents redacts opponent hidden setup commands but preserves own payload", async () => { + const t = setupTestConvex(); + await t.mutation(api.seed.seedAll, {}); + const { asAlice, asBob, matchId } = await createActivePvpMatch(t); + + const advanceHostToMainPhase = async () => { + for (let i = 0; i < 8; i += 1) { + const hostViewJson = await asAlice.query(api.game.getPlayerView, { + matchId, + seat: "host", + }); + const hostView = JSON.parse(hostViewJson); + if (hostView.currentPhase === "main") return; + await asAlice.mutation(api.game.submitAction, { + matchId, + command: JSON.stringify({ type: "ADVANCE_PHASE" }), + seat: "host", + }); + } + throw new Error("Failed to reach main phase for hidden setup test."); + }; + + const submitHiddenSetupFromHand = async () => { + await advanceHostToMainPhase(); + const hostViewJson = await asAlice.query(api.game.getPlayerView, { + matchId, + seat: "host", + }); + const hostView = JSON.parse(hostViewJson); + const hand = Array.isArray(hostView.hand) ? (hostView.hand as string[]) : []; + const hiddenTypes = ["SET_MONSTER", "SET_SPELL_TRAP"] as const; + + for (const cardId of hand) { + for (const type of hiddenTypes) { + try { + const result = await asAlice.mutation(api.game.submitAction, { + matchId, + command: JSON.stringify({ type, cardId }), + seat: "host", + }); + const events = JSON.parse(result.events) as unknown[]; + if (Array.isArray(events) && events.length > 0) { + return { type, cardId }; + } + } catch { + // Try the next candidate command. + } + } + } + + throw new Error("Could not submit a hidden setup command from the host hand."); + }; + + const submitted = await submitHiddenSetupFromHand(); + const extractType = (commandJson: string) => { + try { + return JSON.parse(commandJson)?.type as string | undefined; + } catch { + return undefined; + } + }; + + const awayEvents = await asBob.query(api.game.getRecentEvents, { + matchId, + sinceVersion: 0, + }); + const awayBatch = awayEvents.find( + (batch: any) => + batch.seat === "host" && extractType(String(batch.command)) === submitted.type, + ); + expect(awayBatch).toBeTruthy(); + expect(JSON.parse(String(awayBatch!.command))).toEqual({ type: submitted.type }); + + const hostEvents = await asAlice.query(api.game.getRecentEvents, { + matchId, + sinceVersion: 0, + }); + const hostBatch = hostEvents.find( + (batch: any) => + batch.seat === "host" && extractType(String(batch.command)) === submitted.type, + ); + expect(hostBatch).toBeTruthy(); + expect(JSON.parse(String(hostBatch!.command))).toEqual({ + type: submitted.type, + cardId: submitted.cardId, + }); + }); + + test("getRecentEvents keeps non-hidden commands unchanged", async () => { + const t = setupTestConvex(); + await t.mutation(api.seed.seedAll, {}); + const { asAlice, asBob, matchId } = await createActivePvpMatch(t); + + const expectedCommand = { type: "ADVANCE_PHASE", marker: "keep" }; + await asAlice.mutation(api.game.submitAction, { + matchId, + command: JSON.stringify(expectedCommand), + seat: "host", + }); + + const awayEvents = await asBob.query(api.game.getRecentEvents, { + matchId, + sinceVersion: 0, + }); + const awayBatch = awayEvents.find((batch: any) => { + try { + const command = JSON.parse(String(batch.command)); + return batch.seat === "host" && command?.type === "ADVANCE_PHASE"; + } catch { + return false; + } + }); + expect(awayBatch).toBeTruthy(); + expect(JSON.parse(String(awayBatch!.command))).toEqual(expectedCommand); + }); + + test("recent-event redaction normalizes malformed command payloads to UNKNOWN", () => { + const normalized = __test.normalizeCommandForViewer("{", "away", "host"); + expect(JSON.parse(normalized)).toEqual({ type: "UNKNOWN" }); + + const redacted = __test.redactRecentEventCommands( + [{ command: "{", seat: "away", version: 1 }], + "host", + ); + expect(JSON.parse(String(redacted[0]?.command))).toEqual({ type: "UNKNOWN" }); + }); + test("getLatestSnapshotVersion returns number", async () => { const t = setupTestConvex(); await t.mutation(api.seed.seedAll, {}); @@ -1356,7 +1543,7 @@ describe("submitAction and match views", () => { seat: "host", }); - const meta = await t.query(api.game.getMatchMeta, { matchId }); + const meta = await asAlice.query(api.game.getMatchMeta, { matchId }); expect(meta).toBeTruthy(); expect((meta as any).status).toBe("ended"); // Host surrendered, so away wins @@ -1639,7 +1826,7 @@ describe("story end-to-end flow", () => { expect(context!.outcome).toBeNull(); // 3. Match meta shows active - const metaBefore = await t.query(api.game.getMatchMeta, { + const metaBefore = await asAlice.query(api.game.getMatchMeta, { matchId: battle.matchId, }); expect((metaBefore as any).status).toBe("active"); @@ -1653,7 +1840,7 @@ describe("story end-to-end flow", () => { }); // 5. Match meta shows ended - const metaAfter = await t.query(api.game.getMatchMeta, { + const metaAfter = await asAlice.query(api.game.getMatchMeta, { matchId: battle.matchId, }); expect((metaAfter as any).status).toBe("ended"); @@ -1734,7 +1921,7 @@ describe("PvP end-to-end flow", () => { const { asAlice, asBob, matchId } = await createActivePvpMatch(t); // Match is active - const meta = await t.query(api.game.getMatchMeta, { matchId }); + const meta = await asAlice.query(api.game.getMatchMeta, { matchId }); expect((meta as any).status).toBe("active"); expect((meta as any).mode).toBe("pvp"); @@ -1759,7 +1946,7 @@ describe("PvP end-to-end flow", () => { }); // Match ended, host wins (Bob surrendered) - const metaAfter = await t.query(api.game.getMatchMeta, { matchId }); + const metaAfter = await asAlice.query(api.game.getMatchMeta, { matchId }); expect((metaAfter as any).status).toBe("ended"); expect((metaAfter as any).winner).toBe("host"); }); @@ -2010,7 +2197,7 @@ describe("multi-action game sequences", () => { await t.mutation(api.seed.seedAll, {}); const { asAlice, matchId } = await createActivePvpMatch(t); - const eventsBefore = await t.query(api.game.getRecentEvents, { + const eventsBefore = await asAlice.query(api.game.getRecentEvents, { matchId, sinceVersion: 0, }); @@ -2022,7 +2209,7 @@ describe("multi-action game sequences", () => { seat: "host", }); - const eventsAfter = await t.query(api.game.getRecentEvents, { + const eventsAfter = await asAlice.query(api.game.getRecentEvents, { matchId, sinceVersion: 0, }); @@ -2364,7 +2551,7 @@ describe("off-turn surrender", () => { seat: "away", }); - const meta = await t.query(api.game.getMatchMeta, { matchId }); + const meta = await asBob.query(api.game.getMatchMeta, { matchId }); expect((meta as any).status).toBe("ended"); expect((meta as any).winner).toBe("host"); }); @@ -2388,7 +2575,7 @@ describe("off-turn surrender", () => { seat: "host", }); - const meta = await t.query(api.game.getMatchMeta, { matchId }); + const meta = await asAlice.query(api.game.getMatchMeta, { matchId }); expect((meta as any).status).toBe("ended"); expect((meta as any).winner).toBe("away"); }); diff --git a/convex/game.ts b/convex/game.ts index c329313..1721029 100644 --- a/convex/game.ts +++ b/convex/game.ts @@ -1527,6 +1527,65 @@ function resolveSeatForUser(meta: any, userId: string): "host" | "away" | null { return null; } +const HIDDEN_SETUP_COMMAND_TYPES = new Set(["SET_MONSTER", "SET_SPELL_TRAP"]); + +function normalizeSeat(value: unknown): "host" | "away" | null { + if (value === "host" || value === "away") return value; + return null; +} + +function normalizeCommandForViewer( + commandJson: unknown, + batchSeat: "host" | "away" | null, + viewerSeat: "host" | "away", +): string { + if (typeof commandJson !== "string") { + return JSON.stringify({ type: "UNKNOWN" }); + } + + try { + const parsed = JSON.parse(commandJson) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return JSON.stringify({ type: "UNKNOWN" }); + } + + const commandType = typeof (parsed as Record).type === "string" + ? String((parsed as Record).type) + : ""; + if (!commandType) { + return JSON.stringify({ type: "UNKNOWN" }); + } + + if ( + batchSeat && + batchSeat !== viewerSeat && + HIDDEN_SETUP_COMMAND_TYPES.has(commandType) + ) { + return JSON.stringify({ type: commandType }); + } + + return commandJson; + } catch { + return JSON.stringify({ type: "UNKNOWN" }); + } +} + +function redactRecentEventCommands( + batches: any[], + viewerSeat: "host" | "away", +) { + return batches.map((batch) => { + const command = normalizeCommandForViewer( + (batch as any)?.command, + normalizeSeat((batch as any)?.seat), + viewerSeat, + ); + + if ((batch as any)?.command === command) return batch; + return { ...batch, command }; + }); +} + async function requireMatchParticipant( ctx: any, matchId: string, @@ -1586,6 +1645,8 @@ export const __test = { assertActorMatchesAuthenticatedUser, requireMatchParticipant, assertStoryMatchRequesterAuthorized, + normalizeCommandForViewer, + redactRecentEventCommands, }; async function requireSeatOwnership( @@ -1593,6 +1654,23 @@ async function requireSeatOwnership( matchId: string, seat: "host" | "away", actorUserId: string, +) { + const { meta, seat: resolvedSeat } = await requireMatchParticipation( + ctx, + matchId, + actorUserId, + ); + if (resolvedSeat !== seat) { + throw new ConvexError("You can only access your own seat."); + } + + return meta; +} + +async function requireMatchParticipation( + ctx: any, + matchId: string, + actorUserId: string, ) { const meta = await match.getMatchMeta(ctx, { matchId }); if (!meta) { @@ -1603,11 +1681,8 @@ async function requireSeatOwnership( if (!resolvedSeat) { throw new ConvexError("You are not a participant in this match."); } - if (resolvedSeat !== seat) { - throw new ConvexError("You can only access your own seat."); - } - return meta; + return { meta, seat: resolvedSeat }; } async function submitActionForActor( @@ -2143,13 +2218,43 @@ export const getOpenPromptAsActor = internalQuery({ export const getMatchMeta = query({ args: { matchId: v.string() }, returns: v.any(), - handler: async (ctx, args) => match.getMatchMeta(ctx, args), + handler: async (ctx, args) => { + const user = await requireUser(ctx); + const { meta } = await requireMatchParticipation(ctx, args.matchId, user._id); + return meta; + }, +}); + +export const getMatchMetaAsActor = internalQuery({ + args: { + matchId: v.string(), + actorUserId: v.id("users"), + }, + returns: v.any(), + handler: async (ctx, args) => { + const { meta } = await requireMatchParticipation( + ctx, + args.matchId, + args.actorUserId, + ); + return meta; + }, }); export const getRecentEvents = query({ args: { matchId: v.string(), sinceVersion: v.number() }, returns: v.any(), - handler: async (ctx, args) => match.getRecentEvents(ctx, args), + handler: async (ctx, args) => { + const user = await requireUser(ctx); + const { seat: viewerSeat } = await requireMatchParticipation( + ctx, + args.matchId, + user._id, + ); + const batches = await match.getRecentEvents(ctx, args); + const normalizedBatches = Array.isArray(batches) ? batches : []; + return redactRecentEventCommands(normalizedBatches, viewerSeat); + }, }); export const getPublicEventsAsActor = internalQuery({ diff --git a/convex/http.matchAccess.test.ts b/convex/http.matchAccess.test.ts index 5a9aa47..ca6ec9a 100644 --- a/convex/http.matchAccess.test.ts +++ b/convex/http.matchAccess.test.ts @@ -19,6 +19,7 @@ describe("resolveMatchAndSeat", () => { expect(runQuery).toHaveBeenCalledTimes(1); expect(runQuery.mock.calls[0]![1]).toEqual({ matchId: "match_1", + actorUserId: "host_user", }); }); diff --git a/convex/http.ts b/convex/http.ts index 15191bf..ad8bfa0 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -136,8 +136,9 @@ export async function resolveMatchAndSeat( matchId: string, requestedSeat?: string, ) { - const meta = await ctx.runQuery(api.game.getMatchMeta, { + const meta = await ctx.runQuery(internalApi.game.getMatchMetaAsActor, { matchId, + actorUserId: agentUserId, }); if (!meta) { throw new Error("Match not found"); @@ -1628,7 +1629,10 @@ async function buildTelegramMatchSummary( ctx: { runQuery: any }, args: { matchId: string; userId: string; page?: number }, ): Promise<{ text: string; replyMarkup: TelegramInlineKeyboardMarkup }> { - const meta = await ctx.runQuery(api.game.getMatchMeta, { matchId: args.matchId }); + const meta = await ctx.runQuery(internalApi.game.getMatchMetaAsActor, { + matchId: args.matchId, + actorUserId: args.userId, + }); const status = String((meta as any)?.status ?? "unknown").toUpperCase(); const mode = String((meta as any)?.mode ?? "unknown").toUpperCase(); const winner = (meta as any)?.winner ? String((meta as any).winner).toUpperCase() : null; @@ -1844,7 +1848,10 @@ async function handleTelegramCallbackQuery( throw new Error("Action token expired. Refresh and try again."); } - const meta = await ctx.runQuery(api.game.getMatchMeta, { matchId: tokenPayload.matchId }); + const meta = await ctx.runQuery(internalApi.game.getMatchMetaAsActor, { + matchId: tokenPayload.matchId, + actorUserId: userId, + }); if (!meta) { throw new Error("Match not found."); } diff --git a/convex/telegram.ts b/convex/telegram.ts index ee38e22..0f24d4b 100644 --- a/convex/telegram.ts +++ b/convex/telegram.ts @@ -1,5 +1,5 @@ import { v } from "convex/values"; -import { api, internal } from "./_generated/api"; +import { internal } from "./_generated/api"; import { internalAction, internalMutation, internalQuery, mutation, query } from "./_generated/server"; import { requireUser } from "./auth"; import { getTelegramMiniAppDeepLink } from "./telegramLinks"; @@ -504,7 +504,10 @@ export const notifyUserMatchTransition = internalAction({ const chainEvent = events.find((event) => event?.type === "CHAIN_STARTED"); if (!phaseEvent && !endedEvent && !chainEvent) return null; - const matchMeta = await ctx.runQuery(api.game.getMatchMeta, { matchId: args.matchId }); + const matchMeta = await ctx.runQuery(internalApi.game.getMatchMetaAsActor, { + matchId: args.matchId, + actorUserId: args.userId, + }); const statusText = String((matchMeta as any)?.status ?? "updated").toUpperCase(); const deepLink = getTelegramMiniAppDeepLink(args.matchId);