@checkstack/backend-api 0.20.0 → 0.21.1

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/package.json +15 -14
  3. package/src/auth-strategy.ts +6 -3
  4. package/src/bearer-token.ts +13 -0
  5. package/src/collector-strategy.ts +9 -0
  6. package/src/config-versioning.test.ts +227 -0
  7. package/src/config-versioning.ts +177 -11
  8. package/src/core-services.ts +14 -0
  9. package/src/esm-script-runner.test.ts +55 -16
  10. package/src/esm-script-runner.ts +212 -55
  11. package/src/index.ts +3 -0
  12. package/src/render-templatable-config.test.ts +168 -0
  13. package/src/render-templatable-config.ts +193 -0
  14. package/src/schema-utils.ts +3 -0
  15. package/src/script-sandbox/capabilities.test.ts +122 -0
  16. package/src/script-sandbox/capabilities.ts +372 -0
  17. package/src/script-sandbox/capped-output.test.ts +116 -0
  18. package/src/script-sandbox/capped-output.ts +172 -0
  19. package/src/script-sandbox/env-guard.test.ts +105 -0
  20. package/src/script-sandbox/env-guard.ts +129 -0
  21. package/src/script-sandbox/filesystem.test.ts +437 -0
  22. package/src/script-sandbox/filesystem.ts +514 -0
  23. package/src/script-sandbox/forkbomb.it.test.ts +121 -0
  24. package/src/script-sandbox/global-default.test.ts +161 -0
  25. package/src/script-sandbox/global-default.ts +100 -0
  26. package/src/script-sandbox/index.ts +14 -0
  27. package/src/script-sandbox/network.test.ts +356 -0
  28. package/src/script-sandbox/network.ts +373 -0
  29. package/src/script-sandbox/observability.test.ts +210 -0
  30. package/src/script-sandbox/observability.ts +168 -0
  31. package/src/script-sandbox/output-truncation.test.ts +53 -0
  32. package/src/script-sandbox/output-truncation.ts +69 -0
  33. package/src/script-sandbox/policy.test.ts +189 -0
  34. package/src/script-sandbox/policy.ts +220 -0
  35. package/src/script-sandbox/provider.test.ts +61 -0
  36. package/src/script-sandbox/provider.ts +134 -0
  37. package/src/script-sandbox/readiness.test.ts +80 -0
  38. package/src/script-sandbox/readiness.ts +117 -0
  39. package/src/script-sandbox/report.ts +88 -0
  40. package/src/script-sandbox/rootless-egress.it.test.ts +86 -0
  41. package/src/script-sandbox/rootless-egress.test.ts +99 -0
  42. package/src/script-sandbox/rootless-egress.ts +218 -0
  43. package/src/script-sandbox/shell-quote.test.ts +32 -0
  44. package/src/script-sandbox/shell-quote.ts +10 -0
  45. package/src/script-sandbox/wrapper.test.ts +1194 -0
  46. package/src/script-sandbox/wrapper.ts +714 -0
  47. package/src/shell-script-runner.test.ts +243 -0
  48. package/src/shell-script-runner.ts +210 -45
  49. package/src/types.ts +5 -38
  50. package/src/zod-config.test.ts +60 -0
  51. package/src/zod-config.ts +38 -14
  52. package/tsconfig.json +3 -0
@@ -1,4 +1,6 @@
1
1
  import { z } from "zod";
2
+ import { extractErrorMessage } from "@checkstack/common";
3
+ import type { Migration } from "@checkstack/common";
2
4
 
3
5
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4
6
  // Storage Interfaces (simple data shapes for DB/API)
