Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions apps/server/src/plugins/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}
}
}
Expand Down
33 changes: 33 additions & 0 deletions apps/server/test/plugin-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand Down
29 changes: 17 additions & 12 deletions apps/web/test/auth-navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,20 +86,28 @@ 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" });
expect(window.location.pathname).toBe("/login");
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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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<Response>((resolve) => { finishRequest = resolve; }));
let refetch!: Promise<void>;
Expand All @@ -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");
});
});
12 changes: 12 additions & 0 deletions manual/guides/create-a-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/plugin-sdk/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 13 additions & 2 deletions packages/plugin-sdk/src/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string | string[] | undefined>>;
/** Original bytes, only for routes that opt into rawBody. Never reserialized JSON. */
readonly rawBody?: Uint8Array;
}

export interface RouteDef<P extends z.ZodTypeAny, B extends z.ZodTypeAny, Q extends z.ZodTypeAny> {
method: "GET" | "POST" | "PUT" | "DELETE";
path: string;
Expand All @@ -19,6 +26,8 @@ export interface RouteDef<P extends z.ZodTypeAny, B extends z.ZodTypeAny, Q exte
* the loader answers 423 + `retry-after`.
*/
accessInHospital?: boolean;
/** Deliver the body as untouched bytes for webhook signature verification. */
rawBody?: boolean;
params?: P;
body?: B;
/**
Expand All @@ -31,7 +40,7 @@ export interface RouteDef<P extends z.ZodTypeAny, B extends z.ZodTypeAny, Q exte
query?: Q;
handler: (
ctx: PluginCtx,
input: { params: z.infer<P>; body: z.infer<B>; query: z.infer<Q> },
input: { params: z.infer<P>; body: z.infer<B>; query: z.infer<Q> } & RouteTransport,
) => Promise<RouteResult>;
}

Expand All @@ -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<RouteResult>;
handler(ctx: PluginCtx, input: { params: unknown; body: unknown; query: unknown } & RouteTransport): Promise<RouteResult>;
}

export function route<
Expand All @@ -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(),
Expand Down
6 changes: 6 additions & 0 deletions packages/plugin-sdk/test/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading