@checkstack/backend-api 0.18.0 → 0.20.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.
@@ -1,5 +1,9 @@
1
- import { describe, expect, it } from "bun:test";
1
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
2
+ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
2
5
  import {
6
+ defaultEsmScriptRunner,
3
7
  normaliseUserScript,
4
8
  rewriteHelperImports,
5
9
  } from "./esm-script-runner";
@@ -167,3 +171,91 @@ describe("rewriteHelperImports", () => {
167
171
  expect(out).toBe(`import x from "${HELPER_URL}";`);
168
172
  });
169
173
  });
174
+
175
+ describe("defaultEsmScriptRunner resolutionRoot", () => {
176
+ let root: string;
177
+
178
+ beforeAll(async () => {
179
+ // A throwaway "store" with a node_modules holding one fake package.
180
+ root = await mkdtemp(path.join(tmpdir(), "cs-resroot-"));
181
+ const pkgDir = path.join(root, "node_modules", "fake-pkg");
182
+ await mkdir(pkgDir, { recursive: true });
183
+ await writeFile(
184
+ path.join(pkgDir, "package.json"),
185
+ JSON.stringify({ name: "fake-pkg", version: "1.0.0", main: "index.mjs" }),
186
+ );
187
+ await writeFile(
188
+ path.join(pkgDir, "index.mjs"),
189
+ "export const greeting = 'hello-from-pkg';\n",
190
+ );
191
+ });
192
+
193
+ afterAll(async () => {
194
+ await rm(root, { recursive: true, force: true });
195
+ });
196
+
197
+ it("lets a script import a package from <resolutionRoot>/node_modules", async () => {
198
+ const res = await defaultEsmScriptRunner.run({
199
+ script: `import { greeting } from "fake-pkg";\nexport default greeting;`,
200
+ context: {},
201
+ timeoutMs: 15_000,
202
+ resolutionRoot: root,
203
+ });
204
+ expect(res.error).toBeUndefined();
205
+ expect(res.result).toBe("hello-from-pkg");
206
+ });
207
+
208
+ it("cannot resolve the package without a resolutionRoot (backward-compatible isolation)", async () => {
209
+ const res = await defaultEsmScriptRunner.run({
210
+ script: `import { greeting } from "fake-pkg";\nexport default greeting;`,
211
+ context: {},
212
+ timeoutMs: 15_000,
213
+ });
214
+ // No resolutionRoot -> runs under os.tmpdir(), no node_modules -> the
215
+ // import fails. Either an error is surfaced or no result is produced.
216
+ expect(res.result).toBeUndefined();
217
+ expect(res.error).toBeDefined();
218
+ });
219
+
220
+ it("does NOT auto-install a missing package from the registry (degradation)", async () => {
221
+ // A real, installable package name that is NOT in any resolutionRoot.
222
+ // With auto-install disabled in the per-run bunfig, Bun must error
223
+ // instead of silently fetching it from the registry.
224
+ const res = await defaultEsmScriptRunner.run({
225
+ script: `import isodd from "is-odd";\nexport default typeof isodd;`,
226
+ context: {},
227
+ timeoutMs: 20_000,
228
+ });
229
+ expect(res.result).toBeUndefined();
230
+ expect(res.error).toBeDefined();
231
+ });
232
+ });
233
+
234
+ describe("defaultEsmScriptRunner injected env", () => {
235
+ it("exposes injected env vars as process.env in the subprocess", async () => {
236
+ const res = await defaultEsmScriptRunner.run({
237
+ script: `export default process.env.API_TOKEN ?? null;`,
238
+ context: {},
239
+ timeoutMs: 15_000,
240
+ env: { API_TOKEN: "injected-secret-value" },
241
+ });
242
+ expect(res.error).toBeUndefined();
243
+ expect(res.result).toBe("injected-secret-value");
244
+ });
245
+
246
+ it("does NOT expose backend env that was not injected (isolation intact)", async () => {
247
+ // A backend secret present in the parent process must NOT leak through
248
+ // unless it was explicitly injected for this run.
249
+ process.env.__CS_TEST_BACKEND_SECRET = "must-not-leak";
250
+ try {
251
+ const res = await defaultEsmScriptRunner.run({
252
+ script: `export default process.env.__CS_TEST_BACKEND_SECRET ?? null;`,
253
+ context: {},
254
+ timeoutMs: 15_000,
255
+ });
256
+ expect(res.result).toBeNull();
257
+ } finally {
258
+ delete process.env.__CS_TEST_BACKEND_SECRET;
259
+ }
260
+ });
261
+ });
@@ -86,6 +86,34 @@ export interface EsmScriptRunOptions {
86
86
  helperModuleName?: string;
87
87
  /** Name of the helper function injected as a global AND exported by the virtual module. */
88
88
  helperFunctionName?: string;
89
+ /**
90
+ * Optional directory the per-run temp dir is created *inside*, so Node /
91
+ * Bun module resolution walks up to `<resolutionRoot>/node_modules` and
92
+ * the user's script can `import` managed npm packages.
93
+ *
94
+ * When unset (the default), the per-run dir is created under
95
+ * `os.tmpdir()` exactly as before - backward-compatible, no node_modules
96
+ * visible, isolation unchanged. The script-packages reconciler points
97
+ * this at `<store>/current` (the atomically-flipped symlink to the
98
+ * active materialized tree).
99
+ *
100
+ * Execution isolation is unchanged either way: the subprocess still gets
101
+ * only `SAFE_ENV_VARS`, so packages cannot read backend secrets.
102
+ */
103
+ resolutionRoot?: string;
104
+ /**
105
+ * Extra environment variables injected into the subprocess for THIS run
106
+ * only, merged on top of `SAFE_ENV_VARS`. The Secrets platform uses this
107
+ * to inject a run's resolved secret -> env allowlist (decision 5,
108
+ * least-privilege): only the consumer's declared secrets are injected,
109
+ * memory-only, for the lifetime of this run. It deliberately does NOT
110
+ * widen the ambient `SAFE_ENV_VARS` whitelist — the values live only in
111
+ * this options object and the spawned process env.
112
+ *
113
+ * The user's script reads these as `process.env.ENV_NAME`. On a key
114
+ * collision with a safe var, the injected value wins.
115
+ */
116
+ env?: Record<string, string>;
89
117
  }
