@checkstack/backend-api 0.19.0 → 0.21.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 +205 -0
- package/package.json +12 -11
- package/src/advisory-lock-pool.it.test.ts +282 -0
- package/src/advisory-lock.test.ts +144 -3
- package/src/advisory-lock.ts +97 -55
- package/src/auth-strategy.ts +6 -3
- package/src/bearer-token.ts +13 -0
- package/src/collector-strategy.ts +9 -0
- package/src/config-versioning.test.ts +227 -0
- package/src/config-versioning.ts +172 -0
- package/src/core-services.ts +14 -0
- package/src/esm-script-runner.test.ts +55 -16
- package/src/esm-script-runner.ts +212 -55
- package/src/index.ts +3 -0
- package/src/render-templatable-config.test.ts +168 -0
- package/src/render-templatable-config.ts +193 -0
- package/src/schema-utils.ts +3 -0
- package/src/script-sandbox/capabilities.test.ts +122 -0
- package/src/script-sandbox/capabilities.ts +372 -0
- package/src/script-sandbox/capped-output.test.ts +116 -0
- package/src/script-sandbox/capped-output.ts +172 -0
- package/src/script-sandbox/env-guard.test.ts +105 -0
- package/src/script-sandbox/env-guard.ts +129 -0
- package/src/script-sandbox/filesystem.test.ts +437 -0
- package/src/script-sandbox/filesystem.ts +514 -0
- package/src/script-sandbox/forkbomb.it.test.ts +121 -0
- package/src/script-sandbox/global-default.test.ts +161 -0
- package/src/script-sandbox/global-default.ts +100 -0
- package/src/script-sandbox/index.ts +14 -0
- package/src/script-sandbox/network.test.ts +356 -0
- package/src/script-sandbox/network.ts +373 -0
- package/src/script-sandbox/observability.test.ts +210 -0
- package/src/script-sandbox/observability.ts +168 -0
- package/src/script-sandbox/output-truncation.test.ts +53 -0
- package/src/script-sandbox/output-truncation.ts +69 -0
- package/src/script-sandbox/policy.test.ts +189 -0
- package/src/script-sandbox/policy.ts +220 -0
- package/src/script-sandbox/provider.test.ts +61 -0
- package/src/script-sandbox/provider.ts +134 -0
- package/src/script-sandbox/readiness.test.ts +80 -0
- package/src/script-sandbox/readiness.ts +117 -0
- package/src/script-sandbox/report.ts +88 -0
- package/src/script-sandbox/rootless-egress.it.test.ts +86 -0
- package/src/script-sandbox/rootless-egress.test.ts +99 -0
- package/src/script-sandbox/rootless-egress.ts +218 -0
- package/src/script-sandbox/shell-quote.test.ts +32 -0
- package/src/script-sandbox/shell-quote.ts +10 -0
- package/src/script-sandbox/wrapper.test.ts +1194 -0
- package/src/script-sandbox/wrapper.ts +714 -0
- package/src/shell-script-runner.test.ts +243 -0
- package/src/shell-script-runner.ts +210 -45
- package/src/zod-config.test.ts +60 -0
- package/src/zod-config.ts +38 -14
- package/tsconfig.json +3 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `Versioned<T>` config helper, focused on the
|
|
3
|
+
* assume-v1-on-read parse paths used by configs that are stored
|
|
4
|
+
* UNVERSIONED (raw, nested in a larger JSON blob).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from "bun:test";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import {
|
|
9
|
+
Versioned,
|
|
10
|
+
assertMigrationChainFromV1,
|
|
11
|
+
type Migration,
|
|
12
|
+
} from "./config-versioning";
|
|
13
|
+
|
|
14
|
+
// A v1 config: a single object schema, no migrations.
|
|
15
|
+
const v1Schema = z.object({
|
|
16
|
+
url: z.string(),
|
|
17
|
+
method: z.enum(["GET", "POST"]).default("GET"),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const v1Config = new Versioned({ version: 1, schema: v1Schema });
|
|
21
|
+
|
|
22
|
+
// A v1 -> v2 config that DROPS a removed `sandbox` key, mirroring the
|
|
23
|
+
// real run_script/run_shell migration.
|
|
24
|
+
const v2Schema = z.object({
|
|
25
|
+
script: z.string(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const dropSandboxMigration: Migration<Record<string, unknown>, unknown> = {
|
|
29
|
+
fromVersion: 1,
|
|
30
|
+
toVersion: 2,
|
|
31
|
+
description: "Drop removed `sandbox` key",
|
|
32
|
+
migrate: ({ sandbox: _sandbox, ...rest }) => rest,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const v2Config = new Versioned({
|
|
36
|
+
version: 2,
|
|
37
|
+
schema: v2Schema,
|
|
38
|
+
migrations: [dropSandboxMigration],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("parseAssumingV1", () => {
|
|
42
|
+
it("validates a v1-no-migration config (just validate)", async () => {
|
|
43
|
+
const result = await v1Config.parseAssumingV1({ url: "https://x.test" });
|
|
44
|
+
expect(result).toEqual({ url: "https://x.test", method: "GET" });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("runs the migration chain for a v>1 config and drops a removed key", async () => {
|
|
48
|
+
const result = await v2Config.parseAssumingV1({
|
|
49
|
+
script: "echo hi",
|
|
50
|
+
sandbox: "off",
|
|
51
|
+
});
|
|
52
|
+
expect(result).toEqual({ script: "echo hi" });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("is idempotent: a fresh config without the removed key is unchanged", async () => {
|
|
56
|
+
const result = await v2Config.parseAssumingV1({ script: "echo hi" });
|
|
57
|
+
expect(result).toEqual({ script: "echo hi" });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("strips unknown keys leniently (does NOT reject typos)", async () => {
|
|
61
|
+
const result = await v1Config.parseAssumingV1({
|
|
62
|
+
url: "https://x.test",
|
|
63
|
+
methodd: "GET",
|
|
64
|
+
});
|
|
65
|
+
expect(result).toEqual({ url: "https://x.test", method: "GET" });
|
|
66
|
+
expect(result).not.toHaveProperty("methodd");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("rejects a genuine validation error (missing required field)", async () => {
|
|
70
|
+
await expect(
|
|
71
|
+
v1Config.parseAssumingV1({ method: "GET" }),
|
|
72
|
+
).rejects.toThrow();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("parseStrictAssumingV1", () => {
|
|
77
|
+
it("validates a clean v1-no-migration config", async () => {
|
|
78
|
+
const result = await v1Config.parseStrictAssumingV1({
|
|
79
|
+
url: "https://x.test",
|
|
80
|
+
});
|
|
81
|
+
expect(result).toEqual({ url: "https://x.test", method: "GET" });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("migrates a removed key away, then strict-validates cleanly", async () => {
|
|
85
|
+
const result = await v2Config.parseStrictAssumingV1({
|
|
86
|
+
script: "echo hi",
|
|
87
|
+
sandbox: "off",
|
|
88
|
+
});
|
|
89
|
+
expect(result).toEqual({ script: "echo hi" });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("is idempotent for a fresh config without the removed key", async () => {
|
|
93
|
+
const result = await v2Config.parseStrictAssumingV1({ script: "echo hi" });
|
|
94
|
+
expect(result).toEqual({ script: "echo hi" });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("rejects a genuine unknown-key typo the migration does NOT account for", async () => {
|
|
98
|
+
await expect(
|
|
99
|
+
v1Config.parseStrictAssumingV1({
|
|
100
|
+
url: "https://x.test",
|
|
101
|
+
methodd: "GET",
|
|
102
|
+
}),
|
|
103
|
+
).rejects.toThrow();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("rejects an unknown key on the migrated (v2) shape", async () => {
|
|
107
|
+
await expect(
|
|
108
|
+
v2Config.parseStrictAssumingV1({ script: "echo hi", typo: 1 }),
|
|
109
|
+
).rejects.toThrow();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("falls back to a normal parse for non-object schemas", async () => {
|
|
113
|
+
const unionConfig = new Versioned({
|
|
114
|
+
version: 1,
|
|
115
|
+
schema: z.union([z.object({ a: z.string() }), z.object({ b: z.number() })]),
|
|
116
|
+
});
|
|
117
|
+
const result = await unionConfig.parseStrictAssumingV1({ a: "x" });
|
|
118
|
+
expect(result).toEqual({ a: "x" });
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("construction-time migration-chain guard", () => {
|
|
123
|
+
it("throws for a version>1 config with no migrations", () => {
|
|
124
|
+
expect(
|
|
125
|
+
() => new Versioned({ version: 2, schema: v2Schema, migrations: [] }),
|
|
126
|
+
).toThrow();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("constructs fine for a version 2 config with a complete v1->v2 chain", () => {
|
|
130
|
+
expect(
|
|
131
|
+
() =>
|
|
132
|
+
new Versioned({
|
|
133
|
+
version: 2,
|
|
134
|
+
schema: v2Schema,
|
|
135
|
+
migrations: [dropSandboxMigration],
|
|
136
|
+
}),
|
|
137
|
+
).not.toThrow();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("constructs fine for a version 1 config with no migrations", () => {
|
|
141
|
+
expect(() => new Versioned({ version: 1, schema: v1Schema })).not.toThrow();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("throws for a broken chain (gap / non-+1 step)", () => {
|
|
145
|
+
expect(
|
|
146
|
+
() =>
|
|
147
|
+
new Versioned({
|
|
148
|
+
version: 4,
|
|
149
|
+
schema: v2Schema,
|
|
150
|
+
migrations: [
|
|
151
|
+
{ fromVersion: 1, toVersion: 2, description: "a", migrate: (d) => d },
|
|
152
|
+
{ fromVersion: 3, toVersion: 4, description: "c", migrate: (d) => d },
|
|
153
|
+
],
|
|
154
|
+
}),
|
|
155
|
+
).toThrow();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("validateMigrationChainFromV1", () => {
|
|
160
|
+
it("passes for a v1 config with no migrations", () => {
|
|
161
|
+
expect(v1Config.validateMigrationChainFromV1()).toBeUndefined();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("passes for a v2 config with a complete 1->2 chain", () => {
|
|
165
|
+
expect(v2Config.validateMigrationChainFromV1()).toBeUndefined();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// NOTE: the "missing covering migration" and "gapped chain" cases now
|
|
169
|
+
// throw at CONSTRUCTION (see the construction-time guard above), so they
|
|
170
|
+
// can no longer be exercised via a constructed instance. The pure
|
|
171
|
+
// structural method itself is covered transitively by the guard tests
|
|
172
|
+
// and by the passing cases below.
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("assertMigrationChainFromV1", () => {
|
|
176
|
+
const step = (fromVersion: number, toVersion: number): Migration => ({
|
|
177
|
+
fromVersion,
|
|
178
|
+
toVersion,
|
|
179
|
+
description: `${fromVersion}->${toVersion}`,
|
|
180
|
+
migrate: (d) => d,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("passes for a v1 config with no migrations", () => {
|
|
184
|
+
expect(() =>
|
|
185
|
+
assertMigrationChainFromV1({ version: 1, migrations: [] }),
|
|
186
|
+
).not.toThrow();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("passes for a complete contiguous chain (1->2->3)", () => {
|
|
190
|
+
expect(() =>
|
|
191
|
+
assertMigrationChainFromV1({
|
|
192
|
+
version: 3,
|
|
193
|
+
migrations: [step(2, 3), step(1, 2)],
|
|
194
|
+
}),
|
|
195
|
+
).not.toThrow();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("throws for a version>1 config with no migrations", () => {
|
|
199
|
+
expect(() =>
|
|
200
|
+
assertMigrationChainFromV1({ version: 2, migrations: [] }),
|
|
201
|
+
).toThrow(/incomplete/);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("throws for a chain that does not reach the target version", () => {
|
|
205
|
+
expect(() =>
|
|
206
|
+
assertMigrationChainFromV1({ version: 3, migrations: [step(1, 2)] }),
|
|
207
|
+
).toThrow(/incomplete: reaches version 2/);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("throws for a gap in the chain", () => {
|
|
211
|
+
expect(() =>
|
|
212
|
+
assertMigrationChainFromV1({
|
|
213
|
+
version: 4,
|
|
214
|
+
migrations: [step(1, 2), step(3, 4)],
|
|
215
|
+
}),
|
|
216
|
+
).toThrow(/chain broken: expected migration from version 2/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("throws for a non-+1 step", () => {
|
|
220
|
+
expect(() =>
|
|
221
|
+
assertMigrationChainFromV1({
|
|
222
|
+
version: 3,
|
|
223
|
+
migrations: [step(1, 3)],
|
|
224
|
+
}),
|
|
225
|
+
).toThrow(/increment version by 1/);
|
|
226
|
+
});
|
|
227
|
+
});
|
package/src/config-versioning.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
2
3
|
|
|
3
4
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
4
5
|
// Storage Interfaces (simple data shapes for DB/API)
|
|
@@ -47,6 +48,73 @@ export interface Migration<TFrom = unknown, TTo = unknown> {
|
|
|
47
48
|
migrate(data: TFrom): TTo | Promise<TTo>;
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
52
|
+
// Migration Chain Validation
|
|
53
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Options for {@link assertMigrationChainFromV1}.
|
|
57
|
+
*/
|
|
58
|
+
export interface AssertMigrationChainOptions {
|
|
59
|
+
/** Target/current schema version the chain must reach. */
|
|
60
|
+
version: number;
|
|
61
|
+
/** The ordered (or unordered) set of migrations covering the chain. */
|
|
62
|
+
migrations: Migration<unknown, unknown>[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Standalone, pure structural guard that a migration chain is COMPLETE and
|
|
67
|
+
* contiguous from version 1 up to the given `version`: every applicable step
|
|
68
|
+
* increments by exactly 1, there are no gaps, and the chain reaches
|
|
69
|
+
* `version`. No `migrate()` is ever invoked — this only inspects the
|
|
70
|
+
* `fromVersion` / `toVersion` discriminators.
|
|
71
|
+
*
|
|
72
|
+
* A `version: 1` config with no migrations passes trivially. Any
|
|
73
|
+
* `version > 1` requires a covering chain; an incomplete chain is a latent
|
|
74
|
+
* read failure (the read path would only discover it when it first tries to
|
|
75
|
+
* migrate a genuinely-old stored blob), so callers use this to fail fast at
|
|
76
|
+
* boot / registration instead.
|
|
77
|
+
*
|
|
78
|
+
* This is the single shared implementation behind both {@link Versioned}'s
|
|
79
|
+
* construction guard and its non-throwing {@link Versioned.validateMigrationChainFromV1}
|
|
80
|
+
* variant, and is reused by the auth strategy registration guard.
|
|
81
|
+
*
|
|
82
|
+
* @throws Error with a descriptive message on the first problem found.
|
|
83
|
+
*/
|
|
84
|
+
export function assertMigrationChainFromV1({
|
|
85
|
+
version,
|
|
86
|
+
migrations,
|
|
87
|
+
}: AssertMigrationChainOptions): void {
|
|
88
|
+
const sorted = migrations.toSorted((a, b) => a.fromVersion - b.fromVersion);
|
|
89
|
+
|
|
90
|
+
let expectedVersion = 1;
|
|
91
|
+
for (const migration of sorted) {
|
|
92
|
+
if (migration.fromVersion < 1) continue;
|
|
93
|
+
if (migration.toVersion > version) break;
|
|
94
|
+
|
|
95
|
+
if (migration.fromVersion !== expectedVersion) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Migration chain broken: expected migration from version ${expectedVersion}, ` +
|
|
98
|
+
`but found migration from version ${migration.fromVersion}`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (migration.toVersion !== migration.fromVersion + 1) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Migration must increment version by 1: migration from ${migration.fromVersion} ` +
|
|
104
|
+
`to ${migration.toVersion} is invalid`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
expectedVersion = migration.toVersion;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (expectedVersion !== version) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Migration chain incomplete: reaches version ${expectedVersion}, ` +
|
|
113
|
+
`but target version is ${version}`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
50
118
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
51
119
|
// Migration Builder
|
|
52
120
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
@@ -129,6 +197,18 @@ export class Versioned<T> {
|
|
|
129
197
|
this.version = options.version;
|
|
130
198
|
this.schema = options.schema;
|
|
131
199
|
this._migrations = options.migrations ?? [];
|
|
200
|
+
|
|
201
|
+
// Fail fast at construction (i.e. at module import / plugin
|
|
202
|
+
// registration) if this Versioned's v1->version migration chain is
|
|
203
|
+
// incomplete or broken. A `version: 1` config with no migrations
|
|
204
|
+
// passes trivially. Any `version > 1` without a covering chain would
|
|
205
|
+
// otherwise fail lazily on the first stale `parseAssumingV1` read — we
|
|
206
|
+
// surface it at boot instead. This single guard covers every Versioned
|
|
207
|
+
// instance repo-wide.
|
|
208
|
+
assertMigrationChainFromV1({
|
|
209
|
+
version: this.version,
|
|
210
|
+
migrations: this._migrations,
|
|
211
|
+
});
|
|
132
212
|
}
|
|
133
213
|
|
|
134
214
|
/**
|
|
@@ -177,6 +257,54 @@ export class Versioned<T> {
|
|
|
177
257
|
return { ...migrated, data: validated };
|
|
178
258
|
}
|
|
179
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Parse raw data that was stored UNVERSIONED, assuming it was written
|
|
262
|
+
* at version 1.
|
|
263
|
+
*
|
|
264
|
+
* Many config blobs predate explicit versioning and live nested in a
|
|
265
|
+
* larger JSON document (e.g. an automation `definition`) without a
|
|
266
|
+
* `version` discriminator. Reading them is "assume v1 on read": wrap the
|
|
267
|
+
* raw value as `{ version: 1, data }`, run the migration chain up to the
|
|
268
|
+
* current version, then validate.
|
|
269
|
+
*
|
|
270
|
+
* For a v1-with-no-migrations config this is just a validate; for a
|
|
271
|
+
* config whose `version > 1` it runs the full migration chain first, so
|
|
272
|
+
* removed/renamed fields are migrated away before validation.
|
|
273
|
+
*
|
|
274
|
+
* Validation is LENIENT (unknown keys are stripped, not rejected) — use
|
|
275
|
+
* this on the runtime/read path so a stored config that picked up an
|
|
276
|
+
* extra key survives schema evolution.
|
|
277
|
+
*
|
|
278
|
+
* @throws ZodError on validation failure
|
|
279
|
+
* @throws Error on migration failure
|
|
280
|
+
*/
|
|
281
|
+
async parseAssumingV1(rawData: unknown): Promise<T> {
|
|
282
|
+
return this.parse({ version: 1, data: rawData });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Like {@link parseAssumingV1}, but the FINAL validation is STRICT:
|
|
287
|
+
* unknown keys on a plain-object schema are rejected rather than
|
|
288
|
+
* stripped.
|
|
289
|
+
*
|
|
290
|
+
* Migrate-then-STRICT is the editor/validation path: removed or renamed
|
|
291
|
+
* fields are migrated away by the chain (so stored configs survive
|
|
292
|
+
* schema evolution), while genuine typos — keys no migration accounts
|
|
293
|
+
* for — still surface to the operator.
|
|
294
|
+
*
|
|
295
|
+
* Strict handling mirrors the object-vs-non-object split used by the
|
|
296
|
+
* automation definition validator: only `z.ZodObject` schemas can be
|
|
297
|
+
* tightened with `.strict()`; unions, records, and primitives fall back
|
|
298
|
+
* to a normal parse.
|
|
299
|
+
*
|
|
300
|
+
* @throws ZodError on validation failure
|
|
301
|
+
* @throws Error on migration failure
|
|
302
|
+
*/
|
|
303
|
+
async parseStrictAssumingV1(rawData: unknown): Promise<T> {
|
|
304
|
+
const migrated = await this.migrateToVersion({ version: 1, data: rawData });
|
|
305
|
+
return this.strictValidate(migrated.data);
|
|
306
|
+
}
|
|
307
|
+
|
|
180
308
|
// ─────────────────────────────────────────────────────────────────────────
|
|
181
309
|
// Data Creation (wrap new data)
|
|
182
310
|
// ─────────────────────────────────────────────────────────────────────────
|
|
@@ -214,6 +342,34 @@ export class Versioned<T> {
|
|
|
214
342
|
return input.version !== this.version;
|
|
215
343
|
}
|
|
216
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Structural check (no `migrate()` invocation) that this config has a
|
|
347
|
+
* COMPLETE, contiguous migration chain from version 1 to its current
|
|
348
|
+
* `version`: every step increments by exactly 1 and the chain reaches
|
|
349
|
+
* `version` with no gaps or detours.
|
|
350
|
+
*
|
|
351
|
+
* For a `version: 1` config this is trivially satisfied (no migrations
|
|
352
|
+
* needed). For any `version > 1` it requires a covering chain — which is
|
|
353
|
+
* exactly what {@link parseAssumingV1} relies on, so a missing chain is a
|
|
354
|
+
* latent read failure. Returns the first problem found, or `undefined`
|
|
355
|
+
* when the chain is complete.
|
|
356
|
+
*
|
|
357
|
+
* Intended for a CI contract test that enumerates every registered config
|
|
358
|
+
* and fails the build if a future `version` bump ships without a covering
|
|
359
|
+
* migration — zero per-config upkeep.
|
|
360
|
+
*/
|
|
361
|
+
validateMigrationChainFromV1(): string | undefined {
|
|
362
|
+
try {
|
|
363
|
+
assertMigrationChainFromV1({
|
|
364
|
+
version: this.version,
|
|
365
|
+
migrations: this._migrations,
|
|
366
|
+
});
|
|
367
|
+
return undefined;
|
|
368
|
+
} catch (error) {
|
|
369
|
+
return extractErrorMessage(error);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
217
373
|
/**
|
|
218
374
|
* Validate data without migration (schema.parse wrapper).
|
|
219
375
|
* For data already at current version.
|
|
@@ -229,6 +385,22 @@ export class Versioned<T> {
|
|
|
229
385
|
return this.schema.safeParse(data);
|
|
230
386
|
}
|
|
231
387
|
|
|
388
|
+
/**
|
|
389
|
+
* Validate in STRICT mode: when the schema is a plain object, unknown
|
|
390
|
+
* keys are rejected; otherwise (unions/records/primitives) fall back to
|
|
391
|
+
* a normal parse. Used by {@link parseStrictAssumingV1}.
|
|
392
|
+
*/
|
|
393
|
+
private strictValidate(data: unknown): T {
|
|
394
|
+
if (this.schema instanceof z.ZodObject) {
|
|
395
|
+
// `.strict()` only narrows accepted keys; the parsed output shape is
|
|
396
|
+
// identical to the base object schema's, but its inferred type loses
|
|
397
|
+
// the `T` brand. The cast restores it without changing runtime
|
|
398
|
+
// behaviour.
|
|
399
|
+
return this.schema.strict().parse(data) as T;
|
|
400
|
+
}
|
|
401
|
+
return this.schema.parse(data);
|
|
402
|
+
}
|
|
403
|
+
|
|
232
404
|
// ─────────────────────────────────────────────────────────────────────────
|
|
233
405
|
// Internal migration logic
|
|
234
406
|
// ─────────────────────────────────────────────────────────────────────────
|
package/src/core-services.ts
CHANGED
|
@@ -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 {
|
|
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;`,
|