From 797ae756aa029b38a7d3bca9c11f29ef88a807f0 Mon Sep 17 00:00:00 2001 From: Ron Date: Sun, 13 Sep 2026 22:10:26 +0200 Subject: [PATCH 1/2] Expose raw webhook bodies and headers to plugin routes --- apps/server/src/plugins/routes.ts | 24 ++++++++++++++--- apps/server/test/plugin-routes.test.ts | 33 +++++++++++++++++++++++ manual/guides/create-a-plugin.md | 12 +++++++++ package-lock.json | 2 +- packages/plugin-sdk/package.json | 2 +- packages/plugin-sdk/src/index.ts | 2 +- packages/plugin-sdk/src/route.ts | 15 +++++++++-- packages/plugin-sdk/test/manifest.test.ts | 6 +++++ 8 files changed, 87 insertions(+), 9 deletions(-) diff --git a/apps/server/src/plugins/routes.ts b/apps/server/src/plugins/routes.ts index 8f29adfd..388d9fc9 100644 --- a/apps/server/src/plugins/routes.ts +++ b/apps/server/src/plugins/routes.ts @@ -2,7 +2,7 @@ import type { PlayerSnapshot, PluginManifest } from "@gl3/plugin-sdk"; import { isPluginError, hasPermission } from "@gl3/plugin-sdk"; import { dumpRecentQueries, type Db } from "../db/client.js"; import { eq } from "drizzle-orm"; -import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import type { FastifyInstance, FastifyReply, FastifyRequest, RouteOptions } from "fastify"; import { players, playerStats, roleModuleAccess } from "../db/schema/index.js"; import { settleHospital } from "../game/hospital/status.js"; import { createOutboxDelivery } from "../bus/outbox.js"; @@ -22,7 +22,7 @@ export function registerPluginRoutes( for (const pluginRoute of manifest.routes) { const preHandler = pluginRoute.auth === "public" ? [] : [app.requireAuth]; - app.route({ + const routeOptions: RouteOptions = { method: pluginRoute.method, url: pluginRoute.path, preHandler, @@ -90,7 +90,11 @@ export function registerPluginRoutes( }); try { - const result = await pluginRoute.handler(ctx, { params: params.data, body: body.data, query: query.data }); + const result = await pluginRoute.handler(ctx, { + params: params.data, body: body.data, query: query.data, + headers: request.headers, + ...(pluginRoute.rawBody && Buffer.isBuffer(request.body) ? { rawBody: request.body } : {}), + }); return result.body === undefined ? await reply.code(result.status).send() : await reply.code(result.status).send(result.body); @@ -157,7 +161,19 @@ export function registerPluginRoutes( throw error; } }, - }); + }; + if (pluginRoute.rawBody) { + // Encapsulate the parser so ordinary plugin and core routes retain + // their JSON validation. Fastify still enforces its body-size limit. + void app.register((scoped, _options, done) => { + scoped.removeAllContentTypeParsers(); + scoped.addContentTypeParser("*", { parseAs: "buffer" }, (_request, bytes, parsed) => parsed(null, bytes)); + scoped.route(routeOptions); + done(); + }); + } else { + app.route(routeOptions); + } } } } diff --git a/apps/server/test/plugin-routes.test.ts b/apps/server/test/plugin-routes.test.ts index 056954b8..e3e46a43 100644 --- a/apps/server/test/plugin-routes.test.ts +++ b/apps/server/test/plugin-routes.test.ts @@ -21,6 +21,18 @@ const testPlugin = definePlugin({ version: "1.0.0", basePaths: ["/api/rt"], routes: [ + route({ + method: "POST", path: "/api/rt/raw", auth: "public", rawBody: true, + handler: async (_ctx, { rawBody, headers }) => ({ status: 200, body: { + raw: rawBody ? Buffer.from(rawBody).toString("base64") : null, + signature: headers?.["stripe-signature"], + } }), + }), + route({ + method: "POST", path: "/api/rt/json", auth: "public", + body: z.object({ value: z.string() }), + handler: async (_ctx, { body, rawBody }) => ({ status: 200, body: { ...body, hasRawBody: rawBody !== undefined } }), + }), route({ method: "GET", path: "/api/rt/open", @@ -115,6 +127,27 @@ afterAll(async () => { }); describe("plugin routes", () => { + it("preserves webhook bytes and signature headers without JSON parsing", async () => { + const payload = ' { "value" : "café ☃", "extra": true }\r\n'; + const res = await app.inject({ method: "POST", url: "/api/rt/raw", payload, + headers: { "content-type": "application/json", "Stripe-Signature": "t=123,v1=abc" } }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ raw: Buffer.from(payload).toString("base64"), signature: "t=123,v1=abc" }); + }); + it("leaves malformed bytes untouched for signature checks and enforces the body limit", async () => { + const res = await app.inject({ method: "POST", url: "/api/rt/raw", payload: '{invalid', headers: { "content-type": "application/json" } }); + expect(res.statusCode).toBe(200); + expect(res.json().raw).toBe(Buffer.from('{invalid').toString("base64")); + const oversized = await app.inject({ method: "POST", url: "/api/rt/raw", payload: 'x'.repeat(1024 * 1024 + 1), headers: { "content-type": "application/json" } }); + expect(oversized.statusCode).toBe(413); + }); + it("retains ordinary JSON parsing and schema validation outside raw-body routes", async () => { + const ok = await app.inject({ method: "POST", url: "/api/rt/json", payload: { value: "normal" } }); + expect(ok.json()).toEqual({ value: "normal", hasRawBody: false }); + const invalid = await app.inject({ method: "POST", url: "/api/rt/json", payload: { value: 123 } }); + expect(invalid.statusCode).toBe(400); + }); + it("serves a public route without a token", async () => { const res = await app.inject({ method: "GET", url: "/api/rt/open" }); expect(res.statusCode).toBe(200); diff --git a/manual/guides/create-a-plugin.md b/manual/guides/create-a-plugin.md index ac375523..6c14431c 100644 --- a/manual/guides/create-a-plugin.md +++ b/manual/guides/create-a-plugin.md @@ -154,6 +154,18 @@ inverting an explicit lock order elsewhere) — hence the rule. economy-mutating worker an idempotency key tied to `job.id` (BullMQ is at-least-once). +## Signed webhooks + +With `@gl3/plugin-sdk` 1.0.10 and a server build containing raw-body route support, +set `rawBody: true` on a webhook route. The handler receives the original +`Uint8Array` in `input.rawBody` and lowercase HTTP headers in `input.headers`. +The route's `body` is also the raw buffer; omit a JSON body schema and verify the +signature before parsing event data. Do not reconstruct signed payloads with +`JSON.stringify`. These transport fields are optional for direct/test calls. + +Raw parsing is scoped to the opted-in route and retains Fastify's body-size +limit. Other routes keep their existing JSON parsing and Zod validation. + ## Tests See [Testing conventions](/guides/testing-conventions). Every plugin lands with a diff --git a/package-lock.json b/package-lock.json index eacaef4c..8aea6de3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9303,7 +9303,7 @@ }, "packages/plugin-sdk": { "name": "@gl3/plugin-sdk", - "version": "1.0.9", + "version": "1.0.10", "dependencies": { "@gl3/shared": "^1.0.17", "drizzle-orm": "^0.45.2", diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 3025f08d..38059c0e 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@gl3/plugin-sdk", - "version": "1.0.9", + "version": "1.0.10", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 08b94f61..6cb03739 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -64,7 +64,7 @@ export { type AssetSlot, SINGLETON_ENTITY_ID, } from "./manifest.js"; -export { route, type PluginRoute, type RouteDef, type RouteResult } from "./route.js"; +export { route, type PluginRoute, type RouteDef, type RouteResult, type RouteTransport } from "./route.js"; export { hasPermission } from "./authz.js"; export { settlePool, diff --git a/packages/plugin-sdk/src/route.ts b/packages/plugin-sdk/src/route.ts index 8de43236..6e4919d7 100644 --- a/packages/plugin-sdk/src/route.ts +++ b/packages/plugin-sdk/src/route.ts @@ -6,6 +6,13 @@ export interface RouteResult { body?: unknown; } +/** Optional transport data supplied by the HTTP loader, not direct/test calls. */ +export interface RouteTransport { + readonly headers?: Readonly>; + /** Original bytes, only for routes that opt into rawBody. Never reserialized JSON. */ + readonly rawBody?: Uint8Array; +} + export interface RouteDef

{ method: "GET" | "POST" | "PUT" | "DELETE"; path: string; @@ -19,6 +26,8 @@ export interface RouteDef

; body: z.infer; query: z.infer }, + input: { params: z.infer

; body: z.infer; query: z.infer } & RouteTransport, ) => Promise; } @@ -48,10 +57,11 @@ export interface PluginRoute { auth: "player" | "public" | "admin"; accessInJail: boolean; accessInHospital: boolean; + rawBody?: boolean; params: z.ZodTypeAny; body: z.ZodTypeAny; query: z.ZodTypeAny; - handler(ctx: PluginCtx, input: { params: unknown; body: unknown; query: unknown }): Promise; + handler(ctx: PluginCtx, input: { params: unknown; body: unknown; query: unknown } & RouteTransport): Promise; } export function route< @@ -65,6 +75,7 @@ export function route< auth: def.auth ?? "player", accessInJail: def.accessInJail ?? true, accessInHospital: def.accessInHospital ?? true, + ...(def.rawBody === undefined ? {} : { rawBody: def.rawBody }), params: def.params ?? z.unknown(), body: def.body ?? z.unknown(), query: def.query ?? z.unknown(), diff --git a/packages/plugin-sdk/test/manifest.test.ts b/packages/plugin-sdk/test/manifest.test.ts index baa8ad9d..36cfdf97 100644 --- a/packages/plugin-sdk/test/manifest.test.ts +++ b/packages/plugin-sdk/test/manifest.test.ts @@ -200,6 +200,12 @@ describe("adminPages", () => { }); describe("route auth admin", () => { + it("route() preserves the opt-in raw body mode without enabling it by default", () => { + const base = { method: "POST" as const, path: "/api/test/hook", handler: async () => ({ status: 200 }) }; + expect(route({ ...base, rawBody: true }).rawBody).toBe(true); + expect(route(base).rawBody).toBeUndefined(); + }); + it("route() accepts auth admin and carries it through", () => { const r = route({ method: "GET", path: "/api/admin/hello/things", auth: "admin", From a5b3e507a3d026a870fc35592f66fd1043bc339e Mon Sep 17 00:00:00 2001 From: Ron Date: Sun, 13 Sep 2026 22:41:53 +0200 Subject: [PATCH 2/2] Wait for settled session navigation in web integration tests --- apps/web/test/auth-navigation.test.ts | 29 ++++++++++++++++----------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/apps/web/test/auth-navigation.test.ts b/apps/web/test/auth-navigation.test.ts index e3382a86..306c55bc 100644 --- a/apps/web/test/auth-navigation.test.ts +++ b/apps/web/test/auth-navigation.test.ts @@ -86,11 +86,20 @@ async function login(password = "password") { fireEvent.click(screen.getByRole("button", { name: "Log in" })); } +async function expectPlayingAs(username: string) { + // Session observers and the effect-driven redirect can settle separately. + // Require the destination and account together, not an intermediate DOM match. + await waitFor(() => { + expect(window.location.pathname).toBe("/"); + expect(screen.getByText(`Playing as ${username}`)).toBeTruthy(); + }); +} + describe("session navigation", () => { it.each([false, true])("logs out and immediately signs in without a refresh (network failure: %s)", async (failure) => { logoutFails = failure; openApp("/"); - await screen.findByText("Playing as Alice"); + await expectPlayingAs("Alice"); queryClient.setQueryData(["private-player-data"], { secret: "Alice's data" }); fireEvent.click(screen.getByRole("button", { name: "Log out" })); await screen.findByRole("button", { name: "Log in" }); @@ -98,8 +107,7 @@ describe("session navigation", () => { expect(token).toBeNull(); expect(queryClient.getQueryData(["private-player-data"])).toBeUndefined(); await login(); - await screen.findByText("Playing as Bob"); - expect(window.location.pathname).toBe("/"); + await expectPlayingAs("Bob"); }); it("redirects a signed-out deep link to login and enters the game after login", async () => { @@ -108,8 +116,7 @@ describe("session navigation", () => { await screen.findByRole("button", { name: "Log in" }); expect(window.location.pathname).toBe("/login"); await login(); - await screen.findByText("Playing as Bob"); - expect(window.location.pathname).toBe("/"); + await expectPlayingAs("Bob"); }); it("enters the game after registration", async () => { @@ -121,14 +128,12 @@ describe("session navigation", () => { fireEvent.change(screen.getByLabelText("Password"), { target: { value: "password" } }); fireEvent.click(screen.getByRole("checkbox")); fireEvent.click(screen.getByRole("button", { name: "Register" })); - await screen.findByText("Playing as Bob"); - expect(window.location.pathname).toBe("/"); + await expectPlayingAs("Bob"); }); it("redirects an existing session away from login", async () => { openApp("/login"); - await screen.findByText("Playing as Alice"); - expect(window.location.pathname).toBe("/"); + await expectPlayingAs("Alice"); }); it("keeps failed login on the login page", async () => { @@ -162,12 +167,12 @@ describe("session navigation", () => { await screen.findByText("Your account has been deleted."); expect(window.location.pathname).toBe("/login"); await login(); - await screen.findByText("Playing as Bob"); + await expectPlayingAs("Bob"); }); it("discards an authenticated response that arrives after logout", async () => { openApp("/"); - await screen.findByText("Playing as Alice"); + await expectPlayingAs("Alice"); let finishRequest!: (response: Response) => void; vi.mocked(fetch).mockImplementationOnce(() => new Promise((resolve) => { finishRequest = resolve; })); let refetch!: Promise; @@ -181,6 +186,6 @@ describe("session navigation", () => { await waitFor(() => expect(queryClient.getQueryData(keys.me())).toBeUndefined()); expect(window.location.pathname).toBe("/login"); await login(); - await screen.findByText("Playing as Bob"); + await expectPlayingAs("Bob"); }); });