@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.
- package/CHANGELOG.md +169 -0
- package/package.json +15 -14
- 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 +177 -11
- 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/types.ts +5 -38
- package/src/zod-config.test.ts +60 -0
- package/src/zod-config.ts +38 -14
- package/tsconfig.json +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,174 @@
|
|
|
1
1
|
# @checkstack/backend-api
|
|
2
2
|
|
|
3
|
+
## 0.21.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 13373ce: Break the publish-time dependency cycle between `@checkstack/backend-api` and `@checkstack/cache-api` / `@checkstack/queue-api`.
|
|
8
|
+
|
|
9
|
+
`cache-api` and `queue-api` only ever used `Logger` and `Migration` from `backend-api` as `import type`, yet declared `@checkstack/backend-api` as a runtime dependency. In the monorepo this is harmless (everything resolves via `workspace:*`), but once published, `bun publish` freezes each `workspace:*` into a concrete pin of the _other_ package's then-current version. Because the dependency is mutual, a consumer installing these packages from the registry must resolve `backend-api -> cache-api -> backend-api -> ...` backward through release history until it reaches ancient versions that shipped raw `workspace:*` ranges and a long-removed `@checkstack/cache-api@0.1.0` pin - which fail to resolve. This surfaced as `bun install` errors (and a missing `checkstack-dev` binary) in freshly scaffolded standalone plugins.
|
|
10
|
+
|
|
11
|
+
`Logger` and `Migration` now live in `@checkstack/common` (a dependency-free leaf package). `@checkstack/backend-api` re-exports both for backward compatibility, so existing `import type { Logger, Migration } from "@checkstack/backend-api"` call sites are unchanged. `cache-api` and `queue-api` now depend on `@checkstack/common` instead of `@checkstack/backend-api`, removing the cycle.
|
|
12
|
+
|
|
13
|
+
- Updated dependencies [13373ce]
|
|
14
|
+
- @checkstack/common@0.14.0
|
|
15
|
+
- @checkstack/cache-api@0.3.10
|
|
16
|
+
- @checkstack/queue-api@0.3.10
|
|
17
|
+
- @checkstack/healthcheck-common@1.5.1
|
|
18
|
+
- @checkstack/signal-common@0.2.7
|
|
19
|
+
- @checkstack/template-engine@0.4.1
|
|
20
|
+
|
|
21
|
+
## 0.21.0
|
|
22
|
+
|
|
23
|
+
### Minor Changes
|
|
24
|
+
|
|
25
|
+
- 9dcc848: Add the AI platform: a transport-agnostic tool spine, an OAuth Authorization Server + read-only MCP server, a propose/apply flow with audit log, a streaming in-app chat agent, per-conversation permission modes, per-integration spend caps, and user-scoped tool authorization.
|
|
26
|
+
|
|
27
|
+
Two new packages, `@checkstack/ai-common` (the `AiTool` contract, `read`/`mutate`/`destructive` effect classification, the `ai.*` access rules, the OpenAI-compatible connection shape, and the wire contracts) and `@checkstack/ai-backend` (the tool registry, extension points, principal-to-tool resolver, shared zod-to-JSON-Schema serializer, and all transports). The OpenAI-compatible integration provider registers through the existing integration provider extension point, so its API key is stored in the Secrets Vault and configured in the generic Connections UI.
|
|
28
|
+
|
|
29
|
+
What ships:
|
|
30
|
+
|
|
31
|
+
- Tool spine and extension points: `aiToolExtensionPoint.registerTool` (hand-authored composite tools) and `aiToolProjectionExtensionPoint.expose` (opt-in projections of existing oRPC procedures). Authorization mirrors `autoAuthMiddleware` exactly - a tool is surfaced only when every `requiredAccessRules` entry is satisfied, so a scope-narrowed principal can only ever see fewer tools.
|
|
32
|
+
- OAuth + MCP: Checkstack can act as its own OAuth 2.1 Authorization Server (authorization code + PKCE, consent screen, Dynamic Client Registration) and expose a read-only MCP server over Streamable HTTP at `/api/ai/mcp`. Off by default, enabled by the admin `ai.mcp-oauth` setting. A Bearer OAuth-token branch is added to the auth strategy; token scopes are intersected live with the bound user's access rules on every call. A shared-Postgres rate limiter throttles the DCR endpoint per client IP. `getMcpOAuthSettings` / `setMcpOAuthSettings` contracts added to `@checkstack/auth-common`. A minimal OAuth consent page (`/auth/oauth-consent`) renders the requesting client and scopes.
|
|
33
|
+
- Propose/apply + audit: a transport-agnostic two-step service - `propose` re-checks authz, runs the tool's `dryRun` without mutating, and returns a single-use proposal token (the `proposed` audit row IS the token store, 10-minute TTL, atomic single-use); `apply` re-parses the server-stored payload, re-checks authz, and atomically commits. The `ai_tool_calls` audit table records every call across both transports with a SHA-256 args hash (never raw arguments) and stamps who proposed and who applied. An `ai.toolCalled` event carries metadata only.
|
|
34
|
+
- In-app chat: a server-side, provider-agnostic Vercel AI SDK agent loop (OpenAI, Azure, OpenRouter, Ollama, vLLM, LM Studio, ...). The model provider is built on the backend from the integration credentials, so the API key never leaves the backend. The loop offers only resolver-allowed tools, auto-runs read tools (re-entering the live router as the logged-in user) and routes mutating / destructive tools through propose/apply. Durable conversation persistence (`ai_conversations`, `ai_messages`, owner-scoped RPCs) plus a streaming chat UI with a confirm-card component and per-integration model picker.
|
|
35
|
+
- Per-conversation permission mode (Claude-Code-style approve/auto), a durable `permission_mode` column on `ai_conversations` (default `approve`). `read` always auto-runs in both modes; `mutate` inherits the mode (auto-applies server-side in `auto`, confirm-carded in `approve`); `destructive` ALWAYS requires the human `applyTool` in both modes. Security invariant (structural + tested): the mode is consulted only on the `mutate` branch, so no `(effect, mode)` pair routes a destructive tool to auto-apply.
|
|
36
|
+
- Per-integration LLM spend cap (optional `spendCap` = `tokenBudget` + `windowMinutes`, default OFF). Spend is tracked in a shared-Postgres `ai_spend` ledger; enforcement is a rolling-window SUM run before each turn (HTTP 429 over budget). Per-principal tool rate-limit budgets are a rolling COUNT over `ai_tool_calls`, enforced on both transports. An absent / empty / incomplete `spendCap` is treated as "no cap" rather than rejected.
|
|
37
|
+
- Full tool-call replay: `ai_messages.model_messages` (jsonb) persists the canonical AI-SDK `ResponseMessage[]` per turn and replays them verbatim on the next turn; legacy rows fall back to text-only replay.
|
|
38
|
+
- Enforced no-secret-leak scrubbing: `appendMessage` runs `scrubContent` on every write, redacting credential-shaped keys and high-confidence credential values; a canary regression test asserts injected secrets are stripped. A hardening test suite asserts no secret appears in any AI-surface DTO and that handler-side authz holds when the model misbehaves.
|
|
39
|
+
- Provider correctness: the chat provider uses `@ai-sdk/openai-compatible`'s `chatModel` (plain `/chat/completions`), so OpenAI-compatible gateways (OpenRouter, DeepSeek, Ollama, vLLM) no longer reject turns with `invalid_prompt`; `@ai-sdk/openai` is removed.
|
|
40
|
+
|
|
41
|
+
BREAKING CHANGES:
|
|
42
|
+
|
|
43
|
+
- The `AiTool` contract (`@checkstack/ai-common`) gained a `TRpc` type parameter, and both `dryRun` and `execute` now receive a USER-SCOPED `rpcClient` arg bound to the originating user. Every plugin procedure a tool calls re-enters the live router AS THAT USER, so handler-side authorization (access rules AND per-resource/team scope) is enforced exactly as a direct UI/RPC call - closing a prior privilege-escalation where tools captured a trusted service client at construction. A hand-authored tool MUST resolve its plugin client from this per-call arg and MUST NOT capture a trusted service client at factory scope. Tool factories that previously took `{ rpcClient }` should drop that parameter.
|
|
44
|
+
- `AiToolProjectionExtensionPoint.expose` no longer takes a second `pluginMetadata` argument; the owning metadata lives on `input.sourcePluginMetadata`. Callers must drop the second argument.
|
|
45
|
+
|
|
46
|
+
State and scale: conversations, messages, the audit log, proposal tokens, the rate-limit counter, and the spend ledger all live in shared Postgres, so every pod answers identically and the agent loop is resumable on any pod. The only pod-local state is the live MCP connection registry (bookkeeping, never a source of truth). Cross-pod conversation readback, the spend cap, and the tool budget are verified by env-gated two-pod integration tests.
|
|
47
|
+
|
|
48
|
+
This is a beta minor.
|
|
49
|
+
|
|
50
|
+
- 9dcc848: Automations now run as a configured service account, removing implicit god-mode from the dispatch path.
|
|
51
|
+
|
|
52
|
+
BREAKING: every automation must declare a `runAs` application (service account). Previously every automation action ran as the trusted service client, bypassing all access-rule, per-resource, and team-scope checks - so an automation could touch any team's data. Now each automation runs as a bounded `application` principal, and every data-access call an action makes is authorized exactly as that identity. An automation with no `runAs` fails to run with a clear error rather than falling back to the trusted client; legacy automations must be assigned a service account before they run again.
|
|
53
|
+
|
|
54
|
+
What changed:
|
|
55
|
+
|
|
56
|
+
- New top-level field `runAs` on automations (a `run_as_application_id` column + create/update inputs; `AutomationSchema.runAs`). Required on create; GitOps sets it via the `run-as` metadata label.
|
|
57
|
+
- A new `coreServices.rpcClientAs(applicationId)` mints a short-lived, backend-signed app-principal token; the auth service resolves it LIVE to an `application` principal (reusing `enrichApplicationPrincipal`), so it flows through full `autoAuthMiddleware` enforcement. The dispatch engine threads this client into every action's `execute` as the required `context.rpcClient`.
|
|
58
|
+
- Bind authority (anti-escalation): a user may only bind an application whose access rules are a subset of their own (`isApplicationBindable`); `getBindableApplications` lists only bindable apps, and the create/update handlers enforce the check.
|
|
59
|
+
- `notification.sendTransactional` moves from service-only to access-gated (`notification.send`, a new access rule), so an automation's `runAs` can call the built-in `notify_user` / `notification.send` actions; trusted services still bypass via short-circuit.
|
|
60
|
+
- A "Run as (Service Account)" picker in the automation editor, populated from `getBindableApplications` (server-side filtered to bindable apps), seeding from the loaded `runAs` on edit and passing it into create + update. First-class teaching UX: an inline info banner, a blocked Save with an inline hint until one is chosen, and an empty state linking to the Applications admin + docs when none are bindable.
|
|
61
|
+
|
|
62
|
+
State and scale: `runAs` resolution is a pure read over shared tables; the app-principal token is self-contained and verified statelessly, so the per-run client is correct under horizontal scale.
|
|
63
|
+
|
|
64
|
+
This is a beta minor.
|
|
65
|
+
|
|
66
|
+
- 9dcc848: Harden config-versioning so stored configs always migrate-then-validate and broken migration chains fail fast at boot.
|
|
67
|
+
|
|
68
|
+
- `@checkstack/backend-api` `Versioned<T>` gains `parseAssumingV1` (migrate-from-v1 then validate leniently, runtime path), `parseStrictAssumingV1` (migrate then validate strictly, editor path), and `validateMigrationChainFromV1()`. A standalone pure helper `assertMigrationChainFromV1({ version, migrations })` is the single shared implementation behind the constructor guard and `validateMigrationChainFromV1`.
|
|
69
|
+
- `Versioned` now validates its own v1 -> `version` chain in the constructor, which runs at module import / plugin registration. A new `no-restricted-syntax` ESLint rule bans calling `parse` / `safeParse` / `parseAsync` / `strict` directly on a `Versioned`'s `.schema` member.
|
|
70
|
+
- Auth strategy migration chains are validated at the `betterAuthExtensionPoint.addStrategy` chokepoint (`@checkstack/auth-backend`).
|
|
71
|
+
- Automation action AND trigger configs migrate-then-validate (lenient at dispatch, strict in the editor validator, recursing into `choose`/`parallel`/`repeat`/`sequence` blocks). The `run_script` / `run_shell` action configs bump to `version: 2` dropping the removed `sandbox` key, fixing the editor's `Unrecognized key: sandbox` error.
|
|
72
|
+
- Anomaly read path now validates: `getAnomalyConfig` / `getAnomalyAssignmentConfig` run stored records through `Versioned.parseRecord`; `PartialAnomalySettingsSchema` moved to `@checkstack/anomaly-common`. Notification ConfigService reads thread the migrations argument, and per-strategy `userConfig` is migrate-then-validated before `send()`.
|
|
73
|
+
- gitops-apply migrate-then-validates authored health-check config; integration connection validation routes through `safeValidate`. The latent HTTP health-check `result` schema (at `version: 3` with no migrations) now ships a pass-through v1 -> v2 -> v3 chain.
|
|
74
|
+
|
|
75
|
+
BREAKING CHANGES (fail-fast at boot, intended):
|
|
76
|
+
|
|
77
|
+
- Any `Versioned` config with `version > 1` and an incomplete or non-contiguous migration chain now throws at construction (boot) instead of failing lazily on first read. This covers every `Versioned` instance repo-wide, including future plugin types. Out-of-tree plugins shipping such a config must add the missing migration step(s); all in-repo strategies already have complete chains.
|
|
78
|
+
- An auth strategy declaring `configVersion > 1` without a complete chain throws at registration.
|
|
79
|
+
- A trigger's per-automation config is now a versioned `config: Versioned<TConfig>` instead of a bare `configSchema?`. Plugins registering triggers with `configSchema:` must wrap it: `config: new Versioned({ version: 1, schema })`. The underlying schema stays reachable via `config.schema`; triggers without per-automation config are unaffected.
|
|
80
|
+
|
|
81
|
+
State and scale: all affected reads resolve from shared Postgres / in-process registries, so every pod sees the same migrated answer. No new framework-owned current-state store.
|
|
82
|
+
|
|
83
|
+
This is a beta minor.
|
|
84
|
+
|
|
85
|
+
- 9dcc848: Add environments as a first-class catalog primitive, with per-environment health-check fan-out, config templating, per-environment reactive health, and script run-context exposure.
|
|
86
|
+
|
|
87
|
+
- Catalog primitive: an environment is a sibling of groups - a named, instance-global record carrying free-form custom fields (baseUrl, region, tier, ...) that any system can belong to many-to-many. New `environments` + `systems_environments` tables, `EnvironmentSchema` + create/update schemas, `EntityService` environment CRUD and membership joins, RPC endpoints gated by a new `catalogAccess.environment` access rule, a GitOps `Environment` kind + `System.environments` extension, and frontend management (an `EnvironmentEditor`, an Environments management panel, and a per-system environment picker). The Environments card's Add/Edit/Delete affordances are gated on `catalogAccess.environment.manage`.
|
|
88
|
+
- Per-environment fan-out: run identity becomes `(systemId, configurationId, environmentId)`. Runs, aggregates, and state transitions gain a nullable `environmentId`. The health-check assignment gains an `environmentIds` selector with three modes (All / Specific / None; `null` and `[]` are distinct). The queue executor resolves the effective environment set via the catalog `resolveSystemEnvironments` read and executes one isolated run per environment.
|
|
89
|
+
- Config templating: a new `x-templatable` config-field marker renders a string field through the template engine at execute time, against `{ environment, check, system }`. A shared `renderTemplatableConfig` and a `renderTemplatePreview` helper (re-exported from `@checkstack/template-engine`) keep editor previews identical to the run-time render. The HTTP collector's `url`, `headers[].value`, and `body` are templatable, rendered per environment (the strategy client build moves inside the per-env loop); the `url`'s `.url()` validation moves post-render. Secrets resolve before templating; a field marked both secret and `x-templatable` is rejected at plugin load. `DynamicForm` shows a live "Preview" line, and the catalog `EnvironmentPreviewPicker` ("Preview as: <environment>") drives it in the collector editor (only when the schema has a templatable field).
|
|
90
|
+
- Script run-context: `CollectorRunContext` gains an optional `environment` field (`{ id, name, fields }`, metadata only). Shell collectors receive `CHECKSTACK_ENV_ID` / `_NAME` / `CHECKSTACK_ENV_<FIELD>` vars; inline TS collectors read `globalThis.context.environment`; the editor test panel mirrors both. The env-less path is unchanged.
|
|
91
|
+
- Per-environment reactive health (see BREAKING below), env-keyed read/write paths, env-qualified serialization locks, an optional `trigger.payload.environmentId`, per-environment isolation, and an `ENVIRONMENT_RESOLUTION_FAILED` signal when catalog resolution degrades to a single env-less run.
|
|
92
|
+
|
|
93
|
+
BREAKING CHANGES: the reactive `health` entity's id-shape and cardinality change. It now encodes two views: per-environment (id `"<systemId>::<environmentId>"`) and a system rollup (id `"<systemId>"`, the worst status across environments + env-less runs). The rollup PRESERVES the pre-existing system-level contract - dashboards, status badges, and automations referencing health by `systemId` keep working without re-authoring - but the entity's contract surface changed (new id-shape, higher cardinality, new payload field), so it is flagged breaking. `getBulkHealthState` parses env-qualified ids and keys results by the original id.
|
|
94
|
+
|
|
95
|
+
State and scale: membership and custom fields live only in catalog Postgres and are re-read every tick via the cross-plugin RPC; env-keyed health reads from shared `health_check_runs` / aggregates / transitions (compute-on-read). Every pod resolves the same effective set and the same per-environment health. No pod-local environment state.
|
|
96
|
+
|
|
97
|
+
Also: `unwrapSchema` in `zod-config.ts` loops instead of single-pass-stripping so multi-layer wrappers (`.optional().default()`) still resolve `x-templatable` meta. The env-less `{{ environment.* }}` run notice logs at `debug` (a legitimate recurring configuration), while the post-render HTTP `.url()` check still fails a genuinely-broken empty render with a clear "Rendered URL is invalid" error.
|
|
98
|
+
|
|
99
|
+
This is a beta minor.
|
|
100
|
+
|
|
101
|
+
- 9dcc848: Cut initial-load JS: lazy plugin contributions, a hardened lazy-by-default contribution contract, on-demand Monaco, and a lighter icon/chart load.
|
|
102
|
+
|
|
103
|
+
- Lazy plugin route pages: each plugin's route `element` references a `React.lazy`-wrapped page rendered inside a shared `<Suspense>` boundary. Plugins still register synchronously, so nav, slots, commands, API factories, and `foreignSignals` are available on first paint. This moves ~37 route-page chunks (~600 KB) out of the entry; the entry chunk drops from ~2.4 MB to ~190 KB. Auth flow pages stay eager. The `@checkstack/scripts` scaffold template generates lazy route pages too.
|
|
104
|
+
- Hardened contribution contract (BREAKING, frontend plugin contract): plugins declare contributions lazily and let the framework own code-splitting, Suspense, and per-plugin error isolation. Routes use `load: () => import("./Page").then((m) => ({ default: m.Page }))` instead of `element: <Page />` (`element` is still accepted for the rare page that must paint without a chunk fetch; provide exactly one). Slot extensions accept either an eager `component` or a lazy `load`; new `getLazyContribution` + `ExtensionComponent` exports from `@checkstack/frontend-api` render either kind. This also fixes runtime-installed plugins: `ExtensionSlot` subscribes to the plugin registry, and the API registry rebuilds when the plugin set changes (`getPlugins()` returns an immutable snapshot via `useSyncExternalStore`). A per-plugin error boundary contains a bad contribution.
|
|
105
|
+
- On-demand Monaco: the `@checkstack/ui` barrel no longer pulls the `@codingame/*` / `monaco-languageclient` stack into the initial load. `CodeEditor` lazy-loads its Monaco-backed editor behind `React.lazy` + Suspense, `validateTypeScriptSources` imports the editor API via in-body `await import(...)`, and the "vscode services ready" signal moved to a Monaco-free module. The ~10 MB editor body loads only when a `CodeEditor` mounts. A `react-vendor` `manualChunks` split was added for stable vendor caching.
|
|
106
|
+
- lucide-react 1.x + lighter icons/charts (BREAKING for icon consumers): lucide-react unified from three drifting ranges to `^1.17.0`. lucide v1 removed brand icons, so the GitHub/GitLab marks are vendored in `@checkstack/ui` (`GithubIcon`, `GitlabIcon`, `brandIcons`); a new `IconName` type (`LucideIconName | BrandIconName`) in `@checkstack/common` is canonical, accepted by `AuthStrategy.icon` and the card components, so data-driven brand names keep working. `DynamicIcon` no longer eagerly imports lucide's ~1600-icon map (~1 MB) - it lives in a `React.lazy` `iconRegistry` chunk fetched on first data-driven render, while statically named-imported icons tree-shake normally. The recharts-backed health-check charts (~300 KB) and the `HealthCheckSystemOverview` drawer leave the initial load.
|
|
107
|
+
|
|
108
|
+
BREAKING CHANGES:
|
|
109
|
+
|
|
110
|
+
- Frontend plugin contract: routes/slot contributions are lazy-by-default (`load` instead of `element`/eager elements) as described above.
|
|
111
|
+
- Any external consumer importing a brand icon from `lucide-react` (e.g. `import { Github } from "lucide-react"`) must switch to the vendored `@checkstack/ui` brand icons or a custom SVG.
|
|
112
|
+
|
|
113
|
+
This is a beta minor.
|
|
114
|
+
|
|
115
|
+
- 9dcc848: Layered OS-level script sandbox, secure and fail-closed by default (epic #247).
|
|
116
|
+
|
|
117
|
+
Script and shell health checks and the `run_shell` / `run_script` automation actions now run inside a layered OS-level sandbox by default. The sandbox lives in `core/backend-api/src/script-sandbox/` (the single source of truth) and is enforced inside the shared runners, so it applies wherever a job runs.
|
|
118
|
+
|
|
119
|
+
Layers:
|
|
120
|
+
|
|
121
|
+
- Resource caps (CPU / memory / PID / FD / file-size, via `prlimit` on capable Linux; ESM JS-heap cap via `--max-old-space-size`; portable wall-clock timeout) and an OOM-safe streaming output cap.
|
|
122
|
+
- Privilege drop via a NON-ROOT supervisor model: the shipped images run the supervisor as non-root uid `65532`, so every sandboxed script inherits non-root and can never be host-root; filesystem + network confinement is delivered by ROOTLESS `bwrap`/`nsjail` via unprivileged user namespaces. `enforced.privilege` is truthful (true only when the child cannot run as host-root). Runners no longer pass `uid`/`gid` to `Bun.spawn` (a silent no-op and a forward-compat hazard).
|
|
123
|
+
- Filesystem isolation (`scratch-only` / `scratch-plus-ro`) confining the child to its per-run scratch dir over a read-only base; the interpreter path is RO-bound so the runtime execs, and `TMPDIR` is pinned to the in-namespace tmpfs.
|
|
124
|
+
- Network egress control: `deny` (routeless loopback-only netns), `allowlist` (real plumbed egress via macvlan OR rootless slirp4netns + an in-kernel nftables filter), and an always-on metadata / link-local block (`169.254.0.0/16`, `fe80::/10`, `fc00::/7`). No-blackhole invariant: `enforced.network` is never true when egress is actually severed or unfiltered; unpluggable egress degrades to surfaced host net.
|
|
125
|
+
- Per-run fork-bomb containment via RLIMIT*NPROC inside the fresh per-run user+PID namespace; a centralized forbidden-env denylist (`LD_PRELOAD`, `LD_LIBRARY_PATH`, `DYLD*_`, `NODE*OPTIONS`, `BUN*_`, caller `PATH` overrides).
|
|
126
|
+
- A validated tuned seccomp profile (`deploy/seccomp/checkstack-userns.json`) and a live `clone(CLONE_NEWUSER|CLONE_NEWNET)` capability probe (not the static sysctl), shipped by default in both Dockerfiles, `docker-compose.yml`, and `deploy/k8s/checkstack-sandbox.yaml`.
|
|
127
|
+
|
|
128
|
+
Global policy and operator surface:
|
|
129
|
+
|
|
130
|
+
- The global sandbox policy lives in ONE durable row owned by `script-packages` (its `ConfigService` row in shared `plugin_configs`). A single process-wide provider serves every runner; the two script plugins no longer register competing providers. A dedicated admin-only `script-sandbox.manage` permission gates both reading and writing the policy. New `getSandboxPolicy` / `setSandboxPolicy` endpoints and a Settings -> Script Sandbox admin UI (`enabled`, `onUnavailable`, network/filesystem/privilege modes, allow list, metadata block, resource caps). The startup capability/readiness log is emitted in-process by `script-packages-backend` (no fragile init-order RPC self-loop), and on a host that cannot enforce a layer a one-time startup warning explains the two local-dev paths (Docker, or set the global policy to `degrade`).
|
|
131
|
+
- Satellite relay: the WS protocol carries the resolved policy in the `authenticated` message and a `sandbox_policy` push-on-change; a satellite caches the last relayed policy and resolves every run through it.
|
|
132
|
+
|
|
133
|
+
BREAKING CHANGES (platform in BETA, shipped as minor):
|
|
134
|
+
|
|
135
|
+
- Scripts run sandboxed by default. The shipped global default is FAIL-CLOSED (`onUnavailable: "fail"`): when a requested layer cannot be enforced the run is REFUSED (clean `exitCode: -1`, never an unsandboxed spawn) rather than silently degrading. Deployments on hosts that cannot enforce a layer (no bubblewrap, user namespaces blocked, no `/proc` unmask) must run the official images with the documented runtime flags (the bundled seccomp profile + `systempaths=unconfined`, or k8s `procMount: Unmasked`), or set the global policy to `degrade`. On macOS / restricted containers the strong layers degrade to the portable subset and are surfaced per run.
|
|
136
|
+
- Default network posture is deny-egress (`allowlist` with an empty allow list, which resolves to the routeless `deny` path). Scripts calling external endpoints fail until those destinations are allowlisted in the global default. The always-on metadata / link-local block applies even under looser modes.
|
|
137
|
+
- The per-action / per-check `sandbox` config override and the transport `ScriptRequest.sandbox` field are removed; policy is global-only, so an automation/check author can no longer weaken the sandbox on their own item. Stored configs carrying a stray `sandbox` key are tolerated (stripped on parse).
|
|
138
|
+
- The shared runners' `run()` no longer accepts a `sandbox` option; callers rely on the global policy provider.
|
|
139
|
+
- A satellite fails closed (most restrictive profile) until it receives the first relayed policy; a relay-read failure or an older core keeps it fail-closed. A relay failure can never loosen a satellite's sandbox.
|
|
140
|
+
|
|
141
|
+
State and scale: the global policy is a single durable Postgres row read identically on every pod. Capability detection is per-process, deterministic from the host kernel, and surfaced per run via the `EffectiveSandbox` report (a Linux pod and a macOS satellite may legitimately differ). `CHECKSTACK_SANDBOX_UID/GID` and macvlan addressing are genuinely per-host infrastructure, surfaced per run, not the queryable policy. The satellite's policy cache is satellite-local transport state. No new pod-local current-state.
|
|
142
|
+
|
|
143
|
+
This is a beta minor.
|
|
144
|
+
|
|
145
|
+
- 9dcc848: Align workspace dependency versions and migrate React Router to v7.
|
|
146
|
+
|
|
147
|
+
BREAKING CHANGES (React Router v7): All frontend packages now depend on `react-router-dom@^7.16.0`. Previously the workspace declared four divergent ranges (`^6.20.0`, `^6.22.0`, `^7.1.1`, `^7.14.2`), which resolved both `react-router@6` and `react-router@7` into a single bundle. Everything is now unified on v7. The public imports the app uses (`BrowserRouter`, `Routes`, `Route`, `Link`, `NavLink`, `MemoryRouter`, `useNavigate`, `useParams`, `useSearchParams`, `useLocation`) are unchanged between v6 and v7, so no source rewrites were required - but any out-of-tree plugin still on react-router v6 should upgrade to v7 (see the React Router v6 -> v7 upgrade guide) to share the host's single router instance via the import map.
|
|
148
|
+
|
|
149
|
+
Other unified ranges (no API change): `react` -> `^18.3.1`, the `@orpc/*` family (`contract`, `server`, `client`, `tanstack-query`, `openapi`, `zod`) -> `^1.14.4`, and `better-auth` -> `^1.6.13`.
|
|
150
|
+
|
|
151
|
+
Removed the pre-rename `@orpc/react-query` leftover from `@checkstack/frontend-api`; its `createRouterUtils` / `RouterUtils` / `ProcedureUtils` now come from `@orpc/tanstack-query` (the package already in use).
|
|
152
|
+
|
|
153
|
+
Stale in-range runtime deps pulled up to current published versions: `hono` `^4.12.23`, `@tanstack/react-query` (+devtools) `^5.100.14`, `date-fns` `^4.4.0`, `jose` `^6.2.3`, `tar` `^7.5.16`, `semver` `^7.8.1`, `@xyflow/react` `^12.11.0`.
|
|
154
|
+
|
|
155
|
+
### Patch Changes
|
|
156
|
+
|
|
157
|
+
- Updated dependencies [9dcc848]
|
|
158
|
+
- Updated dependencies [9dcc848]
|
|
159
|
+
- Updated dependencies [9dcc848]
|
|
160
|
+
- Updated dependencies [9dcc848]
|
|
161
|
+
- Updated dependencies [9dcc848]
|
|
162
|
+
- Updated dependencies [9dcc848]
|
|
163
|
+
- Updated dependencies [9dcc848]
|
|
164
|
+
- Updated dependencies [9dcc848]
|
|
165
|
+
- @checkstack/healthcheck-common@1.5.0
|
|
166
|
+
- @checkstack/common@0.13.0
|
|
167
|
+
- @checkstack/template-engine@0.4.0
|
|
168
|
+
- @checkstack/cache-api@0.3.9
|
|
169
|
+
- @checkstack/queue-api@0.3.9
|
|
170
|
+
- @checkstack/signal-common@0.2.6
|
|
171
|
+
|
|
3
172
|
## 0.20.0
|
|
4
173
|
|
|
5
174
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/backend-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -10,30 +10,31 @@
|
|
|
10
10
|
"lint:code": "eslint . --max-warnings 0"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@checkstack/
|
|
14
|
-
"@checkstack/
|
|
15
|
-
"@checkstack/
|
|
16
|
-
"@checkstack/queue-api": "0.3.
|
|
17
|
-
"@checkstack/signal-common": "0.2.
|
|
18
|
-
"@
|
|
19
|
-
"@orpc/
|
|
20
|
-
"@orpc/
|
|
21
|
-
"@orpc/
|
|
22
|
-
"@orpc/
|
|
13
|
+
"@checkstack/cache-api": "0.3.9",
|
|
14
|
+
"@checkstack/common": "0.13.0",
|
|
15
|
+
"@checkstack/healthcheck-common": "1.5.0",
|
|
16
|
+
"@checkstack/queue-api": "0.3.9",
|
|
17
|
+
"@checkstack/signal-common": "0.2.6",
|
|
18
|
+
"@checkstack/template-engine": "0.4.0",
|
|
19
|
+
"@orpc/client": "^1.14.4",
|
|
20
|
+
"@orpc/contract": "^1.14.4",
|
|
21
|
+
"@orpc/openapi": "^1.14.4",
|
|
22
|
+
"@orpc/server": "^1.14.4",
|
|
23
|
+
"@orpc/zod": "^1.14.4",
|
|
23
24
|
"drizzle-orm": "^0.45.0",
|
|
24
|
-
"hono": "^4.12.
|
|
25
|
+
"hono": "^4.12.23",
|
|
25
26
|
"marked": "^17.0.1",
|
|
26
27
|
"zod": "^4.2.1"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|
|
29
|
-
"@checkstack/scripts": "0.
|
|
30
|
+
"@checkstack/scripts": "0.4.0",
|
|
30
31
|
"@checkstack/tsconfig": "0.0.7",
|
|
31
32
|
"@types/bun": "latest",
|
|
32
33
|
"@types/pg": "^8.20.0",
|
|
33
34
|
"pg": "^8.21.0"
|
|
34
35
|
},
|
|
35
36
|
"peerDependencies": {
|
|
36
|
-
"hono": "^4.12.
|
|
37
|
+
"hono": "^4.12.23",
|
|
37
38
|
"drizzle-orm": "^0.45.0"
|
|
38
39
|
},
|
|
39
40
|
"checkstack": {
|
package/src/auth-strategy.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { Migration } from "./config-versioning";
|
|
3
|
-
import type {
|
|
3
|
+
import type { IconName } from "@checkstack/common";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Migration chain for auth strategy configurations.
|
|
@@ -21,8 +21,11 @@ export interface AuthStrategy<Config = unknown> {
|
|
|
21
21
|
/** Optional description of the strategy */
|
|
22
22
|
description?: string;
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Icon name in PascalCase. A lucide icon (e.g. 'Mail') or a vendored brand
|
|
26
|
+
* icon (e.g. 'Github') - see `IconName` / `DynamicIcon`.
|
|
27
|
+
*/
|
|
28
|
+
icon?: IconName;
|
|
26
29
|
|
|
27
30
|
/** Current version of the configuration schema */
|
|
28
31
|
configVersion: number;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared opaque-bearer extraction used by the OAuth resource-server validate
|
|
3
|
+
* path (auth-backend) and the MCP transport (ai-backend) so the two never
|
|
4
|
+
* drift. A `ck_` API key is NOT an opaque OAuth bearer token (it has its own
|
|
5
|
+
* dedicated auth branch), so it is explicitly excluded here.
|
|
6
|
+
*/
|
|
7
|
+
export function opaqueBearerToken(request: Request): string | undefined {
|
|
8
|
+
const header = request.headers.get("authorization");
|
|
9
|
+
if (!header?.startsWith("Bearer ") || header.startsWith("Bearer ck_")) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
return header.slice("Bearer ".length);
|
|
13
|
+
}
|
|
@@ -24,6 +24,15 @@ export interface CollectorResult<TResult> {
|
|
|
24
24
|
export interface CollectorRunContext {
|
|
25
25
|
check: { id: string; name: string; intervalSeconds: number };
|
|
26
26
|
system: { id: string; name: string };
|
|
27
|
+
/**
|
|
28
|
+
* The resolved environment for THIS run, when the check fanned out into
|
|
29
|
+
* one. Absent when the assignment opts out or the system has no
|
|
30
|
+
* environments (the env-less run). `fields` is the environment's
|
|
31
|
+
* free-form custom metadata (verbatim values) - metadata only, never
|
|
32
|
+
* secrets. Exposed to scripts as `globalThis.context.environment` (inline)
|
|
33
|
+
* and the `CHECKSTACK_ENV_*` shell vars.
|
|
34
|
+
*/
|
|
35
|
+
environment?: { id: string; name: string; fields: Record<string, unknown> };
|
|
27
36
|
}
|
|
28
37
|
|
|
29
38
|
/**
|
|
@@ -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
|
+
});
|