@@ -33,18 +35,78 @@ export interface VersionedPluginRecord<T = unknown> extends VersionedRecord<T> {
33
35
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
34
36
 
35
37
  /**
36
- * Type-safe migration from one data version to another.
37
- * Used for backward-compatible schema evolution.
38
+ * The canonical `Migration` definition now lives in `@checkstack/common` so
39
+ * that low-level packages (`@checkstack/cache-api`, `@checkstack/queue-api`)
40
+ * can reference it without depending on `backend-api` and creating a
41
+ * publish-time dependency cycle. Re-exported here for backward compatibility.
38
42
  */
39
- export interface Migration<TFrom = unknown, TTo = unknown> {
40
- /** Version number migrating from */
41
- fromVersion: number;
42
- /** Version number migrating to (must be fromVersion + 1) */
43
- toVersion: number;
44
- /** Human-readable description of what this migration does */
45
- description: string;
46
- /** Migration function that transforms old data to new format */
47
- migrate(data: TFrom): TTo | Promise<TTo>;
43
+ export type { Migration } from "@checkstack/common";
44
+
45
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
46
+ // Migration Chain Validation
47
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
48
+
49
+ /**
50
+ * Options for {@link assertMigrationChainFromV1}.
51
+ */
52
+ export interface AssertMigrationChainOptions {
53
+ /** Target/current schema version the chain must reach. */
54
+ version: number;
55
+ /** The ordered (or unordered) set of migrations covering the chain. */
56
+ migrations: Migration<unknown, unknown>[];
57
+ }
58
+
59
+ /**
60
+ * Standalone, pure structural guard that a migration chain is COMPLETE and
61
+ * contiguous from version 1 up to the given `version`: every applicable step
62
+ * increments by exactly 1, there are no gaps, and the chain reaches
63
+ * `version`. No `migrate()` is ever invoked — this only inspects the
64
+ * `fromVersion` / `toVersion` discriminators.
65
+ *
66
+ * A `version: 1` config with no migrations passes trivially. Any
67
+ * `version > 1` requires a covering chain; an incomplete chain is a latent
68
+ * read failure (the read path would only discover it when it first tries to
69
+ * migrate a genuinely-old stored blob), so callers use this to fail fast at
70
+ * boot / registration instead.
71
+ *
72
+ * This is the single shared implementation behind both {@link Versioned}'s
73
+ * construction guard and its non-throwing {@link Versioned.validateMigrationChainFromV1}
74
+ * variant, and is reused by the auth strategy registration guard.
75
+ *
76
+ * @throws Error with a descriptive message on the first problem found.
77
+ */
78
+ export function assertMigrationChainFromV1({
79
+ version,
80
+ migrations,
81
+ }: AssertMigrationChainOptions): void {
82
+ const sorted = migrations.toSorted((a, b) => a.fromVersion - b.fromVersion);
83
+
84
+ let expectedVersion = 1;
85
+ for (const migration of sorted) {
86
+ if (migration.fromVersion < 1) continue;
87
+ if (migration.toVersion > version) break;
88
+
89
+ if (migration.fromVersion !== expectedVersion) {
90
+ throw new Error(
91
+ `Migration chain broken: expected migration from version ${expectedVersion}, ` +
92
+ `but found migration from version ${migration.fromVersion}`
93
+ );
94
+ }
95
+ if (migration.toVersion !== migration.fromVersion + 1) {
96
+ throw new Error(
97
+ `Migration must increment version by 1: migration from ${migration.fromVersion} ` +
98
+ `to ${migration.toVersion} is invalid`
99
+ );
100
+ }
101
+ expectedVersion = migration.toVersion;
102
+ }
103
+
104
+ if (expectedVersion !== version) {
105
+ throw new Error(
106
+ `Migration chain incomplete: reaches version ${expectedVersion}, ` +
107
+ `but target version is ${version}`
108
+ );
109
+ }
48
110
  }
49
111
 
50
112
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -129,6 +191,18 @@ export class Versioned<T> {
129
191
  this.version = options.version;
130
192
  this.schema = options.schema;
131
193
  this._migrations = options.migrations ?? [];
194
+
195
+ // Fail fast at construction (i.e. at module import / plugin
196
+ // registration) if this Versioned's v1->version migration chain is
197
+ // incomplete or broken. A `version: 1` config with no migrations
198
+ // passes trivially. Any `version > 1` without a covering chain would
199
+ // otherwise fail lazily on the first stale `parseAssumingV1` read — we
200
+ // surface it at boot instead. This single guard covers every Versioned
201
+ // instance repo-wide.
202
+ assertMigrationChainFromV1({
203
+ version: this.version,
204
+ migrations: this._migrations,
205
+ });
132
206
  }
133
207
 
134
208
  /**
@@ -177,6 +251,54 @@ export class Versioned<T> {
177
251
  return { ...migrated, data: validated };
178
252
  }
179
253
 
254
+ /**
255
+ * Parse raw data that was stored UNVERSIONED, assuming it was written
256
+ * at version 1.
257
+ *
258
+ * Many config blobs predate explicit versioning and live nested in a
259
+ * larger JSON document (e.g. an automation `definition`) without a
260
+ * `version` discriminator. Reading them is "assume v1 on read": wrap the
261
+ * raw value as `{ version: 1, data }`, run the migration chain up to the
262
+ * current version, then validate.
263
+ *
264
+ * For a v1-with-no-migrations config this is just a validate; for a
265
+ * config whose `version > 1` it runs the full migration chain first, so
266
+ * removed/renamed fields are migrated away before validation.
267
+ *
268
+ * Validation is LENIENT (unknown keys are stripped, not rejected) — use
269
+ * this on the runtime/read path so a stored config that picked up an
270
+ * extra key survives schema evolution.
271
+ *
272
+ * @throws ZodError on validation failure
273
+ * @throws Error on migration failure
274
+ */
275
+ async parseAssumingV1(rawData: unknown): Promise<T> {
276
+ return this.parse({ version: 1, data: rawData });
277
+ }
278
+
279
+ /**
280
+ * Like {@link parseAssumingV1}, but the FINAL validation is STRICT:
281
+ * unknown keys on a plain-object schema are rejected rather than
282
+ * stripped.
283
+ *
284
+ * Migrate-then-STRICT is the editor/validation path: removed or renamed
285
+ * fields are migrated away by the chain (so stored configs survive
286
+ * schema evolution), while genuine typos — keys no migration accounts
287
+ * for — still surface to the operator.
288
+ *
289
+ * Strict handling mirrors the object-vs-non-object split used by the
290
+ * automation definition validator: only `z.ZodObject` schemas can be
291
+ * tightened with `.strict()`; unions, records, and primitives fall back
292
+ * to a normal parse.
293
+ *
294
+ * @throws ZodError on validation failure
295
+ * @throws Error on migration failure
296
+ */
297
+ async parseStrictAssumingV1(rawData: unknown): Promise<T> {
298
+ const migrated = await this.migrateToVersion({ version: 1, data: rawData });
299
+ return this.strictValidate(migrated.data);
300
+ }
301
+
180
302
  // ─────────────────────────────────────────────────────────────────────────
181
303
  // Data Creation (wrap new data)
182
304
  // ─────────────────────────────────────────────────────────────────────────
@@ -214,6 +336,34 @@ export class Versioned<T> {
214
336
  return input.version !== this.version;
215
337
  }
216
338
 
339
+ /**
340
+ * Structural check (no `migrate()` invocation) that this config has a
341
+ * COMPLETE, contiguous migration chain from version 1 to its current
342
+ * `version`: every step increments by exactly 1 and the chain reaches
343
+ * `version` with no gaps or detours.
344
+ *
345
+ * For a `version: 1` config this is trivially satisfied (no migrations
346
+ * needed). For any `version > 1` it requires a covering chain — which is
347
+ * exactly what {@link parseAssumingV1} relies on, so a missing chain is a
348
+ * latent read failure. Returns the first problem found, or `undefined`
349
+ * when the chain is complete.
350
+ *
351
+ * Intended for a CI contract test that enumerates every registered config
352
+ * and fails the build if a future `version` bump ships without a covering
353
+ * migration — zero per-config upkeep.
354
+ */
355
+ validateMigrationChainFromV1(): string | undefined {
356
+ try {
357
+ assertMigrationChainFromV1({
358
+ version: this.version,
359
+ migrations: this._migrations,
360
+ });
361
+ return undefined;
362
+ } catch (error) {
363
+ return extractErrorMessage(error);
364
+ }
365
+ }
366
+
217
367
  /**
218
368
  * Validate data without migration (schema.parse wrapper).
219
369
  * For data already at current version.
@@ -229,6 +379,22 @@ export class Versioned<T> {
229
379
  return this.schema.safeParse(data);
230
380
  }
231
381
 
382
+ /**
383
+ * Validate in STRICT mode: when the schema is a plain object, unknown
384
+ * keys are rejected; otherwise (unions/records/primitives) fall back to
385
+ * a normal parse. Used by {@link parseStrictAssumingV1}.
386
+ */
387
+ private strictValidate(data: unknown): T {
388
+ if (this.schema instanceof z.ZodObject) {
389
+ // `.strict()` only narrows accepted keys; the parsed output shape is
390
+ // identical to the base object schema's, but its inferred type loses
391
+ // the `T` brand. The cast restores it without changing runtime
392
+ // behaviour.
393
+ return this.schema.strict().parse(data) as T;
394
+ }
395
+ return this.schema.parse(data);
396
+ }
397
+
232
398
  // ─────────────────────────────────────────────────────────────────────────
233
399
  // Internal migration logic
234
400
  // ─────────────────────────────────────────────────────────────────────────
@@ -52,6 +52,20 @@ export const coreServices = {
52
52
  ),
53
53
  rpc: createServiceRef<RpcService>("core.rpc"),
54
54
  rpcClient: createServiceRef<RpcClient>("core.rpcClient"),
55
+ /**
56
+ * Factory for an RPC client that authenticates as a specific APPLICATION
57
+ * (service account), not as the trusted service. The returned client mints a
58
+ * short-lived, backend-signed app-principal token; the live router resolves
59
+ * it to an `application` principal subject to the FULL access-rule and
60
+ * team-scope enforcement (never the service short-circuit). Used by the
61
+ * automation dispatch engine to run an automation as its configured `runAs`.
62
+ *
63
+ * Trusted infra: callable only by backend plugin code. The authority to bind
64
+ * a given application is gated separately when the automation is saved.
65
+ */
66
+ rpcClientAs: createServiceRef<(applicationId: string) => Promise<RpcClient>>(
67
+ "core.rpcClientAs",
68
+ ),
55
69
  queuePluginRegistry: createServiceRef<QueuePluginRegistry>(
56
70
  "core.queuePluginRegistry",
57
71
  ),
@@ -1,4 +1,12 @@
1
- import { afterAll, beforeAll, describe, expect, it } from "bun:test";
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeAll,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ it,
9
+ } from "bun:test";
2
10
  import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
3
11
  import { tmpdir } from "node:os";
4
12
  import path from "node:path";
@@ -7,6 +15,35 @@ import {
7
15
  normaliseUserScript,
8
16
  rewriteHelperImports,
9
17
  } from "./esm-script-runner";
18
+ import {
19
+ registerSandboxPolicyProvider,
20
+ resetSandboxPolicyProvider,
21
+ } from "./script-sandbox/provider";
22
+ import { resolveDefaultSandboxProfile } from "./script-sandbox/policy";
23
+
24
+ /**
25
+ * The spawning suites register the shipped default profile as the active
26
+ * global policy so the runner's behavior is deterministic (rather than failing
27
+ * closed). The shipped default is now fail-closed (`onUnavailable: "fail"`),
28
+ * which on a capability-poor CI/dev host (non-root macOS, no bwrap/prlimit)
29
+ * would refuse EVERY spawn — defeating these runner-behavior tests (env
30
+ * injection, script execution). So here we register the default profile with
31
+ * the opt-in `degrade` mode to exercise the spawn path on any host. The
32
+ * fail-closed default value is asserted in `policy.test.ts`, and the
33
+ * fail-closed RUNTIME path (refuse + exitCode -1, no spawn) in
34
+ * `provider.test.ts` and `shell-script-runner.test.ts`.
35
+ */
36
+ function useDefaultSandboxPolicy(): void {
37
+ beforeEach(() => {
38
+ registerSandboxPolicyProvider(async () => ({
39
+ ...resolveDefaultSandboxProfile(),
40
+ onUnavailable: "degrade",
41
+ }));
42
+ });
43
+ afterEach(() => {
44
+ resetSandboxPolicyProvider();
45
+ });
46
+ }
10
47
 
11
48
  /**
12
49
  * The shared ESM-script runner applies two text transforms to user
@@ -84,8 +121,8 @@ describe("rewriteHelperImports", () => {
84
121
 
85
122
  it("rewrites a named import from the helper module", () => {
86
123
  const out = rewriteHelperImports({
87
- userScript: `import { defineHealthCheck } from "@checkstack/healthcheck";`,
88
- helperModuleName: "@checkstack/healthcheck",
124
+ userScript: `import { defineHealthCheck } from "@checkstack/sdk/healthcheck";`,
125
+ helperModuleName: "@checkstack/sdk/healthcheck",
89
126
  helperUrl: HELPER_URL,
90
127
  });
91
128
  expect(out).toBe(`import { defineHealthCheck } from "${HELPER_URL}";`);
@@ -93,8 +130,8 @@ describe("rewriteHelperImports", () => {
93
130
 
94
131
  it("works with single-quoted import specs too", () => {
95
132
  const out = rewriteHelperImports({
96
- userScript: `import { defineHealthCheck } from '@checkstack/healthcheck';`,
97
- helperModuleName: "@checkstack/healthcheck",
133
+ userScript: `import { defineHealthCheck } from '@checkstack/sdk/healthcheck';`,
134
+ helperModuleName: "@checkstack/sdk/healthcheck",
98
135
  helperUrl: HELPER_URL,
99
136
  });
100
137
  expect(out).toBe(`import { defineHealthCheck } from "${HELPER_URL}";`);
@@ -102,8 +139,8 @@ describe("rewriteHelperImports", () => {
102
139
 
103
140
  it("rewrites a side-effect import too", () => {
104
141
  const out = rewriteHelperImports({
105
- userScript: `import "@checkstack/healthcheck";`,
106
- helperModuleName: "@checkstack/healthcheck",
142
+ userScript: `import "@checkstack/sdk/healthcheck";`,
143
+ helperModuleName: "@checkstack/sdk/healthcheck",
107
144
  helperUrl: HELPER_URL,
108
145
  });
109
146
  expect(out).toBe(`import "${HELPER_URL}";`);
@@ -114,7 +151,7 @@ describe("rewriteHelperImports", () => {
114
151
  expect(
115
152
  rewriteHelperImports({
116
153
  userScript: src,
117
- helperModuleName: "@checkstack/healthcheck",
154
+ helperModuleName: "@checkstack/sdk/healthcheck",
118
155
  helperUrl: HELPER_URL,
119
156
  }),
120
157
  ).toBe(src);
@@ -122,15 +159,15 @@ describe("rewriteHelperImports", () => {
122
159
 
123
160
  it("rewrites multiple occurrences", () => {
124
161
  const src = `
125
- import { defineHealthCheck } from "@checkstack/healthcheck";
126
- import type { HealthCheckScriptResult } from "@checkstack/healthcheck";
162
+ import { defineHealthCheck } from "@checkstack/sdk/healthcheck";
163
+ import type { HealthCheckScriptResult } from "@checkstack/sdk/healthcheck";
127
164
  `;
128
165
  const out = rewriteHelperImports({
129
166
  userScript: src,
130
- helperModuleName: "@checkstack/healthcheck",
167
+ helperModuleName: "@checkstack/sdk/healthcheck",
131
168
  helperUrl: HELPER_URL,
132
169
  });
133
- expect(out.match(/@checkstack\/healthcheck/g)).toBeNull();
170
+ expect(out.match(/@checkstack\/sdk\/healthcheck/g)).toBeNull();
134
171
  expect([...out.matchAll(new RegExp(HELPER_URL, "g"))].length).toBe(2);
135
172
  });
136
173
 
@@ -138,11 +175,11 @@ describe("rewriteHelperImports", () => {
138
175
  // Conservative regex: it only matches the spec position of an import
139
176
  // statement (`from "..."` / `import "..."`). A string containing the
140
177
  // package name elsewhere must be left alone.
141
- const src = `console.log("Look up @checkstack/healthcheck on npm");`;
178
+ const src = `console.log("Look up @checkstack/sdk/healthcheck on npm");`;
142
179
  expect(
143
180
  rewriteHelperImports({
144
181
  userScript: src,
145
- helperModuleName: "@checkstack/healthcheck",
182
+ helperModuleName: "@checkstack/sdk/healthcheck",
146
183
  helperUrl: HELPER_URL,
147
184
  }),
148
185
  ).toBe(src);
@@ -153,8 +190,8 @@ describe("rewriteHelperImports", () => {
153
190
  // plugins; the regex is built from the supplied `helperModuleName`.
154
191
  // Sanity check that the integration variant works.
155
192
  const out = rewriteHelperImports({
156
- userScript: `import { defineIntegration } from "@checkstack/integration";`,
157
- helperModuleName: "@checkstack/integration",
193
+ userScript: `import { defineIntegration } from "@checkstack/sdk/integration";`,
194
+ helperModuleName: "@checkstack/sdk/integration",
158
195
  helperUrl: HELPER_URL,
159
196
  });
160
197
  expect(out).toBe(`import { defineIntegration } from "${HELPER_URL}";`);
@@ -173,6 +210,7 @@ describe("rewriteHelperImports", () => {
173
210
  });
174
211
 
175
212
  describe("defaultEsmScriptRunner resolutionRoot", () => {
213
+ useDefaultSandboxPolicy();
176
214
  let root: string;
177
215
 
178
216
  beforeAll(async () => {
@@ -232,6 +270,7 @@ describe("defaultEsmScriptRunner resolutionRoot", () => {
232
270
  });
233
271
 
234
272
  describe("defaultEsmScriptRunner injected env", () => {
273
+ useDefaultSandboxPolicy();
235
274
  it("exposes injected env vars as process.env in the subprocess", async () => {
236
275
  const res = await defaultEsmScriptRunner.run({
237
276
  script: `export default process.env.API_TOKEN ?? null;`,