@checkstack/backend 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,178 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { Hono } from "hono";
3
+ import { z } from "zod";
4
+ import { implement } from "@orpc/server";
5
+ import { createORPCClient } from "@orpc/client";
6
+ import { RPCLink } from "@orpc/client/fetch";
7
+ import type { ContractRouterClient } from "@orpc/contract";
8
+ import { access, proc } from "@checkstack/common";
9
+ import {
10
+ coreServices,
11
+ createMockRpcContext,
12
+ autoAuthMiddleware,
13
+ correlationMiddleware,
14
+ type AuthService,
15
+ type AuthUser,
16
+ type EventBus,
17
+ type RpcContext,
18
+ } from "@checkstack/backend-api";
19
+ import { ServiceRegistry } from "../services/service-registry";
20
+ import { createApiRouteHandler, registerApiRoute } from "./api-router";
21
+
22
+ /**
23
+ * Hardening test for the automation `runAs` SERVICE-ACCOUNT principal.
24
+ *
25
+ * The whole design rests on ONE invariant: an automation's app-principal token
26
+ * resolves to an `application` principal that is subject to the FULL access-rule
27
+ * enforcement - it must NEVER be treated as a trusted `service` (which
28
+ * short-circuits all checks = god mode). If a future change made the
29
+ * app-principal a `service`, a prompt-injected AI Action could mutate any
30
+ * team's data. This test pins the contrast over real HTTP (real dispatcher,
31
+ * real `autoAuthMiddleware`, real loopback), so that regression goes red.
32
+ *
33
+ * It does not exercise the JWT mint/verify plumbing (that is mechanical); it
34
+ * guards the security-relevant outcome: application => enforced, service =>
35
+ * bypassed.
36
+ */
37
+
38
+ const manageRule = access("thing", "manage", "Manage things");
39
+
40
+ const targetContract = {
41
+ // Gated by `thing.manage`. autoAuthMiddleware qualifies it to
42
+ // `target.thing.manage` for the "target" plugin.
43
+ manageThing: proc({
44
+ operationType: "mutation",
45
+ userType: "authenticated",
46
+ access: [manageRule],
47
+ }).output(z.object({ ok: z.boolean(), kind: z.string() })),
48
+ };
49
+
50
+ type RootClient = ContractRouterClient<{ target: typeof targetContract }>;
51
+
52
+ function buildTargetRouter() {
53
+ const os = implement(targetContract)
54
+ .$context<RpcContext>()
55
+ .use(correlationMiddleware)
56
+ .use(autoAuthMiddleware);
57
+ return os.router({
58
+ manageThing: os.manageThing.handler(async ({ context }) => ({
59
+ ok: true,
60
+ kind: context.user?.type ?? "anonymous",
61
+ })),
62
+ });
63
+ }
64
+
65
+ const noopEventBus: EventBus = {
66
+ subscribe: async () => async () => {},
67
+ emit: async () => {},
68
+ emitLocal: async () => {},
69
+ shutdown: async () => {},
70
+ };
71
+
72
+ /**
73
+ * Maps bearer tokens to the principal kinds we want to contrast:
74
+ * - `app-ok` -> application HOLDING the qualified rule
75
+ * - `app-deny` -> application WITHOUT the rule (must be refused)
76
+ * - `svc` -> trusted service (short-circuits all checks)
77
+ */
78
+ const realAuth: AuthService = {
79
+ async authenticate(request) {
80
+ const header = request.headers.get("authorization");
81
+ if (header === "Bearer app-ok") {
82
+ return {
83
+ type: "application",
84
+ id: "app-ok",
85
+ name: "Bounded App",
86
+ accessRules: ["target.thing.manage"],
87
+ } satisfies AuthUser;
88
+ }
89
+ if (header === "Bearer app-deny") {
90
+ return {
91
+ type: "application",
92
+ id: "app-deny",
93
+ name: "Under-privileged App",
94
+ accessRules: [],
95
+ } satisfies AuthUser;
96
+ }
97
+ if (header === "Bearer svc") {
98
+ return { type: "service", pluginId: "automation" } satisfies AuthUser;
99
+ }
100
+ return undefined;
101
+ },
102
+ async getCredentials() {
103
+ return { headers: {} };
104
+ },
105
+ async getAnonymousAccessRules() {
106
+ return [];
107
+ },
108
+ async checkResourceTeamAccess() {
109
+ return { hasAccess: false };
110
+ },
111
+ async getAccessibleResourceIds({ resourceIds }) {
112
+ return resourceIds;
113
+ },
114
+ };
115
+
116
+ function buildRegistry(): ServiceRegistry {
117
+ const stub = createMockRpcContext();
118
+ const registry = new ServiceRegistry();
119
+ registry.register(coreServices.logger, stub.logger);
120
+ registry.register(coreServices.database, stub.db);
121
+ registry.register(coreServices.fetch, stub.fetch);
122
+ registry.register(coreServices.healthCheckRegistry, stub.healthCheckRegistry);
123
+ registry.register(coreServices.collectorRegistry, stub.collectorRegistry);
124
+ registry.register(coreServices.queuePluginRegistry, stub.queuePluginRegistry);
125
+ registry.register(coreServices.queueManager, stub.queueManager);
126
+ registry.register(coreServices.cachePluginRegistry, stub.cachePluginRegistry);
127
+ registry.register(coreServices.cacheManager, stub.cacheManager);
128
+ registry.register(coreServices.eventBus, noopEventBus);
129
+ registry.register(coreServices.auth, realAuth);
130
+ return registry;
131
+ }
132
+
133
+ const app = new Hono();
134
+ const apiHandler = createApiRouteHandler({
135
+ registry: buildRegistry(),
136
+ pluginRpcRouters: new Map<string, unknown>([["target", buildTargetRouter()]]),
137
+ pluginHttpHandlers: new Map(),
138
+ pluginMetadataRegistry: new Map([["target", { pluginId: "target" }]]),
139
+ });
140
+ registerApiRoute(app, apiHandler);
141
+
142
+ const listening = Bun.serve({ port: 0, fetch: app.fetch });
143
+ const baseUrl = `http://localhost:${listening.port}`;
144
+
145
+ afterAll(() => {
146
+ listening.stop(true);
147
+ });
148
+
149
+ function clientWith(headers: Record<string, string>): RootClient {
150
+ return createORPCClient<RootClient>(
151
+ new RPCLink({ url: `${baseUrl}/api`, headers }),
152
+ );
153
+ }
154
+
155
+ describe("app-principal authorization (real HTTP)", () => {
156
+ test("an application HOLDING the rule is allowed (enforced, not bypassed)", async () => {
157
+ const result = await clientWith({
158
+ authorization: "Bearer app-ok",
159
+ }).target.manageThing();
160
+ expect(result).toEqual({ ok: true, kind: "application" });
161
+ });
162
+
163
+ test("an application WITHOUT the rule is FORBIDDEN - the security crux", async () => {
164
+ // Proves the app-principal flows through full access-rule enforcement and is
165
+ // NOT short-circuited like a service. This is the guarantee that an
166
+ // automation can never exceed its service account's permissions.
167
+ await expect(
168
+ clientWith({ authorization: "Bearer app-deny" }).target.manageThing(),
169
+ ).rejects.toThrow(/Missing access|FORBIDDEN/i);
170
+ });
171
+
172
+ test("a trusted service bypasses the check (the contrast we must NOT grant apps)", async () => {
173
+ const result = await clientWith({
174
+ authorization: "Bearer svc",
175
+ }).target.manageThing();
176
+ expect(result).toEqual({ ok: true, kind: "service" });
177
+ });
178
+ });
@@ -0,0 +1,259 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { Hono } from "hono";
3
+ import { z } from "zod";
4
+ import { implement, ORPCError } from "@orpc/server";
5
+ import { createORPCClient } from "@orpc/client";
6
+ import { RPCLink } from "@orpc/client/fetch";
7
+ import type { ContractRouterClient } from "@orpc/contract";
8
+ import { proc } from "@checkstack/common";
9
+ import {
10
+ coreServices,
11
+ createMockRpcContext,
12
+ autoAuthMiddleware,
13
+ correlationMiddleware,
14
+ type AuthService,
15
+ type AuthUser,
16
+ type EventBus,
17
+ type RpcContext,
18
+ } from "@checkstack/backend-api";
19
+ import { ServiceRegistry } from "../services/service-registry";
20
+ import { createApiRouteHandler, registerApiRoute } from "./api-router";
21
+
22
+ /**
23
+ * REAL end-to-end auth-passthrough test for the `/api/:pluginId/*` dispatcher.
24
+ *
25
+ * This guards the regression where `createApiRouteHandler` built the per-request
26
+ * `RpcContext` WITHOUT `requestHeaders`, so a handler that re-enters the router
27
+ * as the originating user (an AI tool's user-scoped rpcClient via
28
+ * `proposeTool`/`applyTool`) forwarded NO auth and the loopback failed with
29
+ * "Authentication required". It exercises the actual wiring over real HTTP - a
30
+ * real `Bun.serve`, the real dispatcher, real `autoAuthMiddleware`, and a real
31
+ * loopback oRPC client - with NO fakes for the path under test. The only stubs
32
+ * are unrelated core-service collaborators (db, registries, queue, cache), which
33
+ * none of these handlers touch.
34
+ *
35
+ * `caller.run` mirrors exactly what `proposeTool`/`applyTool` do: read
36
+ * `context.requestHeaders`, forward the caller's own cookie/bearer, and call a
37
+ * gated procedure on another plugin AS THAT USER. With the bug present,
38
+ * `caller.run` would throw "Authentication required" instead of returning the
39
+ * caller's id - so this test goes red without the fix and green with it.
40
+ */
41
+
42
+ // ─── Contracts (a "target" plugin gated by auth, a "caller" that loops back) ──
43
+
44
+ const targetContract = {
45
+ // Requires an authenticated user; echoes WHO the router authenticated.
46
+ whoami: proc({
47
+ operationType: "query",
48
+ userType: "authenticated",
49
+ access: [],
50
+ }).output(z.object({ id: z.string(), kind: z.string() })),
51
+ };
52
+
53
+ const callerContract = {
54
+ // PUBLIC so it is reachable without outer auth - that lets us prove the
55
+ // LOOPBACK itself enforces auth (an unauthenticated run still fails downstream).
56
+ run: proc({ operationType: "query", userType: "public", access: [] }).output(
57
+ z.object({ id: z.string(), kind: z.string() }),
58
+ ),
59
+ // Directly surfaces whether the dispatcher populated `context.requestHeaders`.
60
+ echo: proc({ operationType: "query", userType: "public", access: [] }).output(
61
+ z.object({ seenAuthHeader: z.string().nullable() }),
62
+ ),
63
+ };
64
+
65
+ const rootContract = { target: targetContract, caller: callerContract };
66
+ type RootClient = ContractRouterClient<typeof rootContract>;
67
+
68
+ // Set once the server is listening; the caller handler reads it at call time.
69
+ const server = { baseUrl: "" };
70
+
71
+ // ─── Routers ──────────────────────────────────────────────────────────────
72
+
73
+ function buildTargetRouter() {
74
+ const os = implement(targetContract)
75
+ .$context<RpcContext>()
76
+ .use(correlationMiddleware)
77
+ .use(autoAuthMiddleware);
78
+ return os.router({
79
+ whoami: os.whoami.handler(async ({ context }) => {
80
+ // autoAuthMiddleware already rejected an unauthenticated caller; this guard
81
+ // also narrows the AuthUser union to the `user` variant (which carries id).
82
+ const user = context.user;
83
+ if (!user || user.type !== "user") {
84
+ throw new ORPCError("UNAUTHORIZED", {
85
+ message: "Authentication required",
86
+ });
87
+ }
88
+ return { id: user.id, kind: user.type };
89
+ }),
90
+ });
91
+ }
92
+
93
+ function buildCallerRouter() {
94
+ const os = implement(callerContract)
95
+ .$context<RpcContext>()
96
+ .use(correlationMiddleware)
97
+ .use(autoAuthMiddleware);
98
+ return os.router({
99
+ run: os.run.handler(async ({ context }) => {
100
+ // EXACTLY the proposeTool/applyTool pattern: forward the caller's own auth
101
+ // headers and re-enter the router as that user.
102
+ const forward: Record<string, string> = {};
103
+ const auth = context.requestHeaders?.get("authorization");
104
+ if (auth) forward.authorization = auth;
105
+ const cookie = context.requestHeaders?.get("cookie");
106
+ if (cookie) forward.cookie = cookie;
107
+ const link = new RPCLink({
108
+ url: `${server.baseUrl}/api`,
109
+ headers: forward,
110
+ });
111
+ const client = createORPCClient<RootClient>(link);
112
+ return client.target.whoami();
113
+ }),
114
+ echo: os.echo.handler(async ({ context }) => ({
115
+ seenAuthHeader: context.requestHeaders?.get("authorization") ?? null,
116
+ })),
117
+ });
118
+ }
119
+
120
+ // ─── Real auth: maps `Authorization: Bearer u-<id>` to a user principal ──────
121
+
122
+ // A typed no-op event bus: these handlers never emit, so the bus only needs to
123
+ // exist (the dispatcher requires it non-null). No fakes are needed for the path
124
+ // under test.
125
+ const noopEventBus: EventBus = {
126
+ subscribe: async () => async () => {},
127
+ emit: async () => {},
128
+ emitLocal: async () => {},
129
+ shutdown: async () => {},
130
+ };
131
+
132
+ const realAuth: AuthService = {
133
+ async authenticate(request) {
134
+ const header = request.headers.get("authorization");
135
+ const match = header?.match(/^Bearer u-(.+)$/);
136
+ if (!match) return undefined;
137
+ return {
138
+ type: "user",
139
+ id: match[1],
140
+ accessRules: ["*"],
141
+ } satisfies AuthUser;
142
+ },
143
+ async getCredentials() {
144
+ return { headers: {} };
145
+ },
146
+ async getAnonymousAccessRules() {
147
+ return [];
148
+ },
149
+ async checkResourceTeamAccess() {
150
+ return { hasAccess: false };
151
+ },
152
+ async getAccessibleResourceIds({ resourceIds }) {
153
+ return resourceIds;
154
+ },
155
+ };
156
+
157
+ // ─── Harness: real dispatcher on a real Bun.serve ────────────────────────────
158
+
159
+ function buildRegistry(): ServiceRegistry {
160
+ // Reuse the canonical stub shapes for the collaborators none of these handlers
161
+ // touch; override auth with the real header-reading strategy and event bus.
162
+ const stub = createMockRpcContext();
163
+ const registry = new ServiceRegistry();
164
+ registry.register(coreServices.logger, stub.logger);
165
+ registry.register(coreServices.database, stub.db);
166
+ registry.register(coreServices.fetch, stub.fetch);
167
+ registry.register(coreServices.healthCheckRegistry, stub.healthCheckRegistry);
168
+ registry.register(coreServices.collectorRegistry, stub.collectorRegistry);
169
+ registry.register(coreServices.queuePluginRegistry, stub.queuePluginRegistry);
170
+ registry.register(coreServices.queueManager, stub.queueManager);
171
+ registry.register(coreServices.cachePluginRegistry, stub.cachePluginRegistry);
172
+ registry.register(coreServices.cacheManager, stub.cacheManager);
173
+ registry.register(coreServices.eventBus, noopEventBus);
174
+ registry.register(coreServices.auth, realAuth);
175
+ return registry;
176
+ }
177
+
178
+ const app = new Hono();
179
+ const apiHandler = createApiRouteHandler({
180
+ registry: buildRegistry(),
181
+ pluginRpcRouters: new Map<string, unknown>([
182
+ ["target", buildTargetRouter()],
183
+ ["caller", buildCallerRouter()],
184
+ ]),
185
+ pluginHttpHandlers: new Map(),
186
+ pluginMetadataRegistry: new Map([
187
+ ["target", { pluginId: "target" }],
188
+ ["caller", { pluginId: "caller" }],
189
+ ]),
190
+ });
191
+ registerApiRoute(app, apiHandler);
192
+
193
+ const listening = Bun.serve({ port: 0, fetch: app.fetch });
194
+ server.baseUrl = `http://localhost:${listening.port}`;
195
+
196
+ afterAll(() => {
197
+ listening.stop(true);
198
+ });
199
+
200
+ function clientWith(headers: Record<string, string>): RootClient {
201
+ return createORPCClient<RootClient>(
202
+ new RPCLink({ url: `${server.baseUrl}/api`, headers }),
203
+ );
204
+ }
205
+
206
+ describe("RPC dispatcher auth passthrough (real HTTP)", () => {
207
+ test("the dispatcher populates context.requestHeaders from the request", async () => {
208
+ const client = clientWith({ authorization: "Bearer u-alice" });
209
+ const echo = await client.caller.echo();
210
+ // The exact regression: without requestHeaders on the context this is null.
211
+ expect(echo.seenAuthHeader).toBe("Bearer u-alice");
212
+ });
213
+
214
+ test("a request for an UNKNOWN plugin id returns 404, not 500", async () => {
215
+ // No metadata is registered for 'nope', so the dispatcher must treat it as a
216
+ // not-found resource (client error), not an internal server error.
217
+ const res = await fetch(`${server.baseUrl}/api/nope/whatever`, {
218
+ method: "POST",
219
+ headers: { "content-type": "application/json" },
220
+ body: "{}",
221
+ });
222
+ expect(res.status).toBe(404);
223
+ });
224
+
225
+ test("a loopback call re-enters the router authenticated AS the originating user", async () => {
226
+ const client = clientWith({ authorization: "Bearer u-alice" });
227
+ const result = await client.caller.run();
228
+ // caller.run forwarded alice's bearer to target.whoami, which authenticated
229
+ // as alice. With the bug (requestHeaders undefined) this throws
230
+ // "Authentication required" instead.
231
+ expect(result).toEqual({ id: "alice", kind: "user" });
232
+ });
233
+
234
+ test("a different user's auth is forwarded as THAT user (no cross-user bleed)", async () => {
235
+ const result = await clientWith({ authorization: "Bearer u-bob" }).caller.run();
236
+ expect(result).toEqual({ id: "bob", kind: "user" });
237
+ });
238
+
239
+ test("an unauthenticated loopback is refused downstream (auth still enforced)", async () => {
240
+ // caller.run is public, so the OUTER call proceeds; the loopback forwards no
241
+ // auth, so target.whoami refuses - proving the loopback does NOT bypass authz.
242
+ await expect(clientWith({}).caller.run()).rejects.toThrow(
243
+ /Authentication required|UNAUTHORIZED/i,
244
+ );
245
+ });
246
+
247
+ test("calling the gated procedure directly with auth works (sanity)", async () => {
248
+ const result = await clientWith({
249
+ authorization: "Bearer u-carol",
250
+ }).target.whoami();
251
+ expect(result).toEqual({ id: "carol", kind: "user" });
252
+ });
253
+
254
+ test("calling the gated procedure directly WITHOUT auth is refused", async () => {
255
+ await expect(clientWith({}).target.whoami()).rejects.toThrow(
256
+ /Authentication required|UNAUTHORIZED/i,
257
+ );
258
+ });
259
+ });
@@ -139,6 +139,38 @@ export function registerCoreServices({
139
139
  pluginId: payload.service as string,
140
140
  };
141
141
  }
