@checkstack/backend 0.14.0 → 0.16.0

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.
@@ -24,6 +24,7 @@ import type { EventBus } from "@checkstack/backend-api";
24
24
  import type { PluginMetadata } from "@checkstack/common";
25
25
  import { rootLogger } from "../logger";
26
26
  import { extractErrorMessage } from "@checkstack/common";
27
+ import { mapPgErrorToHttp } from "./pg-http-errors";
27
28
 
28
29
  interface RouteHandlerDeps {
29
30
  registry: ServiceRegistry;
@@ -139,14 +140,20 @@ async function resolveRequestContext({
139
140
  deps.pluginMetadataRegistry.get(pluginId);
140
141
 
141
142
  if (!pluginMetadata) {
142
- (logger as Logger).error(
143
+ // No metadata for this pluginId. The common cause is a client requesting an
144
+ // unknown plugin (typo / probing) - a 404, not a 500. A genuine
145
+ // misconfiguration (a core router that forgot
146
+ // pluginManager.registerCorePluginMetadata()) surfaces the same way, so we
147
+ // log it for diagnosis, but at warn level since the dominant case is a
148
+ // client error and erroring on every bad path would be noise.
149
+ (logger as Logger).warn(
143
150
  `${pathname}: no plugin metadata registered for pluginId='${pluginId}'. ` +
144
- `Regular plugins populate this during register(); core routers must call ` +
145
- `pluginManager.registerCorePluginMetadata().`,
151
+ `Either the plugin id is unknown (client typo / probe), or a core ` +
152
+ `router did not call pluginManager.registerCorePluginMetadata().`,
146
153
  );
147
154
  return {
148
155
  ok: false,
149
- response: c.json({ error: "Plugin metadata not found in registry" }, 500),
156
+ response: c.json({ error: "Not Found" }, 404),
150
157
  };
151
158
  }
152
159
 
@@ -163,6 +170,11 @@ async function resolveRequestContext({
163
170
  cachePluginRegistry: cachePluginRegistry as CachePluginRegistry,
164
171
  cacheManager: cacheManager as CacheManager,
165
172
  user,
173
+ // The incoming request's headers, so a handler can forward the caller's OWN
174
+ // auth (session cookie / bearer) when it re-enters the router as the same
175
+ // user - e.g. an AI tool's user-scoped rpcClient (proposeTool/applyTool).
176
+ // Also read by correlationMiddleware for the inbound correlation id.
177
+ requestHeaders: c.req.raw.headers,
166
178
  emitHook,
167
179
  };
168
180
 
@@ -203,6 +215,35 @@ function logHandlerError({
203
215
  }
204
216
  }
205
217
 
218
+ /**
219
+ * Shared interceptor catch path for both the RPC and REST handlers. A Postgres
220
+ * driver error caused by bad client input (bad uuid, out-of-range int, over-long
221
+ * string, constraint violation) is mapped to the correct 4xx `ORPCError` and
222
+ * logged at warn (it is a client mistake, not a server fault). Everything else
223
+ * keeps the existing error-level logging + rethrow so genuine 500s stay loud.
224
+ */
225
+ function rethrowAsHttpError({
226
+ error,
227
+ pathname,
228
+ logger,
229
+ protocolLabel,
230
+ }: {
231
+ error: unknown;
232
+ pathname: string;
233
+ logger: Logger | undefined;
234
+ protocolLabel: string;
235
+ }): never {
236
+ const mapped = mapPgErrorToHttp(error);
237
+ if (mapped) {
238
+ (logger ?? rootLogger).warn(
239
+ `${protocolLabel} ${pathname}: ${mapped.code} (${extractErrorMessage(error)})`,
240
+ );
241
+ throw mapped;
242
+ }
243
+ logHandlerError({ error, pathname, logger, protocolLabel });
244
+ throw error;
245
+ }
246
+
206
247
  /**
207
248
  * Creates the API route handler for Hono.
208
249
  * Serves oRPC's native RPC wire protocol at /api/:pluginId/*.
@@ -242,13 +283,12 @@ export function createApiRouteHandler({
242
283
  try {
243
284
  return await next(rest);
244
285
  } catch (error) {
245
- logHandlerError({
286
+ rethrowAsHttpError({
246
287
  error,
247
288
  pathname,
248
289
  logger,
249
290
  protocolLabel: "RPC",
250
291
  });
251
- throw error;
252
292
  }
253
293
  },
254
294
  ],
@@ -321,13 +361,12 @@ export function createRestRouteHandler({
321
361
  try {
322
362
  return await next(rest);
323
363
  } catch (error) {
324
- logHandlerError({
364
+ rethrowAsHttpError({
325
365
  error,
326
366
  pathname,
327
367
  logger,
328
368
  protocolLabel: "REST",
329
369
  });
330
- throw error;
331
370
  }
332
371
  },
333
372
  ],
@@ -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
+ });
@@ -14,7 +14,7 @@ import {
14
14
  import { AuthApi } from "@checkstack/auth-common";
15
15
  import type { ServiceRegistry } from "../services/service-registry";
16
16
  import { rootLogger } from "../logger";
17
- import { db } from "../db";
17
+ import { db, lockPool } from "../db";
18
18
  import { jwtService } from "../services/jwt";
19
19
  import {
20
20
  CoreHealthCheckRegistry,
@@ -98,11 +98,14 @@ export function registerCoreServices({
98
98
  return createScopedDb(db, assignedSchema);
99
99
  });
100
100
 
101
- // 1b. Advisory Lock Factory (server-global, backed by the shared admin
102
- // pool). Session locks need connection affinity, so the service checks
103
- // out a dedicated client per acquired lock and releases on the SAME
104
- // client the scoped per-query DB proxy can't provide that.
105
- const advisoryLockService = createAdvisoryLockService(adminPool);
101
+ // 1b. Advisory Lock Factory (server-global, backed by the DEDICATED
102
+ // `lockPool`, NOT `adminPool`). Both session locks (`tryAcquire`) and the
103
+ // transaction-scoped `withXactLock` HOLD a connection for the lock's whole
104
+ // lifetime while the locked work runs on `adminPool`. Drawing the lock
105
+ // connection from a separate pool keeps the acquire graph acyclic
106
+ // (lockPool -> adminPool, never back), so a held lock can never starve the
107
+ // work pool into the `idle in transaction` deadlock. See `db.ts`.
108
+ const advisoryLockService = createAdvisoryLockService(lockPool);
106
109
  registry.registerFactory(
107
110
  coreServices.advisoryLock,
108
111
  () => advisoryLockService,
@@ -136,6 +139,38 @@ export function registerCoreServices({
136
139
  pluginId: payload.service as string,
137
140
  };
138
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
+ }
139
174
  }
140
175
 
141
176
  // Strategy B: User Token (via registered strategy)
@@ -312,6 +347,52 @@ export function registerCoreServices({
312
347
  return rpcClient;
313
348
  });
314
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
+
315
396
  // 6. Health Check Registry (Scoped Factory - auto-prefixes strategy IDs with pluginId)
316
397
  const globalHealthCheckRegistry = new CoreHealthCheckRegistry();
317
398
  registry.registerFactory(coreServices.healthCheckRegistry, (metadata) =>