90
118
 
91
119
  /**
@@ -301,14 +329,22 @@ export const defaultEsmScriptRunner: EsmScriptRunner = {
301
329
  timeoutMs,
302
330
  helperModuleName,
303
331
  helperFunctionName,
332
+ resolutionRoot,
333
+ env: injectedEnv,
304
334
  }) {
305
335
  const sessionId = randomUUID();
306
336
  const markerStart = `##__CS_SCRIPT_RESULT_${sessionId}_START__##`;
307
337
  const markerEnd = `##__CS_SCRIPT_RESULT_${sessionId}_END__##`;
308
338
 
309
- const tmpDir = await mkdtemp(path.join(tmpdir(), "checkstack-script-"));
339
+ // When a `resolutionRoot` is given, create the per-run dir *inside* it
340
+ // so module resolution walks up to `<resolutionRoot>/node_modules`.
341
+ // Otherwise fall back to `os.tmpdir()` (today's behavior - no
342
+ // node_modules visible, fully backward compatible).
343
+ const tmpBase = resolutionRoot ?? tmpdir();
344
+ const tmpDir = await mkdtemp(path.join(tmpBase, "checkstack-script-"));
310
345
  const userScriptPath = path.join(tmpDir, "user.mjs");
311
346
  const runnerPath = path.join(tmpDir, "runner.mjs");
347
+ const bunfigPath = path.join(tmpDir, "bunfig.toml");
312
348
 
313
349
  const hasHelper =
314
350
  typeof helperModuleName === "string" &&
@@ -348,6 +384,14 @@ export const defaultEsmScriptRunner: EsmScriptRunner = {
348
384
  })
349
385
  : normalisedSource;
350
386
 
387
+ // Disable Bun auto-install in the per-run dir ALWAYS. Without this,
388
+ // `import "any-package"` silently fetches from the registry (verified
389
+ // empirically), defeating the whole managed-allowlist model. With it,
390
+ // an import resolves ONLY against the reconciled `<resolutionRoot>/
391
+ // node_modules` (when set) and otherwise fails fast - the clear
392
+ // degradation the package feature requires.
393
+ await writeFile(bunfigPath, '[install]\nauto = "disable"\n', "utf8");
394
+
351
395
  await writeFile(userScriptPath, userSource, "utf8");
352
396
  await writeFile(
353
397
  runnerPath,
@@ -363,7 +407,14 @@ export const defaultEsmScriptRunner: EsmScriptRunner = {
363
407
 
364
408
  proc = spawn({
365
409
  cmd: [process.execPath, runnerPath],
366
- env: pickSafeEnv(),
410
+ // CWD = the per-run dir so Bun reads its `bunfig.toml`
411
+ // (auto-install disabled) and resolves modules from
412
+ // `<resolutionRoot>/node_modules` when set.
413
+ cwd: tmpDir,
414
+ // Per-run injected env wins over the safe-vars whitelist. The
415
+ // injected secret values live only here + the child process; they
416
+ // never widen the ambient SAFE_ENV_VARS.
417
+ env: { ...pickSafeEnv(), ...injectedEnv },
367
418
  stdout: "pipe",
368
419
  stderr: "pipe",
369
420
  });
package/src/index.ts CHANGED
@@ -33,3 +33,4 @@ export * from "./incremental-aggregation";
33
33
  export * from "./aggregated-result";
34
34
  export * from "./ws-registry";
35
35
  export * from "./readiness-registry";
36
+ export * from "./advisory-lock";
@@ -85,6 +85,20 @@ export type BackendPluginRegistry = {
85
85
  ) => Promise<void>;
86
86
  }) => void;
87
87
  registerService: <S>(ref: ServiceRef<S>, impl: S) => void;
88
+ /**
89
+ * Resolve a platform service registered by another plugin under `ref`,
90
+ * using THIS plugin's identity as the consumer (for audit / scoped
91
+ * factories). Mirrors the standard dependency-injection resolution used
92
+ * for declared `deps`, but allows resolving ARBITRARY cross-plugin refs
93
+ * at runtime — the path used by the automation dispatch engine to hand
94
+ * `getService` to provider actions at execute time.
95
+ *
96
+ * Resolves the service, or throws a clear error if `ref` is not
97
+ * registered (it never silently returns `undefined`). Safe to call from
98
+ * `init` / `afterPluginsReady` onward, by which point services are
99
+ * registered.
100
+ */
101
+ getService: <S>(ref: ServiceRef<S>) => Promise<S>;
88
102
  registerExtensionPoint: <T>(ref: ExtensionPoint<T>, impl: T) => void;
