@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,467 @@
1
+ import { spawn, type Subprocess } from "bun";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ /**
9
+ * Shared sandbox for executing user-authored TypeScript / JavaScript
10
+ * modules in a fresh Bun subprocess.
11
+ *
12
+ * Used by both `@checkstack/healthcheck-script-backend` (the inline
13
+ * health-check collector) and `@checkstack/integration-script-backend`
14
+ * (the script integration provider). The two had near-identical inline
15
+ * implementations; this module is the canonical version.
16
+ *
17
+ * Why subprocess isolation matters:
18
+ *
19
+ * Running user code via `new Function(script)` in the satellite's
20
+ * own process gives the user `globalThis.process`, `node:fs`, etc.
21
+ * — they can read every secret in `process.env` (DB URLs, signing
22
+ * keys, queue creds) and exfiltrate them through the result. Even
23
+ * `manage`-level users typically have no legitimate API for those
24
+ * secrets, so in-process eval is a privilege amplification.
25
+ *
26
+ * By spawning a separate Bun process with a curated `SAFE_ENV_VARS`
27
+ * subset, the user's script gets the full Node/Bun standard library
28
+ * to work with but cannot see the satellite's environment.
29
+ *
30
+ * Concurrency note: each `run()` invocation is fully isolated.
31
+ *
32
+ * - `mkdtemp` guarantees a unique directory name (POSIX-atomic).
33
+ * - The result-marker session id is a `randomUUID`, so each
34
+ * subprocess's stderr is unambiguously its own — even if user
35
+ * scripts happen to write text that looks like another invocation's
36
+ * marker.
37
+ * - The subprocess is launched with `cmd: [bun, runner.mjs]` whose
38
+ * references to the temp dir are absolute paths in env/argv. Two
39
+ * concurrent subprocesses can never read each other's user.mjs.
40
+ *
41
+ * Cleanup is `finally`-guaranteed: the timeout handle is cleared, any
42
+ * straggler subprocess is killed (idempotent on an already-exited
43
+ * process), and the temp dir is removed recursively — on success, on
44
+ * thrown error, AND on timeout.
45
+ */
46
+
47
+ // =============================================================================
48
+ // PUBLIC TYPES
49
+ // =============================================================================
50
+
51
+ export interface EsmScriptRunResult {
52
+ /** Raw value the user script returned (default export or legacy IIFE return). */
53
+ result?: unknown;
54
+ /** Error message if the script threw or failed to load. */
55
+ error?: string;
56
+ /** Stack trace if available. */
57
+ stack?: string;
58
+ /** Anything the script wrote to stdout — caller can surface as logs. */
59
+ stdout: string;
60
+ /** Anything the script wrote to stderr (with our result-marker stripped). */
61
+ stderr: string;
62
+ /** True if the timeout fired before the subprocess exited. */
63
+ timedOut: boolean;
64
+ }
65
+
66
+ export interface EsmScriptRunOptions {
67
+ /** User-supplied script source (modern ESM with `import`/`export`, or legacy `return X;`). */
68
+ script: string;
69
+ /** Object to expose as `globalThis.context` inside the subprocess. JSON-serialised; no functions / cycles. */
70
+ context: unknown;
71
+ /** Maximum execution time in milliseconds. */
72
+ timeoutMs: number;
73
+ /**
74
+ * Optional virtual module name that the user's script can `import`
75
+ * from. We write a sibling `_helpers.mjs` in the temp dir that
76
+ * exports a single identity function under `helperFunctionName`, and
77
+ * rewrite any `from "<helperModuleName>"` import in the user source
78
+ * to point at that file. Skipped if either field is omitted.
79
+ *
80
+ * @example
81
+ * helperModuleName: "@checkstack/healthcheck"
82
+ * helperFunctionName: "defineHealthCheck"
83
+ * // editor: import { defineHealthCheck } from "@checkstack/healthcheck"
84
+ * // runtime: import { defineHealthCheck } from "file:///tmp/.../_helpers.mjs"
85
+ */
86
+ helperModuleName?: string;
87
+ /** Name of the helper function injected as a global AND exported by the virtual module. */
88
+ helperFunctionName?: string;
89
+ }
90
+
91
+ /**
92
+ * Injectable interface. Production code calls
93
+ * `defaultEsmScriptRunner.run()`; tests can pass a mock to skip the
94
+ * actual subprocess spawn.
95
+ */
96
+ export interface EsmScriptRunner {
97
+ run(options: EsmScriptRunOptions): Promise<EsmScriptRunResult>;
98
+ }
99
+
100
+ // =============================================================================
101
+ // INTERNALS
102
+ // =============================================================================
103
+
104
+ /**
105
+ * Vars passed through to the subprocess. We intentionally do NOT
106
+ * forward the satellite's full env so backend secrets (DB URLs, API
107
+ * tokens, signing keys) never reach user-authored scripts. PATH / HOME
108
+ * / LANG / ... are kept so `node:child_process`, `node:fs`, and
109
+ * locale-sensitive APIs behave normally.
110
+ */
111
+ const SAFE_ENV_VARS = [
112
+ "PATH",
113
+ "HOME",
114
+ "USER",
115
+ "LANG",
116
+ "LC_ALL",
117
+ "LC_CTYPE",
118
+ "TZ",
119
+ "TMPDIR",
120
+ "HOSTNAME",
121
+ "SHELL",
122
+ ];
123
+
124
+ function pickSafeEnv(): Record<string, string> {
125
+ const env: Record<string, string> = {};
126
+ for (const key of SAFE_ENV_VARS) {
127
+ const value = process.env[key];
128
+ if (value !== undefined) {
129
+ env[key] = value;
130
+ }
131
+ }
132
+ return env;
133
+ }
134
+
135
+ // =============================================================================
136
+ // USER-SCRIPT NORMALISATION
137
+ // =============================================================================
138
+
139
+ const ESM_AT_TOP_LEVEL = /^\s*(import\s|export\s)/m;
140
+
141
+ /**
142
+ * Make the user's source loadable as an ES module.
143
+ *
144
+ * Three shapes are supported:
145
+ * 1. **Real module** (contains `import` / `export` at top level) — used as-is.
146
+ * 2. **Legacy IIFE-style** (`return X;` at top level) — wrapped in an
147
+ * async IIFE whose return value becomes the default export.
148
+ * 3. **Side-effect only** — treated as healthy unless it throws.
149
+ */
150
+ export function normaliseUserScript(userScript: string): string {
151
+ if (ESM_AT_TOP_LEVEL.test(userScript)) {
152
+ return userScript;
153
+ }
154
+ // Trailing newline so a `// comment` on the last line doesn't swallow
155
+ // the closing brace.
156
+ return `export default await (async () => {\n${userScript}\n})();\n`;
157
+ }
158
+
159
+ /**
160
+ * Rewrite imports of a virtual module to point at a real on-disk
161
+ * helper file. The user writes a clean package import in the editor
162
+ * (with IntelliSense from the virtual ambient module), and at runtime
163
+ * we redirect it to a local sibling.
164
+ *
165
+ * The regex is anchored to the literal spec position of `from "..."` /
166
+ * `import "..."` — it doesn't touch substrings of comments or string
167
+ * literals.
168
+ */
169
+ export function rewriteHelperImports({
170
+ userScript,
171
+ helperModuleName,
172
+ helperUrl,
173
+ }: {
174
+ userScript: string;
175
+ helperModuleName: string;
176
+ helperUrl: string;
177
+ }): string {
178
+ const escapedName = helperModuleName.replaceAll(
179
+ /[.*+?^${}()|[\]\\]/g,
180
+ String.raw`\$&`,
181
+ );
182
+ const fromRe = new RegExp(
183
+ String.raw`(from\s+)(["'])${escapedName}\2`,
184
+ "g",
185
+ );
186
+ const sideEffectRe = new RegExp(
187
+ String.raw`(import\s+)(["'])${escapedName}\2`,
188
+ "g",
189
+ );
190
+ return userScript
191
+ .replaceAll(fromRe, (_match, fromKw: string) =>
192
+ `${fromKw}${JSON.stringify(helperUrl)}`,
193
+ )
194
+ .replaceAll(sideEffectRe, (_match, importKw: string) =>
195
+ `${importKw}${JSON.stringify(helperUrl)}`,
196
+ );
197
+ }
198
+
199
+ // =============================================================================
200
+ // RUNNER GENERATION
201
+ // =============================================================================
202
+
203
+ function buildHelperSource(helperFunctionName: string): string {
204
+ return `// Auto-generated. Identity helper that exists only so the editor can
205
+ // type-check the user's return shape. The runtime is intentionally trivial.
206
+ export function ${helperFunctionName}(value) { return value; }
207
+ `;
208
+ }
209
+
210
+ function buildRunnerSource({
211
+ userScriptUrl,
212
+ contextJson,
213
+ helperFunctionName,
214
+ markerStart,
215
+ markerEnd,
216
+ }: {
217
+ userScriptUrl: string;
218
+ contextJson: string;
219
+ helperFunctionName: string | undefined;
220
+ markerStart: string;
221
+ markerEnd: string;
222
+ }): string {
223
+ const helperGlobal = helperFunctionName
224
+ ? `globalThis.${helperFunctionName} = (value) => value;\n`
225
+ : "";
226
+
227
+ // `String.raw` so embedded \n / \\ in the generated source survive
228
+ // verbatim to the temp file — the runner needs to write a real
229
+ // newline at runtime, which means the file on disk needs the two
230
+ // characters `\n` (not a real LF).
231
+ return String.raw`// Auto-generated runner for an inline user-script execution.
232
+ // Sets up the user-facing globals, imports the user module, captures the
233
+ // result, and writes it back to the parent through a stderr marker.
234
+
235
+ globalThis.context = ${contextJson};
236
+ ${helperGlobal}
237
+ const __markerStart = ${JSON.stringify(markerStart)};
238
+ const __markerEnd = ${JSON.stringify(markerEnd)};
239
+
240
+ function __emit(payload) {
241
+ // Single-line JSON, sandwiched between unique markers. The parent
242
+ // process does a lastIndexOf() to find it and is tolerant of
243
+ // arbitrary user output on stderr above.
244
+ process.stderr.write(__markerStart + JSON.stringify(payload) + __markerEnd + "\n");
245
+ }
246
+
247
+ try {
248
+ const __mod = await import(${JSON.stringify(userScriptUrl)});
249
+ let __result;
250
+ if (__mod && "default" in __mod && __mod.default !== undefined) {
251
+ const __def = __mod.default;
252
+ __result =
253
+ typeof __def === "function"
254
+ ? await __def(globalThis.context)
255
+ : __def;
256
+ }
257
+ __emit({ ok: true, result: __result ?? null });
258
+ } catch (err) {
259
+ const __message =
260
+ err && typeof err === "object" && "message" in err
261
+ ? String(err.message)
262
+ : String(err);
263
+ const __stack =
264
+ err && typeof err === "object" && "stack" in err
265
+ ? String(err.stack)
266
+ : undefined;
267
+ __emit({ ok: false, error: __message, stack: __stack });
268
+ process.exit(1);
269
+ }
270
+ `;
271
+ }
272
+
273
+ // =============================================================================
274
+ // MARKER PAYLOAD VALIDATION
275
+ // =============================================================================
276
+
277
+ type RunnerPayload =
278
+ | { ok: true; result: unknown }
279
+ | { ok: false; error: string; stack?: string };
280
+
281
+ function isRunnerPayload(value: unknown): value is RunnerPayload {
282
+ if (typeof value !== "object" || value === null) return false;
283
+ const v = value as Record<string, unknown>;
284
+ if (v.ok === true) return true;
285
+ if (v.ok === false && typeof v.error === "string") return true;
286
+ return false;
287
+ }
288
+
289
+ // =============================================================================
290
+ // DEFAULT RUNNER
291
+ // =============================================================================
292
+
293
+ /**
294
+ * Default runner implementation. Production code should use this; tests
295
+ * can substitute a mock that conforms to {@link EsmScriptRunner}.
296
+ */
297
+ export const defaultEsmScriptRunner: EsmScriptRunner = {
298
+ async run({
299
+ script,
300
+ context,
301
+ timeoutMs,
302
+ helperModuleName,
303
+ helperFunctionName,
304
+ }) {
305
+ const sessionId = randomUUID();
306
+ const markerStart = `##__CS_SCRIPT_RESULT_${sessionId}_START__##`;
307
+ const markerEnd = `##__CS_SCRIPT_RESULT_${sessionId}_END__##`;
308
+
309
+ const tmpDir = await mkdtemp(path.join(tmpdir(), "checkstack-script-"));
310
+ const userScriptPath = path.join(tmpDir, "user.mjs");
311
+ const runnerPath = path.join(tmpDir, "runner.mjs");
312
+
313
+ const hasHelper =
314
+ typeof helperModuleName === "string" &&
315
+ helperModuleName.length > 0 &&
316
+ typeof helperFunctionName === "string" &&
317
+ helperFunctionName.length > 0;
318
+ const helperPath = hasHelper ? path.join(tmpDir, "_helpers.mjs") : undefined;
319
+ const helperUrl = helperPath ? pathToFileURL(helperPath).href : undefined;
320
+
321
+ let proc: Subprocess | undefined;
322
+ let timedOut = false;
323
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
324
+
325
+ const timeoutPromise = new Promise<never>((_, reject) => {
326
+ timeoutHandle = setTimeout(() => {
327
+ timedOut = true;
328
+ proc?.kill();
329
+ reject(new Error("__TIMEOUT__"));
330
+ }, timeoutMs);
331
+ });
332
+
333
+ try {
334
+ // Helper module first so the user's
335
+ // `import { <fn> } from "<helperModuleName>"` (which we rewrite
336
+ // to point at this file's URL) resolves at module-evaluation time.
337
+ if (helperPath && helperFunctionName) {
338
+ await writeFile(helperPath, buildHelperSource(helperFunctionName), "utf8");
339
+ }
340
+
341
+ const normalisedSource = normaliseUserScript(script);
342
+ const userSource =
343
+ hasHelper && helperUrl
344
+ ? rewriteHelperImports({
345
+ userScript: normalisedSource,
346
+ helperModuleName: helperModuleName!,
347
+ helperUrl,
348
+ })
349
+ : normalisedSource;
350
+
351
+ await writeFile(userScriptPath, userSource, "utf8");
352
+ await writeFile(
353
+ runnerPath,
354
+ buildRunnerSource({
355
+ userScriptUrl: pathToFileURL(userScriptPath).href,
356
+ contextJson: JSON.stringify(context),
357
+ helperFunctionName: hasHelper ? helperFunctionName : undefined,
358
+ markerStart,
359
+ markerEnd,
360
+ }),
361
+ "utf8",
362
+ );
363
+
364
+ proc = spawn({
365
+ cmd: [process.execPath, runnerPath],
366
+ env: pickSafeEnv(),
367
+ stdout: "pipe",
368
+ stderr: "pipe",
369
+ });
370
+
371
+ let stdout: string;
372
+ let stderr: string;
373
+
374
+ try {
375
+ [stdout, stderr] = (await Promise.race([
376
+ Promise.all([
377
+ new Response(proc.stdout as ReadableStream).text(),
378
+ new Response(proc.stderr as ReadableStream).text(),
379
+ proc.exited,
380
+ ]),
381
+ timeoutPromise,
382
+ ])) as [string, string, number];
383
+ } catch (error) {
384
+ if (timedOut) {
385
+ return {
386
+ stdout: "",
387
+ stderr: "",
388
+ timedOut: true,
389
+ error: "Script execution timed out",
390
+ };
391
+ }
392
+ throw error;
393
+ }
394
+
395
+ // Pluck the runner payload out of stderr.
396
+ const startIdx = stderr.lastIndexOf(markerStart);
397
+ const endIdx = stderr.lastIndexOf(markerEnd);
398
+
399
+ let cleanStderr = stderr;
400
+ let payload: RunnerPayload | undefined;
401
+
402
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
403
+ const jsonStr = stderr.slice(startIdx + markerStart.length, endIdx);
404
+ try {
405
+ const parsed: unknown = JSON.parse(jsonStr);
406
+ if (isRunnerPayload(parsed)) {
407
+ payload = parsed;
408
+ }
409
+ } catch {
410
+ // Fall through to the "no marker" branch.
411
+ }
412
+ cleanStderr = (
413
+ stderr.slice(0, startIdx) + stderr.slice(endIdx + markerEnd.length)
414
+ )
415
+ .replace(/\n$/, "")
416
+ .trim();
417
+ }
418
+
419
+ if (!payload) {
420
+ // The runner never got far enough to emit — typically a syntax
421
+ // error in the user module or a hard crash. Surface whatever the
422
+ // subprocess wrote to stderr as the error.
423
+ return {
424
+ stdout: stdout.trim(),
425
+ stderr: cleanStderr,
426
+ timedOut: false,
427
+ error:
428
+ cleanStderr.length > 0
429
+ ? cleanStderr
430
+ : "Script exited without producing a result",
431
+ };
432
+ }
433
+
434
+ if (payload.ok) {
435
+ return {
436
+ result: payload.result,
437
+ stdout: stdout.trim(),
438
+ stderr: cleanStderr,
439
+ timedOut: false,
440
+ };
441
+ }
442
+
443
+ return {
444
+ error: payload.error,
445
+ stack: payload.stack,
446
+ stdout: stdout.trim(),
447
+ stderr: cleanStderr,
448
+ timedOut: false,
449
+ };
450
+ } finally {
451
+ // Order matters:
452
+ // 1. Clear the timer (otherwise a fast script leaks an
453
+ // event-loop handle for up to `timeoutMs`).
454
+ // 2. Kill any straggler subprocess. `.kill()` is idempotent on
455
+ // an already-exited process.
456
+ // 3. Remove the tempdir last — after the subprocess can no
457
+ // longer be touching its files.
458
+ if (timeoutHandle !== undefined) {
459
+ clearTimeout(timeoutHandle);
460
+ }
461
+ proc?.kill();
462
+ await rm(tmpDir, { recursive: true, force: true }).catch(() => {
463
+ // Best-effort. Anything left in /tmp will be reaped by the OS.
464
+ });
465
+ }
466
+ },
467
+ };
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export * from "./esm-script-runner";
2
+ export * from "./shell-script-runner";
1
3
  export * from "./service-ref";
