@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.
@@ -0,0 +1,175 @@
1
+ import { spawn, type Subprocess } from "bun";
2
+
3
+ /**
4
+ * Shared sandbox for executing user-authored shell scripts through
5
+ * `sh -c`.
6
+ *
7
+ * Used by both `@checkstack/healthcheck-script-backend` (the shell
8
+ * health-check strategy) and `@checkstack/integration-script-backend`
9
+ * (the shell integration provider). The two had near-identical inline
10
+ * implementations; this module is the canonical version.
11
+ *
12
+ * Why a curated env: a script author already has authority to execute
13
+ * arbitrary shell, but we must not leak the satellite's own secrets to
14
+ * them. The forwarded env is the minimum needed for ordinary commands
15
+ * (`awk`, `curl`, `git`, locale-aware tools) to behave correctly:
16
+ * `PATH` so binaries resolve, `HOME` / `USER` so tools find their
17
+ * config, `LANG` / `LC_*` so output is parseable, `TZ` so timestamps
18
+ * are consistent, `TMPDIR` so `mktemp` works. Everything else
19
+ * (DB URLs, signing keys, queue creds, etc.) is dropped.
20
+ *
21
+ * Cleanup is `finally`-guaranteed: the timeout handle is cleared so a
22
+ * fast script doesn't leak an event-loop timer, and any straggler
23
+ * subprocess is `.kill()`-ed (idempotent on an already-exited
24
+ * process). This matches the pattern in the ESM script runner.
25
+ */
26
+
27
+ // =============================================================================
28
+ // PUBLIC TYPES
29
+ // =============================================================================
30
+
31
+ export interface ShellScriptRunResult {
32
+ /** Exit code reported by the subprocess. -1 if it never started or timed out. */
33
+ exitCode: number;
34
+ /** Captured stdout, trimmed of trailing newlines. */
35
+ stdout: string;
36
+ /** Captured stderr, trimmed of trailing newlines. */
37
+ stderr: string;
38
+ /** True if the timeout fired before the subprocess exited. */
39
+ timedOut: boolean;
40
+ }
41
+
42
+ export interface ShellScriptRunOptions {
43
+ /** Shell-script source. Fed verbatim to `sh -c`, so pipes, redirects, etc. work. */
44
+ script: string;
45
+ /** Maximum execution time in milliseconds. */
46
+ timeoutMs: number;
47
+ /** Optional working directory for the subprocess. */
48
+ cwd?: string;
49
+ /**
50
+ * Optional extra environment variables. Merged on top of the
51
+ * safe-vars whitelist (`PATH`, `HOME`, ...). User-supplied values
52
+ * win on key collision.
53
+ *
54
+ * Note: callers should validate the keys themselves if they need to
55
+ * forbid `LD_PRELOAD`, `NODE_OPTIONS`, etc. — at the shared-runner
56
+ * layer we accept whatever the caller passes, because the legitimate
57
+ * use cases (e.g. integration shell scripts injecting `PAYLOAD_*`
58
+ * vars) vary too much.
59
+ */
60
+ env?: Record<string, string>;
61
+ }
62
+
63
+ /**
64
+ * Injectable interface. Production code calls
65
+ * `defaultShellScriptRunner.run()`; tests can pass a mock to skip the
66
+ * actual subprocess spawn.
67
+ */
68
+ export interface ShellScriptRunner {
69
+ run(options: ShellScriptRunOptions): Promise<ShellScriptRunResult>;
70
+ }
71
+
72
+ // =============================================================================
73
+ // INTERNALS
74
+ // =============================================================================
75
+
76
+ /**
77
+ * Vars passed through to the subprocess. We intentionally do NOT
78
+ * forward the satellite's full env so backend secrets (DB URLs, API
79
+ * tokens, signing keys) never reach user-authored scripts.
80
+ */
81
+ const SAFE_ENV_VARS = [
82
+ "PATH",
83
+ "HOME",
84
+ "USER",
85
+ "LANG",
86
+ "LC_ALL",
87
+ "LC_CTYPE",
88
+ "TZ",
89
+ "TMPDIR",
90
+ "HOSTNAME",
91
+ "SHELL",
92
+ ];
93
+
94
+ function pickSafeEnv(): Record<string, string> {
95
+ const env: Record<string, string> = {};
96
+ for (const key of SAFE_ENV_VARS) {
97
+ const value = process.env[key];
98
+ if (value !== undefined) {
99
+ env[key] = value;
100
+ }
101
+ }
102
+ return env;
103
+ }
104
+
105
+ // =============================================================================
106
+ // DEFAULT RUNNER
107
+ // =============================================================================
108
+
109
+ /**
110
+ * Default runner implementation. Production code should use this; tests
111
+ * can substitute a mock that conforms to {@link ShellScriptRunner}.
112
+ */
113
+ export const defaultShellScriptRunner: ShellScriptRunner = {
114
+ async run({ script, timeoutMs, cwd, env }) {
115
+ let proc: Subprocess | undefined;
116
+ let timedOut = false;
117
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
118
+
119
+ const timeoutPromise = new Promise<never>((_, reject) => {
120
+ timeoutHandle = setTimeout(() => {
121
+ timedOut = true;
122
+ proc?.kill();
123
+ reject(new Error("Script execution timed out"));
124
+ }, timeoutMs);
125
+ });
126
+
127
+ try {
128
+ // Execute through `sh -c` so the user's script can use pipes,
129
+ // redirects, variable expansion, conditionals, command
130
+ // substitution, etc. — i.e. behave like a real shell script
131
+ // rather than a single argv vector.
132
+ proc = spawn({
133
+ cmd: ["sh", "-c", script],
134
+ cwd,
135
+ env: { ...pickSafeEnv(), ...env },
136
+ stdout: "pipe",
137
+ stderr: "pipe",
138
+ });
139
+
140
+ const [stdout, stderr, exitCode] = await Promise.race([
141
+ Promise.all([
142
+ new Response(proc.stdout as ReadableStream).text(),
143
+ new Response(proc.stderr as ReadableStream).text(),
144
+ proc.exited,
145
+ ]),
146
+ timeoutPromise,
147
+ ]);
148
+
149
+ return {
150
+ exitCode,
151
+ stdout: stdout.trim(),
152
+ stderr: stderr.trim(),
153
+ timedOut: false,
154
+ };
155
+ } catch (error) {
156
+ if (timedOut) {
157
+ return {
158
+ exitCode: -1,
159
+ stdout: "",
160
+ stderr: "Script execution timed out",
161
+ timedOut: true,
162
+ };
163
+ }
164
+ throw error;
165
+ } finally {
166
+ if (timeoutHandle !== undefined) {
167
+ clearTimeout(timeoutHandle);
168
+ }
169
+ // Idempotent — no-op when the subprocess has already exited
170
+ // cleanly, but guarantees we never leave a runaway `sh` from
171
+ // an exception path.
172
+ proc?.kill();
173
+ }
174
+ },
175
+ };
package/src/test-utils.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { mock } from "bun:test";
2
2
  import { RpcContext, EmitHookFn } from "./rpc";