142
+
143
+ // App-principal token: minted by `rpcClientAs` so an automation can
144
+ // run as its configured service account. Resolve the application's
145
+ // CURRENT rules/teams LIVE (never trust frozen claims) and return an
146
+ // `application` principal, so it flows through the full access-rule
147
+ // and team-scope enforcement - NOT the trusted service short-circuit.
148
+ if (payload && typeof payload.appPrincipal === "string") {
149
+ try {
150
+ const rpcClient = await registry.get(coreServices.rpcClient, {
151
+ pluginId: "core",
152
+ });
153
+ const authClient = rpcClient.forPlugin(AuthApi);
154
+ const enriched = await authClient.enrichApplicationPrincipal({
155
+ applicationId: payload.appPrincipal,
156
+ });
157
+ if (!enriched) return; // app no longer exists -> unauthenticated
158
+ return {
159
+ type: "application" as const,
160
+ id: enriched.id,
161
+ name: enriched.name,
162
+ roles: enriched.roles,
163
+ accessRules: enriched.accessRules,
164
+ teamIds: enriched.teamIds,
165
+ };
166
+ } catch (error) {
167
+ // SECURITY: Fail-Closed - never fall back to a broader principal.
168
+ rootLogger.error(
169
+ `[auth] app-principal enrichment failed for ${payload.appPrincipal}; denying. Error: ${error}`,
170
+ );
171
+ return;
172
+ }
173
+ }
142
174
  }
