@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.
- package/CHANGELOG.md +182 -0
- package/package.json +22 -22
- package/src/index.ts +102 -6
- package/src/plugin-manager/api-router.ts +47 -8
- package/src/plugin-manager/app-principal-authz.test.ts +178 -0
- package/src/plugin-manager/auth-passthrough.test.ts +259 -0
- package/src/plugin-manager/core-services.ts +78 -0
- package/src/plugin-manager/pg-http-errors.test.ts +65 -0
- package/src/plugin-manager/pg-http-errors.ts +85 -0
- package/src/plugin-manager/plugin-loader.getservice.test.ts +38 -0
- package/src/plugin-manager/plugin-loader.skip-naming.test.ts +115 -0
- package/src/plugin-manager/plugin-loader.ts +64 -5
- package/src/plugin-manager.ts +5 -1
- package/src/services/collector-registry.ts +9 -0
- package/src/services/dev-auth.test.ts +71 -9
- package/src/services/dev-auth.ts +42 -5
- package/src/services/health-check-registry.test.ts +34 -0
- package/src/services/health-check-registry.ts +7 -0
- package/src/services/service-registry.test.ts +60 -0
- package/src/utils/plugin-discovery.test.ts +29 -4
|
@@ -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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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,
|
package/src/plugin-manager.ts
CHANGED
|
@@ -197,11 +197,15 @@ export class PluginManager {
|
|
|
197
197
|
async loadPlugins(
|
|
198
198
|
rootRouter: Hono,
|
|
199
199
|
manualPlugins: BackendPlugin[] = [],
|
|
200
|
-
options: {
|
|
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
|
-
|
|
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({
|
|
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({
|
|
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
|
|
56
|
-
const svc = createDevAuthService({
|
|
57
|
-
|
|
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({
|
|
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({
|
|
134
|
+
const svc = createDevAuthService({
|
|
135
|
+
getAllAccessRules: () => [],
|
|
136
|
+
pluginId: "dev",
|
|
137
|
+
});
|
|
76
138
|
expect(
|
|
77
139
|
await svc.getAccessibleResourceIds({
|
|
78
140
|
userId: "x",
|
package/src/services/dev-auth.ts
CHANGED
|
@@ -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(
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
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
|
-
|
|
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
|
+
});
|