@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.
- package/CHANGELOG.md +224 -0
- package/package.json +16 -16
- package/src/db.ts +109 -2
- 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 +87 -6
- 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
|
@@ -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
|
-
|
|
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
|