@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.
- package/CHANGELOG.md +251 -0
- package/package.json +10 -8
- package/src/advisory-lock-pool.it.test.ts +282 -0
- package/src/advisory-lock.it.test.ts +111 -0
- package/src/advisory-lock.test.ts +273 -0
- package/src/advisory-lock.ts +216 -0
- package/src/collector-strategy.ts +9 -0
- package/src/core-services.ts +7 -0
- package/src/esm-script-runner.test.ts +93 -1
- package/src/esm-script-runner.ts +53 -2
- package/src/index.ts +1 -0
- package/src/plugin-system.ts +14 -0
- package/src/schema-utils.test.ts +44 -0
- package/src/schema-utils.ts +6 -0
- package/src/zod-config.ts +33 -0
|
@@ -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
|
+
});
|
package/src/esm-script-runner.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
package/src/plugin-system.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/schema-utils.ts
CHANGED
|
@@ -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
|
+
}
|