2
4
  export * from "./extension-point";
3
5
  export * from "./core-services";
package/src/rpc.ts CHANGED
@@ -53,6 +53,21 @@ export interface RpcContext {
53
53
  cacheManager: CacheManager;
54
54
  /** Emit a hook event for cross-plugin communication */
55
55
  emitHook: EmitHookFn;
56
+ /**
57
+ * Inbound HTTP request headers (read-only view). Populated by the
58
+ * `/api/*` and `/rest/*` Hono handlers in `core/backend`. Optional
59
+ * because non-HTTP call sites (S2S clients, tests, scheduled queue
60
+ * jobs) can construct an `RpcContext` without a backing request.
61
+ */
62
+ requestHeaders?: Headers;
63
+ /**
64
+ * Mutable response headers. Middleware (e.g. `correlationMiddleware`)
65
+ * can set headers here, and the Hono handler that drives the oRPC
66
+ * `RPCHandler` / `OpenAPIHandler` merges them onto the actual
67
+ * `Response` after the procedure has run. Optional for the same
68
+ * reason as `requestHeaders`.
69
+ */
70
+ responseHeaders?: Headers;
56
71
  }
57
72
 
58
73
  /** Context with authenticated real user */
@@ -461,6 +476,91 @@ export const autoAuthMiddleware = os.middleware(
461
476
  },