143
175
 
144
176
  // Strategy B: User Token (via registered strategy)
@@ -315,6 +347,52 @@ export function registerCoreServices({
315
347
  return rpcClient;
316
348
  });
317
349
 
350
+ // 5b. Application-scoped RPC Client Factory.
351
+ // Returns a builder that mints a short-lived app-principal token and returns
352
+ // a client re-entering the live router AS THAT APPLICATION. The receiving
353
+ // `authenticate` (Strategy A) resolves the token to an `application`
354
+ // principal, so the call runs through the full access-rule + team-scope
355
+ // enforcement - NOT the trusted service short-circuit. The automation
356
+ // dispatch engine uses this to run an automation as its `runAs` account.
357
+ registry.registerFactory(coreServices.rpcClientAs, () => {
358
+ const apiBaseUrl = process.env.INTERNAL_URL || "http://localhost:3000";
359
+ return async (applicationId: string): Promise<RpcClient> => {
360
+ // Mint a FRESH short-lived token PER REQUEST (not once at client
361
+ // construction). An automation run can stay live far longer than the
362
+ // token TTL within a single un-suspended stretch - a long AI agent loop
363
+ // making many tool calls, or many sequential actions - and a client that
364
+ // baked one token would start failing with 401s once it expired. Minting
365
+ // per request (mirroring the trusted client's `getCredentials`) means the
366
+ // TTL only has to outlive a single in-flight request. (Across a
367
+ // suspend/resume the engine rebuilds the client, so waits are unaffected
368
+ // either way.)
369
+ const authedFetch = async (
370
+ input: RequestInfo | URL,
371
+ init?: RequestInit,
372
+ ): Promise<Response> => {
373
+ const token = await jwtService.sign(
374
+ { appPrincipal: applicationId },
375
+ "5m",
376
+ );
377
+ const headers = new Headers(init?.headers);
378
+ headers.set("Authorization", `Bearer ${token}`);
379
+ return fetch(input, { ...init, headers });
380
+ };
381
+ const link = new RPCLink({
382
+ url: `${apiBaseUrl}/api`,
383
+ fetch: authedFetch,
384
+ });
385
+ const appClient = createORPCClient(link);
386
+ return {
387
+ forPlugin(def) {
388
+ return (appClient as Record<string, unknown>)[
389
+ def.pluginId
390
+ ] as never;
391
+ },
392
+ };
393
+ };
394
+ });
395
+
318
396
  // 6. Health Check Registry (Scoped Factory - auto-prefixes strategy IDs with pluginId)