89
103
  getExtensionPoint: <T>(ref: ExtensionPoint<T>) => T;
90
104
  /**
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { configString, withConfigMeta } from "./zod-config";
4
+ import { toJsonSchema } from "./schema-utils";
5
+
6
+ describe("toJsonSchema x-* metadata", () => {
7
+ test("propagates x-script-testable and x-editor-types onto the field", () => {
8
+ const schema = z.object({
9
+ script: configString({
10
+ "x-editor-types": ["typescript"],
11
+ "x-script-testable": true,
12
+ }),
13
+ });
14
+
15
+ const json = toJsonSchema(schema) as {
16
+ properties: Record<string, Record<string, unknown>>;
17
+ };
18
+
19
+ expect(json.properties.script?.["x-script-testable"]).toBe(true);
20
+ expect(json.properties.script?.["x-editor-types"]).toEqual(["typescript"]);
21
+ });
22
+
23
+ test("omits x-script-testable when not set", () => {
24
+ const schema = z.object({
25
+ plain: configString({}),
26
+ });
27
+ const json = toJsonSchema(schema) as {
28
+ properties: Record<string, Record<string, unknown>>;
29
+ };
30
+ expect("x-script-testable" in (json.properties.plain ?? {})).toBe(false);
31
+ });
32
+
33
+ test("propagates x-secret-env onto a record field via withConfigMeta", () => {
34
+ const schema = z.object({
35
+ secretEnv: withConfigMeta(z.record(z.string(), z.string()), {
36
+ "x-secret-env": true,
37
+ }),
38
+ });
39
+ const json = toJsonSchema(schema) as {
40
+ properties: Record<string, Record<string, unknown>>;
41
+ };
42
+ expect(json.properties.secretEnv?.["x-secret-env"]).toBe(true);
43
+ });
44
+ });
@@ -67,6 +67,12 @@ function addSchemaMetadata(
67
67
  if (meta["x-editor-types"]) {
68
68
  jsonField["x-editor-types"] = meta["x-editor-types"];
69
69
  }
70
+ if (meta["x-script-testable"]) {
71
+ jsonField["x-script-testable"] = true;
72
+ }
73
+ if (meta["x-secret-env"]) {
74
+ jsonField["x-secret-env"] = true;
75
+ }
70
76
  if (meta["x-hidden-when"]) {
71
77
  jsonField["x-hidden-when"] = meta["x-hidden-when"];
72
78
  }
package/src/zod-config.ts CHANGED
@@ -38,6 +38,22 @@ export interface ConfigMeta {
38
38
  * - "formdata": Key/value pair editor (URL-encoded)
39
39
  */