462
477
  );
463
478
 
479
+ // =============================================================================
480
+ // CORRELATION ID MIDDLEWARE
481
+ // =============================================================================
482
+
483
+ /**
484
+ * Name of the inbound and outbound HTTP header that carries the correlation
485
+ * ID. Exported so dev tools, integration tests, and front-end fetch wrappers
486
+ * can refer to the canonical value rather than hard-coding the string.
487
+ */
488
+ export const CORRELATION_ID_HEADER = "x-correlation-id";
489
+
490
+ /**
491
+ * Per-request observability middleware.
492
+ *
493
+ * Behaviour:
494
+ * - Reads `x-correlation-id` from `context.requestHeaders` (populated by the
495
+ * `/api/*` and `/rest/*` Hono handlers in `core/backend`).
496
+ * - Generates a fresh UUID v4 via `crypto.randomUUID()` if absent. This is
497
+ * the ONLY generation site for correlation IDs in the platform — handlers
498
+ * must NOT mint new IDs on their own.
499
+ * - Binds `{ correlationId, pluginId, userId? }` onto a child logger via
500
+ * `ctx.logger.child(...)` so every subsequent log line in the request
501
+ * carries that metadata automatically.
502
+ * - Writes the ID back to `context.responseHeaders` (if available) so the
503
+ * outer Hono handler can echo `x-correlation-id` on the response, letting
504
+ * the caller correlate their own client-side trace to the server log.
505
+ *
506
+ * Note on the echo: oRPC middleware has no direct access to the outgoing
507
+ * `Response` object — the framework constructs it from the procedure's
508
+ * return value AFTER middleware has finished. We use the mutable
509
+ * `responseHeaders` bag on `RpcContext` as a thin write-through: the Hono
510
+ * route handler merges those headers onto the `Response` post-handle. When
511
+ * an `RpcContext` is constructed without `responseHeaders` (S2S clients,
512
+ * tests), the echo silently no-ops; the ID is still bound to the child
513
+ * logger so server-side correlation still works.
514
+ *
515
+ * Order matters: in plugin routers, `.use(correlationMiddleware)` MUST
516
+ * appear BEFORE `.use(autoAuthMiddleware)` so that auth failures still log
517
+ * with the correlation ID attached.
518
+ *
519
+ * Usage:
520
+ *
521
+ * const os = implement(myContract)
522
+ * .$context<RpcContext>()
523
+ * .use(correlationMiddleware)
524
+ * .use(autoAuthMiddleware);
525
+ */
526
+ export const correlationMiddleware = os.middleware(
527
+ async ({ next, context }) => {
528
+ const incoming = context.requestHeaders?.get(CORRELATION_ID_HEADER);
529
+ const correlationId =
530
+ incoming && incoming.length > 0 ? incoming : crypto.randomUUID();
531
+
532
+ context.responseHeaders?.set(CORRELATION_ID_HEADER, correlationId);
533
+
534
+ const meta: Record<string, unknown> = {
535
+ correlationId,
536
+ pluginId: context.pluginMetadata.pluginId,
537
+ };
538
+ if (context.user && "id" in context.user) {
539
+ meta.userId = context.user.id;
540
+ }
541
+
542
+ // `child` is optional on the Logger interface so minimal test-mock
543
+ // loggers don't have to implement it. Production Winston loggers
544
+ // always do; gracefully fall back to the base logger otherwise so
545
+ // the middleware never breaks a request just because metadata
546
+ // binding wasn't possible.
547
+ const boundLogger = context.logger.child
548
+ ? context.logger.child(meta)
549
+ : context.logger;
550
+
551
+ // Partial-merge style (no `...context` spread): oRPC merges the
552
+ // returned context fields onto the existing context. Spreading
553
+ // would widen TypeScript's inferred chain type and surface
554
+ // TS2883 "inferred type cannot be named" errors in downstream
555
+ // packages whose routers compose this middleware.
556
+ return next({
557
+ context: {
558
+ logger: boundLogger,
559
+ },
560
+ });
561
+ },
562
+ );
563
+
464
564
  /**
465
565
  * Extract a nested value from an object using dot notation.
466
566
  * E.g., getNestedValue({ params: { id: "123" } }, "params.id") => "123"