319
397
  const globalHealthCheckRegistry = new CoreHealthCheckRegistry();
320
398
  registry.registerFactory(coreServices.healthCheckRegistry, (metadata) =>
@@ -0,0 +1,65 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ORPCError } from "@orpc/server";
3
+ import { mapPgErrorToHttp } from "./pg-http-errors";
4
+
5
+ /**
6
+ * Guards the regression where a client-caused Postgres fault (bad uuid,
7
+ * out-of-range int, over-long string, FK/unique violation) surfaced as a 500
8
+ * instead of a 4xx. The fuzzing pass found that `where id = $1` with a non-uuid
9
+ * `$1` reaches the driver and throws `22P02`, which oRPC reported as 500.
10
+ */
11
+ describe("mapPgErrorToHttp", () => {
12
+ test("maps invalid_text_representation (bad uuid/int) to 400", () => {
13
+ const mapped = mapPgErrorToHttp({ code: "22P02" });
14
+ expect(mapped).toBeInstanceOf(ORPCError);
15
+ expect(mapped?.code).toBe("BAD_REQUEST");
16
+ });
17
+
18
+ test("maps numeric_value_out_of_range to 400", () => {
19
+ expect(mapPgErrorToHttp({ code: "22003" })?.code).toBe("BAD_REQUEST");
20
+ });
21
+
22
+ test("maps string_data_right_truncation to 400", () => {
23
+ expect(mapPgErrorToHttp({ code: "22001" })?.code).toBe("BAD_REQUEST");
24
+ });
25
+
26
+ test("maps foreign_key_violation to 400", () => {
27
+ expect(mapPgErrorToHttp({ code: "23503" })?.code).toBe("BAD_REQUEST");
28
+ });
29
+
30
+ test("maps unique_violation to 409 CONFLICT", () => {
31
+ expect(mapPgErrorToHttp({ code: "23505" })?.code).toBe("CONFLICT");
32
+ });
33
+
34
+ test("unwraps a Drizzle-wrapped driver error via cause", () => {
35
+ const mapped = mapPgErrorToHttp({
36
+ message: "wrapped",
37
+ cause: { code: "22P02" },
38
+ });
39
+ expect(mapped?.code).toBe("BAD_REQUEST");
40
+ });
41
+
42
+ test("returns undefined for an unknown SQLSTATE (genuine 500 stays a 500)", () => {
43
+ // 40001 = serialization_failure: a real server fault, not client input.
44
+ expect(mapPgErrorToHttp({ code: "40001" })).toBeUndefined();
45
+ });
46
+
47
+ test("returns undefined for a non-Postgres error", () => {
48
+ expect(mapPgErrorToHttp(new Error("boom"))).toBeUndefined();
49
+ expect(mapPgErrorToHttp("nope")).toBeUndefined();
50
+ expect(mapPgErrorToHttp(undefined)).toBeUndefined();
51
+ });
52
+
53
+ test("passes an already-mapped ORPCError through untouched (no double-map)", () => {
54
+ const original = new ORPCError("CONFLICT", { message: "dup" });
55
+ expect(mapPgErrorToHttp(original)).toBeUndefined();
56
+ });
57
+
58
+ test("does not leak the raw driver message to the client", () => {
59
+ const mapped = mapPgErrorToHttp({
60
+ code: "22001",
61
+ message: 'value too long for column "secret_token"',
62
+ });
63
+ expect(mapped?.message).not.toContain("secret_token");
64
+ });
65
+ });
@@ -0,0 +1,85 @@
1
+ import { z } from "zod";
2
+ import { ORPCError } from "@orpc/server";
3
+
4
+ /**
5
+ * Postgres SQLSTATE codes for the error classes a *client* can trigger with bad
6
+ * input. These must surface as 4xx, not 500 - a malformed uuid, an out-of-range
7
+ * integer, an over-long string, or a constraint violation is the caller's
8
+ * mistake, not an internal server error.
9
+ *
10
+ * Without this mapping a `where id = $1` with `$1 = "not-a-uuid"` reaches the
11
+ * driver and throws `22P02`, which oRPC reports as a 500 - making routine
12
+ * fuzzing/probing look like the server is broken and burying genuine 500s.
13
+ *
14
+ * @see https://www.postgresql.org/docs/current/errcodes-appendix.html
15
+ */
16
+ const PG_CLIENT_ERROR_CODES: Record<string, { code: "BAD_REQUEST" | "CONFLICT"; message: string }> = {
17
+ // Class 22 — data exception (the caller sent a value the type can't hold).
18
+ "22P02": {
19
+ code: "BAD_REQUEST",
20
+ message: "Invalid input: a value was malformed (e.g. not a valid id).",
21
+ },
22
+ "22003": {
23
+ code: "BAD_REQUEST",
24
+ message: "Invalid input: a numeric value is out of the allowed range.",
25
+ },
26
+ "22001": {
27
+ code: "BAD_REQUEST",
28
+ message: "Invalid input: a text value exceeds the allowed length.",
29
+ },
30
+ "22007": {
31
+ code: "BAD_REQUEST",
32
+ message: "Invalid input: a date/time value is malformed.",
33
+ },
34
+ // Class 23 — integrity-constraint violation.
35
+ "23502": {
36
+ code: "BAD_REQUEST",
37
+ message: "Invalid input: a required value is missing.",
38
+ },
39
+ "23503": {
40
+ code: "BAD_REQUEST",
41
+ message: "Invalid input: a referenced record does not exist.",
42
+ },
43
+ "23505": {
44
+ code: "CONFLICT",
45
+ message: "That record already exists.",
46
+ },
47
+ "23514": {
48
+ code: "BAD_REQUEST",
49
+ message: "Invalid input: a value failed a validation constraint.",
50
+ },
51
+ };
52
+
53
+ // A Postgres driver error carries a SQLSTATE string in `code`. Drizzle may
54
+ // rethrow it wrapped, exposing the original on `cause`, so we check both shapes
55
+ // (mirrors the catalog-backend `pg-errors` matcher).
56
+ const pgErrorSchema = z.object({ code: z.string() });
57
+ const wrappedPgErrorSchema = z.object({ cause: pgErrorSchema });
58
+
59
+ function extractSqlState(error: unknown): string | undefined {
60
+ const direct = pgErrorSchema.safeParse(error);
61
+ if (direct.success) return direct.data.code;
62
+ const wrapped = wrappedPgErrorSchema.safeParse(error);
63
+ if (wrapped.success) return wrapped.data.cause.code;
64
+ return undefined;
65
+ }
66
+
67
+ /**
68
+ * Maps a caught Postgres driver error to an `ORPCError` with the right 4xx
69
+ * status when the SQLSTATE indicates a client-caused fault. Returns `undefined`
70
+ * for anything else (genuine 500s, application errors), so callers fall through
71
+ * to their existing error-logging + rethrow path.
72
+ *
73
+ * The original error is preserved as `cause` for diagnostics; the client-facing
74
+ * message is deliberately generic so we never leak column/constraint names.
75
+ */
76
+ export function mapPgErrorToHttp(error: unknown): ORPCError<string, unknown> | undefined {
77
+ // An already-mapped oRPC error (e.g. a handler's own CONFLICT/NOT_FOUND) must
78
+ // pass through untouched.
79
+ if (error instanceof ORPCError) return undefined;
80
+ const sqlState = extractSqlState(error);
81
+ if (!sqlState) return undefined;
82
+ const mapping = PG_CLIENT_ERROR_CODES[sqlState];
83
+ if (!mapping) return undefined;
84
+ return new ORPCError(mapping.code, { message: mapping.message, cause: error });
85
+ }