3
+ import { Logger } from "./types";
3
4
  import { SafeDatabase } from "./plugin-system";
4
5
  import { HealthCheckRegistry } from "./health-check";
5
6
  import { CollectorRegistry } from "./collector-registry";
@@ -9,6 +10,22 @@ import {
9
10
  CacheManager,
10
11
  } from "@checkstack/cache-api";
11
12
 
13
+ /**
14
+ * Build a mock `Logger` whose `.child(...)` returns another mock logger
15
+ * (recursively). Matches the structural contract that
16
+ * `correlationMiddleware` and other binding sites rely on.
17
+ */
18
+ function createMockLogger(): Logger {
19
+ const logger: Logger = {
20
+ info: mock(),
21
+ error: mock(),
22
+ warn: mock(),
23
+ debug: mock(),
24
+ child: mock(() => createMockLogger()),
25
+ };
26
+ return logger;
27
+ }
28
+
12
29
  /**
13
30
  * Creates a mocked oRPC context for testing.
14
31
  * By default provides an authenticated user with wildcard access.
@@ -19,12 +36,7 @@ export function createMockRpcContext(
19
36
  return {
20
37
  pluginMetadata: { pluginId: "test-plugin" },
21
38
  db: mock() as unknown as SafeDatabase<Record<string, unknown>>,
22
- logger: {
23
- info: mock(),
24
- error: mock(),
25
- warn: mock(),
26
- debug: mock(),
27
- },
39
+ logger: createMockLogger(),
28
40
  fetch: {
29
41
  fetch: mock(),
30
42
  forPlugin: mock().mockReturnValue({
package/src/types.ts CHANGED
@@ -1,11 +1,45 @@
1
1
  import { ZodSchema } from "zod";
2
2
  import { ClientDefinition, InferClient } from "@checkstack/common";
3
3
 
4
+ /**
5
+ * Backend logger interface used everywhere in the platform via `RpcContext.logger`
6
+ * and the various `coreServices.logger` accessors.
7
+ *
8
+ * Each method accepts a free-form trailing argument list (`...args: unknown[]`)
9
+ * so the long-standing varargs callsites — `logger.error("…", err)` where `err`
10
+ * is an `Error`, or `logger.info("…", value1, value2)` — keep working unchanged.
11
+ *
12
+ * For NEW code, prefer the structured-metadata shape:
13
+ *
14
+ * logger.info("did something", { userId, durationMs });
15
+ *
16
+ * Winston's `splat` handling treats a single trailing plain object as
17
+ * structured metadata (merged into the log entry), and an `Error` instance as
18
+ * a special-cased error (with stack). Either shape lands in the same vararg
19
+ * slot here, so this signature covers both without overload churn.
20
+ *
21
+ * Auto-injected metadata (when the request flows through
22
+ * `correlationMiddleware`): `{ correlationId, pluginId, userId? }`. Do NOT
23
+ * include secrets in the structured-metadata object — it is forwarded
24
+ * verbatim to the log destination.
25
+ */
4
26
  export interface Logger {
5
27
  info(message: string, ...args: unknown[]): void;
6
28
  error(message: string, ...args: unknown[]): void;
7
29
  warn(message: string, ...args: unknown[]): void;
8
30
  debug(message: string, ...args: unknown[]): void;
31
+ /**
32
+ * Returns a derived logger with the supplied metadata bound to every
33
+ * subsequent log entry. Used by `correlationMiddleware` to attach
34
+ * `{ correlationId, pluginId, userId? }`, and available to handlers that
35
+ * want a tighter scope (e.g. `ctx.logger.child({ jobId })`).
36
+ *
37
+ * Optional only to keep minimal test-mock logger objects compatible with
38
+ * this interface — production loggers (Winston via `core/backend`) always
39
+ * implement it. Call sites that rely on metadata binding should branch
40
+ * on presence and fall back to the base logger when it is not available.
41
+ */
42
+ child?(meta: Record<string, unknown>): Logger;
9
43
  }
10
44
 
11
45
  export interface Fetch {