@checkstack/backend-api 0.15.3 → 0.17.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 CHANGED
@@ -1,5 +1,117 @@
1
1
  # @checkstack/backend-api
2
2
 
3
+ ## 0.17.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f23f3c9: Add `correlationMiddleware` to `@checkstack/backend-api` and apply it
8
+ to every plugin/core router so each request carries a stable
9
+ `x-correlation-id` (read from the inbound header, or freshly minted
10
+ via `crypto.randomUUID()` when absent) and an auto-injected child
11
+ logger bound with `{ correlationId, pluginId, userId? }`. The ID is
12
+ echoed back on the response header so the caller can correlate their
13
+ client-side trace to the server logs.
14
+
15
+ The `Logger` interface in `@checkstack/backend-api` now formally
16
+ documents the structured-metadata convention (`logger.info("msg",
17
+ { ...meta })`) alongside the long-standing varargs shape. Winston's
18
+ splat handling already routes both shapes through the same vararg
19
+ slot, so existing call sites are unaffected. A new optional
20
+ `Logger.child(meta)` method captures the metadata-binding contract the
21
+ new middleware relies on; production loggers always implement it,
22
+ minimal test mocks may omit it (the middleware falls back gracefully).
23
+
24
+ `RpcContext` grew two optional `Headers` bags, `requestHeaders` and
25
+ `responseHeaders`, populated by the outer Hono `/api/*` and `/rest/*`
26
+ handlers in `@checkstack/backend`. They are write-through observation
27
+ points for middleware; an `RpcContext` constructed without them (S2S
28
+ clients, tests) keeps working — the echo is a silent no-op and the ID
29
+ is still bound onto the child logger for server-side correlation.
30
+
31
+ The scaffolding template in `@checkstack/scripts` was updated so any
32
+ new plugin generated via `bun run create` wires the middleware in the
33
+ expected `.use(correlationMiddleware).use(autoAuthMiddleware)` order
34
+ out of the box.
35
+
36
+ ### Patch Changes
37
+
38
+ - Updated dependencies [f23f3c9]
39
+ - Updated dependencies [f23f3c9]
40
+ - @checkstack/common@0.11.0
41
+ - @checkstack/healthcheck-common@1.1.2
42
+ - @checkstack/signal-common@0.2.4
43
+ - @checkstack/cache-api@0.3.4
44
+ - @checkstack/queue-api@0.3.4
45
+
46
+ ## 0.16.0
47
+
48
+ ### Minor Changes
49
+
50
+ - a06b899: Dead-code audit cleanup and a small platform of shared notification helpers.
51
+
52
+ **Removed (dead code)**
53
+
54
+ - `core/backend/src/plugin-manager/deregistration-guard.ts` deleted. The exported `assertCanDeregister()` was never called and was a less-complete version of the dependents+isUninstallable checks already done inline by `previewUninstallOriginator` / `uninstallOriginator` in `plugin-manager-orchestrator.ts`.
55
+ - `createMockQueueFactory` deprecated alias removed from `@checkstack/test-utils-backend`. Use `createMockQueueManager` directly.
56
+
57
+ **New shared helpers**
58
+
59
+ - `@checkstack/backend-api` now exports `requestTimeoutMs()` — a Zod field builder for outbound HTTP request timeouts (1s..60s, default 10s). Replaces hand-rolled `configNumber({}).min(1000).max(60_000).default(10_000)` in `integration-webhook-backend`, `integration-script-backend`, and `healthcheck-script-backend`'s inline collector.
60
+ - `@checkstack/notification-common` now exports `SubjectStatusSchema` / `SubjectStatus`, mirroring the existing `ImportanceSchema`.
61
+ - `@checkstack/notification-backend` now exports:
62
+ - `SUBJECT_STATUS_EMOJI` / `IMPORTANCE_EMOJI` — the shared status / importance emoji maps that Discord, Slack, Teams, Webex and Telegram previously each redefined inline.
63
+ - `postJson(opts)` — a timeout-bounded `fetch` wrapper that handles non-2xx logging and error mapping for webhook-style POSTs. Returns `{ ok: true, response } | { ok: false, error }`.
64
+
65
+ **Migrated to shared helpers**
66
+
67
+ - Discord, Slack, Gotify, Pushover notification backends now use `postJson`. Outer try/catch + per-plugin error mapping deleted (~140 LOC).
68
+ - Discord, Slack, Teams, Telegram, Webex notification backends now use `IMPORTANCE_EMOJI`. Discord, Slack, Teams use `SUBJECT_STATUS_EMOJI`.
69
+ - Teams, Webex, Backstage, Telegram kept their inline fetch/Bot logic: their error strings surface server response bodies to operators, or the transport isn't raw `fetch` (Telegram uses `grammy`'s `Bot`).
70
+
71
+ **API surface tightening**
72
+
73
+ - Per-plugin test-only re-exports in 6 notification backends (Pushover, Gotify, Backstage, Slack, Discord, Teams) and the `CertificateInfo` interface in `healthcheck-tls-backend/strategy.ts` are now JSDoc-tagged `@internal`. No behaviour change; signals that downstream consumers must not depend on them.
74
+
75
+ - a06b899: Extract shared `EsmScriptRunner` + `ShellScriptRunner` utilities, fix HIGH-severity privilege amplification in the integration TS provider, and harden the integration shell setupGuide example.
76
+
77
+ **SECURITY FIX (HIGH)**
78
+
79
+ The integration TS provider (`@checkstack/integration-script-backend` → `scriptProvider`) previously executed user scripts via `new Function(script)` in the satellite's main V8 isolate. A user with `integrationAccess.manage` could read `globalThis.process.env` directly (`DATABASE_URL`, `JWT_SECRET`, queue credentials, signing keys, …) and exfiltrate them through `result.id` — which round-trips into `delivery_logs.externalId` and is readable via the `getDeliveryLog` ORPC procedure. The same permission grants no legitimate API to those secrets; this was a privilege amplification.
80
+
81
+ The provider now runs user scripts in a fresh Bun subprocess (matching the healthcheck inline-script collector model). The subprocess receives only a curated `SAFE_ENV_VARS` whitelist (`PATH`, `HOME`, `USER`, `LANG`, `LC_ALL`, `LC_CTYPE`, `TZ`, `TMPDIR`, `HOSTNAME`, `SHELL`) — backend secrets are no longer visible to user code. Filesystem reads, network calls (`fetch`), and the rest of the Node/Bun standard library continue to work, just in an isolated process.
82
+
83
+ **BREAKING CHANGE (`@checkstack/integration-script-backend`)**
84
+
85
+ User scripts can no longer read the satellite process's environment variables (`process.env.DATABASE_URL` etc. return `undefined`). Scripts that legitimately need configuration should accept it via the provider's `script` field input, not by introspecting the host environment. The full Node/Bun stdlib remains available; only the env scrub is new.
86
+
87
+ **REFACTOR — new shared utilities in `@checkstack/backend-api`**
88
+
89
+ Both the healthcheck and integration plugins had near-identical inline implementations of "run a user script in a subprocess sandbox" (ESM path) and "run a user shell script through `sh -c`" (shell path). These are now canonical, single-source-of-truth utilities:
90
+
91
+ - **`defaultEsmScriptRunner.run({ script, context, timeoutMs, helperModuleName?, helperFunctionName? })`** — writes the user module to a fresh `mkdtemp` directory along with a generated runner module, spawns a Bun subprocess with `pickSafeEnv()`, parses the result back through a UUID-tagged stderr marker, and tears everything down in `finally` (`clearTimeout` + `proc.kill()` + recursive `rm`). The optional `helperModuleName` / `helperFunctionName` pair drops a sibling `_helpers.mjs` file and rewrites `import { <fn> } from "<module>"` to point at it (this is the trick that makes `@checkstack/healthcheck` / `@checkstack/integration` resolve at runtime even though they're not real npm packages).
92
+ - **`defaultShellScriptRunner.run({ script, timeoutMs, cwd?, env? })`** — invokes `sh -c <script>` via `Bun.spawn` with `SAFE_ENV_VARS` (user-supplied `env` merged on top), `Promise.race` timeout with `proc.kill()` on expiry, and the same `clearTimeout` + `proc.kill()` cleanup in `finally`.
93
+
94
+ Both runners expose `EsmScriptRunner` / `ShellScriptRunner` interfaces so tests can inject mocks without touching the spawn path. The four call sites (`plugins/healthcheck-script-backend/src/inline-script-collector.ts`, `strategy.ts` and `plugins/integration-script-backend/src/provider.ts`, `shell-provider.ts`) collapse from full inline implementations to ~8-line adapters.
95
+
96
+ **FIXES**
97
+
98
+ - Integration shell provider's `setupGuide` example replaced the unsafe `curl -d "{\"title\": \"$PAYLOAD_TITLE\"}"` JSON interpolation with a `jq -n --arg title "$PAYLOAD_TITLE" '{title: $title}'` pattern. The previous example demonstrated a shell-injection vulnerability whenever event payload values contained shell-special or JSON-special characters (which they can, since payloads come from other plugins / events / GitOps reconciles).
99
+ - The shared shell runner adds `clearTimeout` + idempotent `proc?.kill()` in `finally`, fixing a leaked event-loop timer in the integration shell provider's previous inline implementation.
100
+
101
+ **TESTS**
102
+
103
+ - New `core/backend-api/src/esm-script-runner.test.ts` covering `normaliseUserScript` + `rewriteHelperImports` across both healthcheck and integration helper-module names, including regex-metacharacter escape coverage.
104
+ - The plugin-local `inline-script-normaliser.test.ts` was deleted; the same coverage (plus more) lives at the canonical location with the utility.
105
+ - Integration TS provider console-logging tests updated: in the subprocess model, `console.warn` and `console.error` both write to stderr (Bun matches Node), so the provider forwards every stderr line to `logger.error`. `console.log({…})` uses Bun's native `util.inspect` format rather than `JSON.stringify`, so the JSON-logging test now asserts on substring presence instead of strict serialisation.
106
+
107
+ 2047 tests pass, lint + typecheck clean.
108
+
109
+ ### Patch Changes
110
+
111
+ - @checkstack/cache-api@0.3.3
112
+ - @checkstack/queue-api@0.3.3
113
+ - @checkstack/healthcheck-common@1.1.1
114
+
3
115
  ## 0.15.3
4
116
 
5
117
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend-api",
3
- "version": "0.15.3",
3
+ "version": "0.17.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -11,9 +11,9 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "@checkstack/common": "0.10.0",
14
- "@checkstack/healthcheck-common": "1.1.0",
15
- "@checkstack/cache-api": "0.3.1",
16
- "@checkstack/queue-api": "0.3.1",
14
+ "@checkstack/healthcheck-common": "1.1.1",
15
+ "@checkstack/cache-api": "0.3.3",
16
+ "@checkstack/queue-api": "0.3.3",
17
17
  "@checkstack/signal-common": "0.2.3",
18
18
  "@orpc/client": "^1.13.14",
19
19
  "@orpc/contract": "^1.13.14",
@@ -24,3 +24,22 @@ export const baseStrategyConfigSchema = z.object({
24
24
  * Base config type that all strategy configs must satisfy.
25
25
  */
26
26
  export type BaseStrategyConfig = z.infer<typeof baseStrategyConfigSchema>;
27
+
28
+ /**
29
+ * Shared timeout field for outbound HTTP requests (webhooks, integration
30
+ * deliveries, inline scripts). Standardises bounds and default across plugins
31
+ * so operators see a consistent UX in the config UI.
32
+ *
33
+ * Bounds: `1_000ms` to `60_000ms`, default `10_000ms`.
34
+ *
35
+ * @example
36
+ * ```typescript
37
+ * const provider = z.object({
38
+ * url: configString({}).url(),
39
+ * timeout: requestTimeoutMs().describe("Request timeout in milliseconds"),
40
+ * });
41
+ * ```
42
+ */
43
+ export function requestTimeoutMs() {
44
+ return configNumber({}).min(1000).max(60_000).default(10_000);
45
+ }
@@ -0,0 +1,191 @@
1
+ import { describe, expect, it, mock, type Mock } from "bun:test";
2
+ import { call, implement } from "@orpc/server";
3
+ import { z } from "zod";
4
+ import { proc } from "@checkstack/common";
5
+ import {
6
+ autoAuthMiddleware,
7
+ CORRELATION_ID_HEADER,
8
+ correlationMiddleware,
9
+ RpcContext,
10
+ } from "./rpc";
11
+ import { createMockRpcContext } from "./test-utils";
12
+ import { Logger } from "./types";
13
+
14
+ const echoLoggerProcedure = proc({
15
+ userType: "anonymous",
16
+ operationType: "query",
17
+ access: [],
18
+ }).output(
19
+ z.object({
20
+ correlationIdFromMeta: z.string().optional(),
21
+ pluginIdFromMeta: z.string().optional(),
22
+ userIdFromMeta: z.string().optional(),
23
+ }),
24
+ );
25
+
26
+ const testContract = {
27
+ echo: echoLoggerProcedure,
28
+ };
29
+
30
+ type LastChildMeta = Record<string, unknown> | undefined;
31
+
32
+ function buildRouterCapturingChildMeta(captureRef: { value: LastChildMeta }) {
33
+ const os = implement(testContract)
34
+ .$context<RpcContext>()
35
+ .use(correlationMiddleware)
36
+ .use(autoAuthMiddleware);
37
+
38
+ return os.router({
39
+ echo: os.echo.handler(({ context }) => {
40
+ // The capture happens in the child() mock on the test's logger.
41
+ // We just return the metadata that the middleware should have bound.
42
+ void context.logger;
43
+ return {
44
+ correlationIdFromMeta: captureRef.value?.correlationId as
45
+ | string
46
+ | undefined,
47
+ pluginIdFromMeta: captureRef.value?.pluginId as string | undefined,
48
+ userIdFromMeta: captureRef.value?.userId as string | undefined,
49
+ };
50
+ }),
51
+ });
52
+ }
53
+
54
+ function buildContextWithCapture(
55
+ overrides: Partial<RpcContext> = {},
56
+ ): {
57
+ context: RpcContext;
58
+ capture: { value: LastChildMeta };
59
+ childMock: Mock<(meta: Record<string, unknown>) => Logger>;
60
+ } {
61
+ const capture: { value: LastChildMeta } = { value: undefined };
62
+ const childMock = mock((meta: Record<string, unknown>): Logger => {
63
+ capture.value = meta;
64
+ return {
65
+ info: mock(),
66
+ error: mock(),
67
+ warn: mock(),
68
+ debug: mock(),
69
+ };
70
+ });
71
+ const baseContext = createMockRpcContext(overrides);
72
+ // Replace the logger so child() captures the bound metadata.
73
+ baseContext.logger = {
74
+ info: mock(),
75
+ error: mock(),
76
+ warn: mock(),
77
+ debug: mock(),
78
+ child: childMock,
79
+ };
80
+ return { context: baseContext, capture, childMock };
81
+ }
82
+
83
+ const UUID_V4_REGEX =
84
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
85
+
86
+ describe("correlationMiddleware", () => {
87
+ it("generates a UUID v4 when no x-correlation-id header is present", async () => {
88
+ const { context, capture } = buildContextWithCapture();
89
+ const router = buildRouterCapturingChildMeta(capture);
90
+
91
+ await call(router.echo, {}, { context });
92
+
93
+ expect(typeof capture.value?.correlationId).toBe("string");
94
+ expect(capture.value?.correlationId).toMatch(UUID_V4_REGEX);
95
+ });
96
+
97
+ it("uses the incoming x-correlation-id header when present", async () => {
98
+ const incoming = "11111111-2222-4333-8444-555555555555";
99
+ const { context, capture } = buildContextWithCapture({
100
+ requestHeaders: new Headers({ [CORRELATION_ID_HEADER]: incoming }),
101
+ });
102
+ const router = buildRouterCapturingChildMeta(capture);
103
+
104
+ await call(router.echo, {}, { context });
105
+
106
+ expect(capture.value?.correlationId).toBe(incoming);
107
+ });
108
+
109
+ it("ignores an empty x-correlation-id header and generates a fresh ID", async () => {
110
+ const { context, capture } = buildContextWithCapture({
111
+ requestHeaders: new Headers({ [CORRELATION_ID_HEADER]: "" }),
112
+ });
113
+ const router = buildRouterCapturingChildMeta(capture);
114
+
115
+ await call(router.echo, {}, { context });
116
+
117
+ expect(capture.value?.correlationId).toMatch(UUID_V4_REGEX);
118
+ });
119
+
120
+ it("echoes the correlation ID on responseHeaders when available", async () => {
121
+ const responseHeaders = new Headers();
122
+ const { context, capture } = buildContextWithCapture({
123
+ responseHeaders,
124
+ });
125
+ const router = buildRouterCapturingChildMeta(capture);
126
+
127
+ await call(router.echo, {}, { context });
128
+
129
+ const echoed = responseHeaders.get(CORRELATION_ID_HEADER);
130
+ expect(echoed).not.toBeNull();
131
+ expect(echoed).toBe(capture.value?.correlationId as string);
132
+ });
133
+
134
+ it("binds pluginId from context.pluginMetadata onto the child logger", async () => {
135
+ const { context, capture } = buildContextWithCapture({
136
+ pluginMetadata: { pluginId: "fancy-plugin" },
137
+ });
138
+ const router = buildRouterCapturingChildMeta(capture);
139
+
140
+ await call(router.echo, {}, { context });
141
+
142
+ expect(capture.value?.pluginId).toBe("fancy-plugin");
143
+ });
144
+
145
+ it("binds userId when the user has an id field", async () => {
146
+ const { context, capture } = buildContextWithCapture({
147
+ user: { type: "user", id: "user-abc", accessRules: ["*"] },
148
+ });
149
+ const router = buildRouterCapturingChildMeta(capture);
150
+
151
+ await call(router.echo, {}, { context });
152
+
153
+ expect(capture.value?.userId).toBe("user-abc");
154
+ });
155
+
156
+ it("omits userId when the user is a service (no id field)", async () => {
157
+ const { context, capture } = buildContextWithCapture({
158
+ user: { type: "service", pluginId: "internal-svc" },
159
+ });
160
+ const router = buildRouterCapturingChildMeta(capture);
161
+
162
+ await call(router.echo, {}, { context });
163
+
164
+ expect("userId" in (capture.value ?? {})).toBe(false);
165
+ });
166
+
167
+ it("falls back to the base logger when child() is not implemented", async () => {
168
+ // Reproduce a minimal mock logger without `.child` (e.g. a test mock
169
+ // that hasn't been updated). The middleware must not throw and the
170
+ // request must still complete.
171
+ const context = createMockRpcContext();
172
+ context.logger = {
173
+ info: mock(),
174
+ error: mock(),
175
+ warn: mock(),
176
+ debug: mock(),
177
+ // intentionally no child
178
+ };
179
+
180
+ const os = implement(testContract)
181
+ .$context<RpcContext>()
182
+ .use(correlationMiddleware)
183
+ .use(autoAuthMiddleware);
184
+ const router = os.router({
185
+ echo: os.echo.handler(() => ({})),
186
+ });
187
+
188
+ const result = await call(router.echo, {}, { context });
189
+ expect(result).toEqual({});
190
+ });
191
+ });
@@ -0,0 +1,169 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ normaliseUserScript,
4
+ rewriteHelperImports,
5
+ } from "./esm-script-runner";
6
+
7
+ /**
8
+ * The shared ESM-script runner applies two text transforms to user
9
+ * source before writing it to a temp `.mjs` file:
10
+ *
11
+ * 1. `normaliseUserScript` — wraps legacy `return X;` style scripts in
12
+ * an async IIFE so they're valid as a module's default export,
13
+ * while leaving real ESM modules (those with top-level
14
+ * `import`/`export`) untouched.
15
+ *
16
+ * 2. `rewriteHelperImports` — rewrites `from "<helperModuleName>"`
17
+ * to point at a runtime helper file written next to the user
18
+ * script.
19
+ *
20
+ * Both are easy to regress (a regex tweak that gets greedier than
21
+ * intended, an edge case with comments, …) and both directly affect
22
+ * whether user scripts run at all. Keep this suite tight.
23
+ *
24
+ * These were previously tested under
25
+ * `plugins/healthcheck-script-backend/src/inline-script-normaliser.test.ts`;
26
+ * the utilities now live in `@checkstack/backend-api` and are shared by
27
+ * both healthcheck (inline-script-collector) and integration
28
+ * (script-provider).
29
+ */
30
+
31
+ describe("normaliseUserScript", () => {
32
+ it("wraps a bare `return` body in an async IIFE default export", () => {
33
+ const out = normaliseUserScript("return { success: true };");
34
+ expect(out).toContain("export default await (async () => {");
35
+ expect(out).toContain("return { success: true };");
36
+ expect(out).toContain("})();");
37
+ });
38
+
39
+ it("wraps a no-return body (side-effect script) the same way", () => {
40
+ const out = normaliseUserScript("console.log('hi');");
41
+ expect(out).toContain("export default await (async () => {");
42
+ expect(out).toContain("console.log('hi');");
43
+ });
44
+
45
+ it("leaves a real ESM module (with top-level `import`) untouched", () => {
46
+ const src = `import { loadavg } from "node:os";\nexport default { success: true };`;
47
+ expect(normaliseUserScript(src)).toBe(src);
48
+ });
49
+
50
+ it("leaves a module with only `export default` (no import) untouched", () => {
51
+ const src = `export default { success: true };`;
52
+ expect(normaliseUserScript(src)).toBe(src);
53
+ });
54
+
55
+ it("leaves a script with `export` after leading whitespace untouched", () => {
56
+ // Top-level matters regardless of leading whitespace — the regex is
57
+ // anchored with `^\s*` per-line via the `m` flag.
58
+ const src = ` export default 42;`;
59
+ expect(normaliseUserScript(src)).toBe(src);
60
+ });
61
+
62
+ it("treats indented `return`-style scripts as legacy (wraps them)", () => {
63
+ // Per current behaviour: only top-of-line `export`/`import` qualifies
64
+ // as ESM. Anything else gets wrapped. This is intentional — a script
65
+ // that contains the substring "import" inside a comment shouldn't be
66
+ // misclassified as ESM and have its `return` left dangling.
67
+ const src = ` // imports are great\n return true;`;
68
+ expect(normaliseUserScript(src)).toContain("export default await");
69
+ });
70
+
71
+ it("appends a trailing newline before `})` so a trailing `//` comment doesn't swallow the brace", () => {
72
+ const out = normaliseUserScript("return true; // trailing comment");
73
+ // Confirm the wrapper structure didn't get clobbered by the comment.
74
+ expect(out).toMatch(/return true; \/\/ trailing comment\s*\n\}\)\(\)/);
75
+ });
76
+ });
77
+
78
+ describe("rewriteHelperImports", () => {
79
+ const HELPER_URL = "file:///tmp/checkstack-script-abc/_helpers.mjs";
80
+
81
+ it("rewrites a named import from the helper module", () => {
82
+ const out = rewriteHelperImports({
83
+ userScript: `import { defineHealthCheck } from "@checkstack/healthcheck";`,
84
+ helperModuleName: "@checkstack/healthcheck",
85
+ helperUrl: HELPER_URL,
86
+ });
87
+ expect(out).toBe(`import { defineHealthCheck } from "${HELPER_URL}";`);
88
+ });
89
+
90
+ it("works with single-quoted import specs too", () => {
91
+ const out = rewriteHelperImports({
92
+ userScript: `import { defineHealthCheck } from '@checkstack/healthcheck';`,
93
+ helperModuleName: "@checkstack/healthcheck",
94
+ helperUrl: HELPER_URL,
95
+ });
96
+ expect(out).toBe(`import { defineHealthCheck } from "${HELPER_URL}";`);
97
+ });
98
+
99
+ it("rewrites a side-effect import too", () => {
100
+ const out = rewriteHelperImports({
101
+ userScript: `import "@checkstack/healthcheck";`,
102
+ helperModuleName: "@checkstack/healthcheck",
103
+ helperUrl: HELPER_URL,
104
+ });
105
+ expect(out).toBe(`import "${HELPER_URL}";`);
106
+ });
107
+
108
+ it("leaves other imports alone", () => {
109
+ const src = `import { loadavg } from "node:os";\nimport fs from "node:fs/promises";`;
110
+ expect(
111
+ rewriteHelperImports({
112
+ userScript: src,
113
+ helperModuleName: "@checkstack/healthcheck",
114
+ helperUrl: HELPER_URL,
115
+ }),
116
+ ).toBe(src);
117
+ });
118
+
119
+ it("rewrites multiple occurrences", () => {
120
+ const src = `
121
+ import { defineHealthCheck } from "@checkstack/healthcheck";
122
+ import type { HealthCheckScriptResult } from "@checkstack/healthcheck";
123
+ `;
124
+ const out = rewriteHelperImports({
125
+ userScript: src,
126
+ helperModuleName: "@checkstack/healthcheck",
127
+ helperUrl: HELPER_URL,
128
+ });
129
+ expect(out.match(/@checkstack\/healthcheck/g)).toBeNull();
130
+ expect([...out.matchAll(new RegExp(HELPER_URL, "g"))].length).toBe(2);
131
+ });
132
+
133
+ it("doesn't rewrite the package name if it appears in a string literal", () => {
134
+ // Conservative regex: it only matches the spec position of an import
135
+ // statement (`from "..."` / `import "..."`). A string containing the
136
+ // package name elsewhere must be left alone.
137
+ const src = `console.log("Look up @checkstack/healthcheck on npm");`;
138
+ expect(
139
+ rewriteHelperImports({
140
+ userScript: src,
141
+ helperModuleName: "@checkstack/healthcheck",
142
+ helperUrl: HELPER_URL,
143
+ }),
144
+ ).toBe(src);
145
+ });
146
+
147
+ it("rewrites the integration helper module too (regex is parameterised)", () => {
148
+ // The shared runner is used by both healthcheck and integration
149
+ // plugins; the regex is built from the supplied `helperModuleName`.
150
+ // Sanity check that the integration variant works.
151
+ const out = rewriteHelperImports({
152
+ userScript: `import { defineIntegration } from "@checkstack/integration";`,
153
+ helperModuleName: "@checkstack/integration",
154
+ helperUrl: HELPER_URL,
155
+ });
156
+ expect(out).toBe(`import { defineIntegration } from "${HELPER_URL}";`);
157
+ });
158
+
159
+ it("escapes regex metacharacters in the helper module name", () => {
160
+ // Defence-in-depth: if someone ever passes a name with special
161
+ // chars, we don't want it interpreted as a regex.
162
+ const out = rewriteHelperImports({
163
+ userScript: `import x from "weird.pkg+name";`,
164
+ helperModuleName: "weird.pkg+name",
165
+ helperUrl: HELPER_URL,
166
+ });
167
+ expect(out).toBe(`import x from "${HELPER_URL}";`);
168
+ });
169
+ });