@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.
@@ -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
+ }
@@ -63,6 +63,44 @@ function captureEnv(deps: PluginLoaderDeps): BackendPluginRegistry {
63
63
  return captured;
64
64
  }
65
65
 
66
+ describe("registerPlugin pluginPath threading (migrations)", () => {
67
+ // Regression guard (#251): a manually-loaded plugin (e.g. the dev server's
68
+ // plugin-under-dev) must carry its on-disk path through to `pendingInits`,
69
+ // because the loader builds the Drizzle migrations folder as
70
+ // `<pluginPath>/drizzle`. Manual plugins historically got `pluginPath: ""`,
71
+ // so their migrations were silently skipped and a scaffolded plugin booted
72
+ // with no tables. The dev path now supplies the plugin's dir via
73
+ // `manualPluginPaths`; this asserts the value reaches `pendingInits`.
74
+ function registerWithPath(pluginPath: string): PendingInit[] {
75
+ const pendingInits: PendingInit[] = [];
76
+ const plugin = createBackendPlugin({
77
+ metadata: { pluginId: "widget" },
78
+ register: (env) => {
79
+ env.registerInit({ deps: {}, init: async () => {} });
80
+ },
81
+ });
82
+ registerPlugin({
83
+ backendPlugin: plugin,
84
+ pluginPath,
85
+ pendingInits,
86
+ providedBy: new Map<string, string>(),
87
+ deps: makeDeps(new ServiceRegistry()),
88
+ });
89
+ return pendingInits;
90
+ }
91
+
92
+ it("threads a supplied pluginPath into the pending init (so migrations run)", () => {
93
+ const pending = registerWithPath("/repo/packages/widget-backend");
94
+ expect(pending).toHaveLength(1);
95
+ expect(pending[0]?.pluginPath).toBe("/repo/packages/widget-backend");
96
+ });
97
+
98
+ it("keeps an empty pluginPath when none is supplied (migrations skipped)", () => {
99
+ const pending = registerWithPath("");
100
+ expect(pending[0]?.pluginPath).toBe("");
101
+ });
102
+ });
103
+
66
104
  describe("plugin env.getService", () => {
67
105
  it("resolves a service registered by another plugin through the real ServiceRegistry", async () => {
68
106
  const registry = new ServiceRegistry();
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test";
2
+ import { createBackendPlugin } from "@checkstack/backend-api";
3
+ import type { AccessRule, PluginMetadata } from "@checkstack/common";
4
+ import { ServiceRegistry } from "../services/service-registry";
5
+ import { createExtensionPointManager } from "./extension-points";
6
+ import { registerPlugin, type PluginLoaderDeps } from "./plugin-loader";
7
+ import type { PendingInit } from "./types";
8
+ import type { AnyContractRouter } from "@orpc/contract";
9
+ import { rootLogger } from "../logger";
10
+
11
+ /**
12
+ * Regression coverage for the "no register() export" skip diagnostic.
13
+ *
14
+ * A package declared `checkstack.type: "backend"` is inserted into the
15
+ * `plugins` table and loaded in Phase 1 via `pluginModule.default`. If that
16
+ * default export is missing (a host-consumed library mis-typed as a backend
17
+ * plugin, e.g. `@checkstack/signal-backend` before it was reclassified to
18
+ * `tooling`), `backendPlugin` is `undefined` and the loader skips it.
19
+ *
20
+ * Previously the skip warning rendered the opaque literal "unknown" because
21
+ * the only identifier source was `backendPlugin?.metadata?.pluginId`. These
22
+ * tests assert the package name / path from the database row is used as a
23
+ * fallback so the offending package is always identifiable, and that a valid
24
+ * plugin loads without any warning.
25
+ */
26
+
27
+ function makeDeps(registry: ServiceRegistry): PluginLoaderDeps {
28
+ return {
29
+ registry,
30
+ pluginRpcRouters: new Map<string, unknown>(),
31
+ pluginHttpHandlers: new Map<string, (req: Request) => Promise<Response>>(),
32
+ extensionPointManager: createExtensionPointManager(),
33
+ registeredAccessRules: [] as (AccessRule & { pluginId: string })[],
34
+ getAllAccessRules: () => [],
35
+ db: {} as PluginLoaderDeps["db"],
36
+ pluginMetadataRegistry: new Map<string, PluginMetadata>(),
37
+ cleanupHandlers: new Map<string, Array<() => Promise<void>>>(),
38
+ pluginContractRegistry: new Map<string, AnyContractRouter>(),
39
+ };
40
+ }
41
+
42
+ // A module whose `default` export is missing models a package mis-typed as a
43
+ // backend plugin. `registerPlugin` accepts `BackendPlugin | undefined` precisely
44
+ // because `pluginModule.default` can be `undefined` at runtime, so no cast is
45
+ // needed to exercise the guard.
46
+ const missingDefaultExport = undefined;
47
+
48
+ describe("registerPlugin skip diagnostic", () => {
49
+ let warnSpy: ReturnType<typeof spyOn>;
50
+
51
+ beforeEach(() => {
52
+ warnSpy = spyOn(rootLogger, "warn").mockImplementation(() => rootLogger);
53
+ });
54
+
55
+ afterEach(() => {
56
+ warnSpy.mockRestore();
57
+ });
58
+
59
+ it("names the package by its database-row name (never 'unknown') when the default export is missing", () => {
60
+ const pendingInits: PendingInit[] = [];
61
+
62
+ registerPlugin({
63
+ backendPlugin: missingDefaultExport,
64
+ pluginPath: "/workspace/core/signal-backend",
65
+ pluginName: "@checkstack/signal-backend",
66
+ pendingInits,
67
+ providedBy: new Map<string, string>(),
68
+ deps: makeDeps(new ServiceRegistry()),
69
+ });
70
+
71
+ expect(pendingInits).toHaveLength(0);
72
+ expect(warnSpy).toHaveBeenCalledTimes(1);
73
+ const message = String(warnSpy.mock.calls[0]?.[0]);
74
+ expect(message).toContain("@checkstack/signal-backend");
75
+ expect(message).not.toContain("unknown");
76
+ });
77
+
78
+ it("falls back to the plugin path when no name is available", () => {
79
+ const pendingInits: PendingInit[] = [];
80
+
81
+ registerPlugin({
82
+ backendPlugin: missingDefaultExport,
83
+ pluginPath: "/workspace/core/mystery-backend",
84
+ pendingInits,
85
+ providedBy: new Map<string, string>(),
86
+ deps: makeDeps(new ServiceRegistry()),
87
+ });
88
+
89
+ expect(warnSpy).toHaveBeenCalledTimes(1);
90
+ const message = String(warnSpy.mock.calls[0]?.[0]);
91
+ expect(message).toContain("/workspace/core/mystery-backend");
92
+ expect(message).not.toContain("unknown");
93
+ });
94
+
95
+ it("does not warn when a valid plugin with register() is registered", () => {
96
+ const pendingInits: PendingInit[] = [];
97
+ const plugin = createBackendPlugin({
98
+ metadata: { pluginId: "valid-plugin" },
99
+ register: () => {
100
+ // no-op: a valid plugin must not trigger the skip diagnostic.
101
+ },
102
+ });
103
+
104
+ registerPlugin({
105
+ backendPlugin: plugin,
106
+ pluginPath: "/workspace/core/valid-backend",
107
+ pluginName: "@checkstack/valid-backend",
108
+ pendingInits,
109
+ providedBy: new Map<string, string>(),
110
+ deps: makeDeps(new ServiceRegistry()),
111
+ });
112
+
113
+ expect(warnSpy).not.toHaveBeenCalled();
114
+ });
115
+ });
@@ -71,27 +71,72 @@ export interface PluginLoaderDeps {
71
71
  onApiRouteRegistered?: () => void;
72
72
  }
73
73
 
74
+ /**
75
+ * Resolves a human-identifiable name for a plugin module so diagnostics never
76
+ * render the opaque literal "unknown". Prefers the declared `pluginId`, then
77
+ * the package name from the database/discovery row, then the on-disk path.
78
+ */
79
+ function resolvePluginIdentifier({
80
+ backendPlugin,
81
+ pluginName,
82
+ pluginPath,
83
+ }: {
84
+ backendPlugin: BackendPlugin | undefined;
85
+ pluginName?: string;
86
+ pluginPath: string;
87
+ }): string {
88
+ const pluginId = backendPlugin?.metadata?.pluginId;
89
+ if (pluginId) {
90
+ return pluginId;
91
+ }
92
+ if (pluginName) {
93
+ return pluginName;
94
+ }
95
+ if (pluginPath) {
96
+ return pluginPath;
97
+ }
98
+ return "unknown";
99
+ }
100
+
74
101
  /**
75
102
  * Registers a single plugin - called during Phase 1.
76
103
  */
77
104
  export function registerPlugin({
78
105
  backendPlugin,
79
106
  pluginPath,
107
+ pluginName,
80
108
  pendingInits,
81
109
  providedBy,
82
110
  deps,
83
111
  }: {
84
- backendPlugin: BackendPlugin;
112
+ /**
113
+ * The plugin module's default export. Typed as possibly `undefined` because
114
+ * a package mis-typed as `checkstack.type: "backend"` may have no default
115
+ * export at all (`pluginModule.default` is `undefined` at runtime); the guard
116
+ * below handles that case explicitly.
117
+ */
118
+ backendPlugin: BackendPlugin | undefined;
85
119
  pluginPath: string;
120
+ /**
121
+ * Package name of the plugin from its database/discovery row. Used so the
122
+ * "no register() export" diagnostic can name the offending package even when
123
+ * its module has no default export carrying `metadata.pluginId`.
124
+ */
125
+ pluginName?: string;
86
126
  pendingInits: PendingInit[];
87
127
  providedBy: Map<string, string>;
88
128
  deps: PluginLoaderDeps;
89
129
  }) {
90
130
  if (!backendPlugin || typeof backendPlugin.register !== "function") {
131
+ const identifier = resolvePluginIdentifier({
132
+ backendPlugin,
133
+ pluginName,
134
+ pluginPath,
135
+ });
91
136
  rootLogger.warn(
92
- `Plugin ${
93
- backendPlugin?.metadata?.pluginId || "unknown"
94
- } is not using new API. Skipping.`,
137
+ `Plugin '${identifier}' has no backend register() export and was skipped. ` +
138
+ `If this package is a host-consumed library rather than a runtime plugin, ` +
139
+ `set its package.json "checkstack.type" to "tooling" so it is not discovered as a backend plugin.`,
95
140
  );
96
141
  return;
97
142
  }
@@ -201,11 +246,21 @@ export function registerPlugin({
201
246
  export async function loadPlugins({
202
247
  rootRouter,
203
248
  manualPlugins = [],
249
+ manualPluginPaths,
204
250
  skipDiscovery = false,
205
251
  deps,
206
252
  }: {
207
253
  rootRouter: Hono;
208
254
  manualPlugins?: BackendPlugin[];
255
+ /**
256
+ * Optional `pluginId -> on-disk plugin dir` map for manually-loaded
257
+ * plugins. When a manual plugin has an entry here, its Drizzle migrations
258
+ * (in `<dir>/drizzle`) are applied just like a discovered plugin's. The
259
+ * dev server passes the plugin's `CHECKSTACK_DEV_PLUGIN_PATH` here so a
260
+ * scaffolded plugin's tables exist on first boot; without it, manual
261
+ * plugins keep their historical `pluginPath: ""` (migrations skipped).
262
+ */
263
+ manualPluginPaths?: Map<string, string>;
209
264
  /** When true, skip filesystem plugin discovery (for testing) */
210
265
  skipDiscovery?: boolean;
211
266
  deps: PluginLoaderDeps;
@@ -286,6 +341,7 @@ export async function loadPlugins({
286
341
  registerPlugin({
287
342
  backendPlugin,
288
343
  pluginPath: plugin.path,
344
+ pluginName: plugin.name,
289
345
  pendingInits,
290
346
  providedBy,
291
347
  deps,
@@ -301,7 +357,10 @@ export async function loadPlugins({
301
357
  for (const backendPlugin of manualPlugins) {
302
358
  registerPlugin({
303
359
  backendPlugin,
304
- pluginPath: "",
360
+ // Use the supplied dir (dev server passes the plugin's path so its
361
+ // migrations run); fall back to "" for manual plugins without one.
362
+ pluginPath:
363
+ manualPluginPaths?.get(backendPlugin.metadata.pluginId) ?? "",
305
364
  pendingInits,
306
365
  providedBy,
307
366
  deps,
@@ -197,11 +197,15 @@ export class PluginManager {
197
197
  async loadPlugins(
198
198
  rootRouter: Hono,
199
199
  manualPlugins: BackendPlugin[] = [],
200
- options: { skipDiscovery?: boolean } = {}
200
+ options: {
201
+ skipDiscovery?: boolean;
202
+ manualPluginPaths?: Map<string, string>;
203
+ } = {}
201
204
  ) {
202
205
  await loadPluginsImpl({
203
206
  rootRouter,
204
207
  manualPlugins,
208
+ manualPluginPaths: options.manualPluginPaths,
205
209
  skipDiscovery: options.skipDiscovery,
206
210
  deps: {
207
211
  registry: this.registry,
@@ -4,6 +4,7 @@ import type {
4
4
  TransportClient,
5
5
  RegisteredCollector,
6
6
  } from "@checkstack/backend-api";
7
+ import { assertNoSecretTemplatableConflict } from "@checkstack/backend-api";
7
8
  import { rootLogger } from "../logger";
8
9
 
9
10
  /**
@@ -19,6 +20,14 @@ export class CoreCollectorRegistry {
19
20
  ): void {
20
21
  // Use fully-qualified ID: ownerPluginId.collectorId
21
22
  const qualifiedId = `${ownerPlugin.pluginId}.${collector.id}`;
23
+ // Load-time guard: a config field must not be both a secret and
24
+ // templatable (they resolve in separate ordered passes). Fail fast on a
25
+ // misconfigured collector rather than silently skipping one pass at run
26
+ // time.
27
+ assertNoSecretTemplatableConflict({
28
+ schema: collector.config.schema,
29
+ schemaName: `collector:${qualifiedId}`,
30
+ });
22
31
  if (this.collectors.has(qualifiedId)) {
23
32
  rootLogger.warn(
24
33
  `CollectorStrategy '${qualifiedId}' is already registered. Overwriting.`
@@ -1,9 +1,26 @@
1
- import { describe, it, expect } from "bun:test";
2
- import { createDevAuthService } from "./dev-auth";
1
+ import { describe, it, expect, mock } from "bun:test";
2
+
3
+ // dev-auth signs/verifies S2S tokens via jwtService, which is backed by the
4
+ // DB-bound KeyStore. Mock it so these stay pure unit tests (no DB): tokens are
5
+ // modeled as `svc:<pluginId>` strings so we can assert the wiring without real
6
+ // crypto.
7
+ mock.module("./jwt", () => ({
8
+ jwtService: {
9
+ sign: async (payload: Record<string, unknown>) =>
10
+ `svc:${String(payload.service)}`,
11
+ verify: async (token: string) =>
12
+ token.startsWith("svc:") ? { service: token.slice(4) } : undefined,
13
+ },
14
+ }));
15
+
16
+ const { createDevAuthService } = await import("./dev-auth");
3
17
 
4
18
  describe("createDevAuthService", () => {
5
19
  it("authenticate() returns a stable RealUser identity", async () => {
6
- const svc = createDevAuthService({ getAllAccessRules: () => [] });
20
+ const svc = createDevAuthService({
21
+ getAllAccessRules: () => [],
22
+ pluginId: "dev",
23
+ });
7
24
  const user = await svc.authenticate(new Request("http://x"));
8
25
  expect(user).toBeDefined();
9
26
  if (!user || user.type !== "user") {
@@ -22,6 +39,7 @@ describe("createDevAuthService", () => {
22
39
  { id: "catalog.system.read" },
23
40
  { id: "catalog.system.manage" },
24
41
  ],
42
+ pluginId: "dev",
25
43
  });
26
44
  const user = await svc.authenticate(new Request("http://x"));
27
45
  if (!user || user.type !== "user") throw new Error("expected RealUser");
@@ -33,7 +51,10 @@ describe("createDevAuthService", () => {
33
51
 
34
52
  it("re-reads access rules on each authenticate (rules registered later still apply)", async () => {
35
53
  const rules = [{ id: "first.rule" }];
36
- const svc = createDevAuthService({ getAllAccessRules: () => rules });
54
+ const svc = createDevAuthService({
55
+ getAllAccessRules: () => rules,
56
+ pluginId: "dev",
57
+ });
37
58
 
38
59
  const before = await svc.authenticate(new Request("http://x"));
39
60
  if (!before || before.type !== "user") throw new Error("expected RealUser");
@@ -45,20 +66,58 @@ describe("createDevAuthService", () => {
45
66
  expect(after.accessRules).toEqual(["first.rule", "second.rule"]);
46
67
  });
47
68
 
69
+ it("authenticate() honors real SERVICE tokens (S2S calls stay services, not the dev user)", async () => {
70
+ const svc = createDevAuthService({
71
+ getAllAccessRules: () => [],
72
+ pluginId: "dev",
73
+ });
74
+ // A backend-to-backend caller presents a token minted as `{ service: <id> }`.
75
+ const principal = await svc.authenticate(
76
+ new Request("http://x", {
77
+ headers: { Authorization: "Bearer svc:incident" },
78
+ }),
79
+ );
80
+ expect(principal).toEqual({ type: "service", pluginId: "incident" });
81
+ });
82
+
83
+ it("authenticate() falls back to the dev user for non-service tokens", async () => {
84
+ const svc = createDevAuthService({
85
+ getAllAccessRules: () => [{ id: "x" }],
86
+ pluginId: "dev",
87
+ });
88
+ const principal = await svc.authenticate(
89
+ new Request("http://x", {
90
+ headers: { Authorization: "Bearer not-a-real-token" },
91
+ }),
92
+ );
93
+ if (!principal || principal.type !== "user") {
94
+ throw new Error("expected RealUser");
95
+ }
96
+ expect(principal.id).toBe("dev-user");
97
+ });
98
+
48
99
  it("getAnonymousAccessRules returns an empty list (anonymous gets nothing in dev)", async () => {
49
100
  const svc = createDevAuthService({
50
101
  getAllAccessRules: () => [{ id: "x" }, { id: "y" }],
102
+ pluginId: "dev",
51
103
  });
52
104
  expect(await svc.getAnonymousAccessRules()).toEqual([]);
53
105
  });
54
106
 
55
- it("getCredentials returns an empty headers object", async () => {
56
- const svc = createDevAuthService({ getAllAccessRules: () => [] });
57
- expect(await svc.getCredentials()).toEqual({ headers: {} });
107
+ it("getCredentials mints a service token scoped to this plugin", async () => {
108
+ const svc = createDevAuthService({
109
+ getAllAccessRules: () => [],
110
+ pluginId: "notification",
111
+ });
112
+ const creds = await svc.getCredentials();
113
+ expect(creds.headers.Authorization).toBe("Bearer svc:notification");
58
114
  });
59
115
 
60
116
  it("checkResourceTeamAccess always grants", async () => {
61
- const svc = createDevAuthService({ getAllAccessRules: () => [] });
117
+ const svc = createDevAuthService({
118
+ getAllAccessRules: () => [],
119
+ pluginId: "dev",
120
+ });
62
121
  expect(
63
122
  await svc.checkResourceTeamAccess({
64
123
  userId: "x",
@@ -72,7 +131,10 @@ describe("createDevAuthService", () => {
72
131
  });
73
132
 
74
133
  it("getAccessibleResourceIds returns the input list unfiltered", async () => {
75
- const svc = createDevAuthService({ getAllAccessRules: () => [] });
134
+ const svc = createDevAuthService({
135
+ getAllAccessRules: () => [],
136
+ pluginId: "dev",
137
+ });
76
138
  expect(
77
139
  await svc.getAccessibleResourceIds({
78
140
  userId: "x",
@@ -1,4 +1,5 @@
1
1
  import type { AuthService, RealUser } from "@checkstack/backend-api";
2
+ import { jwtService } from "./jwt";
2
3
 
3
4
  /**
4
5
  * Dev-only auth service.
@@ -8,17 +9,37 @@ import type { AuthService, RealUser } from "@checkstack/backend-api";
8
9
  * synthetic user that has every registered access rule, so any procedure
9
10
  * (regardless of the access guards on it) authorizes.
10
11
  *
12
+ * IMPORTANT: real SERVICE tokens still resolve to a `ServiceUser`. dev-auth
13
+ * REPLACES the core auth factory, so without this passthrough every
14
+ * backend-to-backend call (and the service-only endpoints they hit, e.g.
15
+ * `notification.registerSubscriptionSpec` from a plugin's `afterPluginsReady`)
16
+ * would be authenticated as the synthetic USER and 403 with "for services
17
+ * only" - which fatally breaks app boot. Only non-service requests get the
18
+ * synthetic dev admin.
19
+ *
11
20
  * NEVER register this in production. The runtime gates installation
12
21
  * behind a `CHECKSTACK_DEV_AUTH=true` env var that we set explicitly in
13
22
  * the dev server entry point.
14
23
  *
15
24
  * The user identity is stable (`dev-user`) so plugin code that derives
16
25
  * UI / data from `user.id` behaves consistently across reloads.
26
+ *
27
+ * Note: app-principal tokens (automation `runAs`) are NOT specially handled
28
+ * here - in dev they fall through to the dev admin. That's a dev convenience;
29
+ * the bounded-principal enforcement is covered by unit/integration tests.
17
30
  */
18
31
  export function createDevAuthService({
19
32
  getAllAccessRules,
33
+ pluginId,
20
34
  }: {
21
35
  getAllAccessRules: () => Array<{ id: string }>;
36
+ /**
37
+ * The plugin this auth instance is scoped to (from the service-factory
38
+ * metadata). Used to mint S2S credentials as `{ service: pluginId }`, so
39
+ * service-only endpoints that check the caller's plugin (e.g. subscription
40
+ * spec ownership) accept the call - exactly like the production auth factory.
41
+ */
42
+ pluginId: string;
22
43
  }): AuthService {
23
44
  const devUser: RealUser = {
24
45
  type: "user",
@@ -31,15 +52,31 @@ export function createDevAuthService({
31
52
  };
32
53
 
33
54
  return {
34
- async authenticate(_request) {
35
- // Always grant every access rule the platform currently knows about.
36
- // We resolve this lazily so rules registered by plugins after auth
37
- // construction (the normal flow) still apply.
55
+ async authenticate(request) {
56
+ // Honor real service tokens so backend-to-backend calls (and the
57
+ // service-only endpoints they hit during boot) keep working.
58
+ const token = request.headers.get("Authorization")?.replace("Bearer ", "");
59
+ if (token) {
60
+ const payload = await jwtService.verify(token);
61
+ if (payload && payload.service) {
62
+ return { type: "service" as const, pluginId: payload.service as string };
63
+ }
64
+ }
65
+ // Otherwise authenticate as the synthetic dev admin. Grant every access
66
+ // rule the platform currently knows about, resolved lazily so rules
67
+ // registered by plugins after auth construction still apply.
38
68
  devUser.accessRules = getAllAccessRules().map((r) => r.id);
39
69
  return devUser;
40
70
  },
41
71
  async getCredentials() {
42
- return { headers: {} };
72
+ // Mint a real service token (as THIS plugin) so backend-to-backend calls
73
+ // authenticate as a SERVICE and pass service-only endpoints - including
74
+ // those that check the caller's plugin id (e.g. subscription-spec
75
+ // ownership) - matching production. Without this, S2S calls would carry
76
+ // no auth, be treated as the synthetic user, and 403 on service-only
77
+ // procedures (breaking boot).
78
+ const token = await jwtService.sign({ service: pluginId }, "5m");
79
+ return { headers: { Authorization: `Bearer ${token}` } };
43
80
  },
44
81
  async getAnonymousAccessRules() {
45
82
  // Anonymous users get nothing; the dev user is the only authenticated