@checkstack/backend 0.15.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.
@@ -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
@@ -8,6 +8,7 @@ import {
8
8
  Versioned,
9
9
  VersionedAggregated,
10
10
  aggregatedCounter,
11
+ configString,
11
12
  } from "@checkstack/backend-api";
12
13
  import { createMockLogger } from "@checkstack/test-utils-backend";
13
14
  import { z } from "zod";
@@ -80,6 +81,39 @@ describe("CoreHealthCheckRegistry", () => {
80
81
  expect(registry.getStrategy(qualifiedId)).toBe(mockStrategy1);
81
82
  });
82
83
 
84
+ it("throws at register time when a field is both secret and templatable", () => {
85
+ const conflictingStrategy: HealthCheckStrategy = {
86
+ id: "conflicting-strategy",
87
+ displayName: "Conflicting",
88
+ description: "Has a field that is both secret and templatable",
89
+ config: new Versioned({
90
+ version: 1,
91
+ schema: z.object({
92
+ timeout: z.number().default(30_000),
93
+ token: configString({ "x-secret": true, "x-templatable": true }),
94
+ }),
95
+ }),
96
+ result: new Versioned({
97
+ version: 1,
98
+ schema: z.record(z.string(), z.unknown()),
99
+ }),
100
+ aggregatedResult: new VersionedAggregated({
101
+ version: 1,
102
+ fields: { count: aggregatedCounter({}) },
103
+ }),
104
+ createClient: mock(() =>
105
+ Promise.resolve({
106
+ client: { exec: async () => ({}) },
107
+ close: () => {},
108
+ }),
109
+ ),
110
+ mergeResult: mock(() => ({})),
111
+ };
112
+ expect(() =>
113
+ registry.registerWithOwner(conflictingStrategy, mockOwner),
114
+ ).toThrow(/both/);
115
+ });
116
+
83
117
  it("should overwrite an existing strategy with the same qualified ID", () => {
84
118
  const overwritingStrategy: HealthCheckStrategy = {
85
119
  ...mockStrategy1,
@@ -2,6 +2,7 @@ import type { PluginMetadata } from "@checkstack/common";
2
2
  import {
3
3
  HealthCheckRegistry,
4
4
  HealthCheckStrategy,
5
+ assertNoSecretTemplatableConflict,
5
6
  } from "@checkstack/backend-api";
6
7
  import { rootLogger } from "../logger";
7
8
 
@@ -30,6 +31,12 @@ export class CoreHealthCheckRegistry {
30
31
  ownerPlugin: PluginMetadata,
31
32
  ): void {
32
33
  const qualifiedId = `${ownerPlugin.pluginId}.${strategy.id}`;
34
+ // Load-time guard: secret + templatable on one field is a config error
35
+ // (the two are resolved in separate ordered passes).
36
+ assertNoSecretTemplatableConflict({
37
+ schema: strategy.config.schema,
38
+ schemaName: `strategy:${qualifiedId}`,
39
+ });
33
40
  if (this.strategies.has(qualifiedId)) {
34
41
  rootLogger.warn(
35
42
  `HealthCheckStrategy '${qualifiedId}' is already registered. Overwriting.`,
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { createServiceRef } from "@checkstack/backend-api";
3
+ import type { PluginMetadata } from "@checkstack/common";
4
+ import { ServiceRegistry } from "./service-registry";
5
+
6
+ const metadata: PluginMetadata = { pluginId: "core" };
7
+
8
+ describe("ServiceRegistry resolution precedence", () => {
9
+ it("resolves a factory before a plain instance for the same ref", async () => {
10
+ const ref = createServiceRef<string>("test.svc");
11
+ const registry = new ServiceRegistry();
12
+ registry.register(ref, "instance");
13
+ registry.registerFactory(ref, () => "factory");
14
+ // get() tries factories first — this is the long-standing behaviour the
15
+ // dev-auth fix relies on (see below).
16
+ expect(await registry.get(ref, metadata)).toBe("factory");
17
+ });
18
+
19
+ it("returns the registered instance when no factory exists", async () => {
20
+ const ref = createServiceRef<string>("test.svc.instance-only");
21
+ const registry = new ServiceRegistry();
22
+ registry.register(ref, "instance");
23
+ expect(await registry.get(ref, metadata)).toBe("instance");
24
+ });
25
+
26
+ // Regression guard (#251): the CHECKSTACK_DEV_AUTH bypass was dead code on
27
+ // the API path because the production auth FACTORY (registered by
28
+ // `registerCoreServices`) shadowed a dev auth INSTANCE registered via
29
+ // `registerService(coreServices.auth, …)` — `get()` resolves factories
30
+ // before instances, so the dev auth was never returned and every plugin
31
+ // API request 401ed. The fix registers the dev auth as a FACTORY so it
32
+ // wins. This test models that exact wiring on the auth ref.
33
+ it("dev auth registered as a factory overrides a pre-existing production auth factory", async () => {
34
+ const authRef = createServiceRef<{ kind: string }>("checkstack.auth");
35
+ const registry = new ServiceRegistry();
36
+
37
+ // Production setup: real auth registered as a factory.
38
+ const prodAuth = { kind: "production" };
39
+ registry.registerFactory(authRef, () => prodAuth);
40
+
41
+ // Sanity: before the dev override, get() returns the production factory.
42
+ expect((await registry.get(authRef, metadata)).kind).toBe("production");
43
+
44
+ // Dev path: register dev auth as a factory (NOT a plain instance).
45
+ const devAuth = { kind: "dev" };
46
+ registry.registerFactory(authRef, () => devAuth);
47
+
48
+ // The dev auth must now be what get() returns.
49
+ expect((await registry.get(authRef, metadata)).kind).toBe("dev");
50
+ });
51
+
52
+ it("a dev auth registered as a plain instance is (still) shadowed by a factory — documents why the fix uses a factory", async () => {
53
+ const authRef = createServiceRef<{ kind: string }>("checkstack.auth.legacy");
54
+ const registry = new ServiceRegistry();
55
+ registry.registerFactory(authRef, () => ({ kind: "production" }));
56
+ // This is the BUGGY shape the fix replaced: an instance is shadowed.
57
+ registry.register(authRef, { kind: "dev" });
58
+ expect((await registry.get(authRef, metadata)).kind).toBe("production");
59
+ });
60
+ });