40
40
  "x-editor-types"?: EditorType[];
41
+ /**
42
+ * Mark this field as an inline script that can be tested in-UI. When the
43
+ * editor renders the field (via `MultiTypeEditorField`) and the owning
44
+ * page supplies a `scriptTestRenderer`, a test panel appears beneath the
45
+ * editor so operators can run the script against a sample context.
46
+ */
47
+ "x-script-testable"?: boolean;
48
+ /**
49
+ * Mark a record field as a secret -> env mapping
50
+ * (`{ ENV_NAME: "${{ secrets.NAME }}" }`). The editor renders a
51
+ * dedicated key (env name) + secret-name picker, with the available
52
+ * names supplied to `DynamicForm` via `secretNames` (from the secrets
53
+ * plugin's `listSecretNames`). Without the marker the record falls back
54
+ * to the plain JSON editor.
55
+ */
56
+ "x-secret-env"?: boolean;
41
57
  }
42
58
 
43
59
  /**
@@ -164,3 +180,20 @@ export function configBoolean(meta: ConfigMeta) {
164
180
  schema.register(configRegistry, meta);
165
181
  return schema;
166
182
  }
183
+
184
+ /**
185
+ * Attach config metadata to an existing schema (e.g. a `z.record`) and
186
+ * return it. Use this when a field's base schema is defined elsewhere
187
+ * (such as `secretEnvMappingSchema` from `@checkstack/secrets-common`) but
188
+ * still needs editor metadata like `x-secret-env`.
189
+ */
190
+ export function withConfigMeta<T extends z.ZodTypeAny>(
191
+ schema: T,
192
+ meta: ConfigMeta,
193
+ ): T {
194
+ // The registry is typed `z.registry<ConfigMeta>()`, so registering the
195
+ // meta is sound; the generic `T` confuses zod's conditional `.register`
196
+ // overload, so register through the base schema type.
197
+ (schema as z.ZodTypeAny).register(configRegistry, meta);
198
+ return schema;
199
+ }