@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/package.json +12 -11
  3. package/src/advisory-lock-pool.it.test.ts +282 -0
  4. package/src/advisory-lock.test.ts +144 -3
  5. package/src/advisory-lock.ts +97 -55
  6. package/src/auth-strategy.ts +6 -3
  7. package/src/bearer-token.ts +13 -0
  8. package/src/collector-strategy.ts +9 -0
  9. package/src/config-versioning.test.ts +227 -0
  10. package/src/config-versioning.ts +172 -0
  11. package/src/core-services.ts +14 -0
  12. package/src/esm-script-runner.test.ts +55 -16
  13. package/src/esm-script-runner.ts +212 -55
  14. package/src/index.ts +3 -0
  15. package/src/render-templatable-config.test.ts +168 -0
  16. package/src/render-templatable-config.ts +193 -0
  17. package/src/schema-utils.ts +3 -0
  18. package/src/script-sandbox/capabilities.test.ts +122 -0
  19. package/src/script-sandbox/capabilities.ts +372 -0
  20. package/src/script-sandbox/capped-output.test.ts +116 -0
  21. package/src/script-sandbox/capped-output.ts +172 -0
  22. package/src/script-sandbox/env-guard.test.ts +105 -0
  23. package/src/script-sandbox/env-guard.ts +129 -0
  24. package/src/script-sandbox/filesystem.test.ts +437 -0
  25. package/src/script-sandbox/filesystem.ts +514 -0
  26. package/src/script-sandbox/forkbomb.it.test.ts +121 -0
  27. package/src/script-sandbox/global-default.test.ts +161 -0
  28. package/src/script-sandbox/global-default.ts +100 -0
  29. package/src/script-sandbox/index.ts +14 -0
  30. package/src/script-sandbox/network.test.ts +356 -0
  31. package/src/script-sandbox/network.ts +373 -0
  32. package/src/script-sandbox/observability.test.ts +210 -0
  33. package/src/script-sandbox/observability.ts +168 -0
  34. package/src/script-sandbox/output-truncation.test.ts +53 -0
  35. package/src/script-sandbox/output-truncation.ts +69 -0
  36. package/src/script-sandbox/policy.test.ts +189 -0
  37. package/src/script-sandbox/policy.ts +220 -0
  38. package/src/script-sandbox/provider.test.ts +61 -0
  39. package/src/script-sandbox/provider.ts +134 -0
  40. package/src/script-sandbox/readiness.test.ts +80 -0
  41. package/src/script-sandbox/readiness.ts +117 -0
  42. package/src/script-sandbox/report.ts +88 -0
  43. package/src/script-sandbox/rootless-egress.it.test.ts +86 -0
  44. package/src/script-sandbox/rootless-egress.test.ts +99 -0
  45. package/src/script-sandbox/rootless-egress.ts +218 -0
  46. package/src/script-sandbox/shell-quote.test.ts +32 -0
  47. package/src/script-sandbox/shell-quote.ts +10 -0
  48. package/src/script-sandbox/wrapper.test.ts +1194 -0
  49. package/src/script-sandbox/wrapper.ts +714 -0
  50. package/src/shell-script-runner.test.ts +243 -0
  51. package/src/shell-script-runner.ts +210 -45
  52. package/src/zod-config.test.ts +60 -0
  53. package/src/zod-config.ts +38 -14
  54. package/tsconfig.json +3 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,210 @@
1
1
  # @checkstack/backend-api
2
2
 
3
+ ## 0.21.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+
9
+ 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.
10
+
11
+ What ships:
12
+
13
+ - 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.
14
+ - 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.
15
+ - 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.
16
+ - 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.
17
+ - 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.
18
+ - 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.
19
+ - 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.
20
+ - 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.
21
+ - 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.
22
+
23
+ BREAKING CHANGES:
24
+
25
+ - 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.
26
+ - `AiToolProjectionExtensionPoint.expose` no longer takes a second `pluginMetadata` argument; the owning metadata lives on `input.sourcePluginMetadata`. Callers must drop the second argument.
27
+
28
+ 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.
29
+
30
+ This is a beta minor.
31
+
32
+ - 9dcc848: Automations now run as a configured service account, removing implicit god-mode from the dispatch path.
33
+
34
+ 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.
35
+
36
+ What changed:
37
+
38
+ - 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.
39
+ - 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`.
40
+ - 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.
41
+ - `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.
42
+ - 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.
43
+
44
+ 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.
45
+
46
+ This is a beta minor.
47
+
48
+ - 9dcc848: Harden config-versioning so stored configs always migrate-then-validate and broken migration chains fail fast at boot.
49
+
50
+ - `@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`.
51
+ - `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.
52
+ - Auth strategy migration chains are validated at the `betterAuthExtensionPoint.addStrategy` chokepoint (`@checkstack/auth-backend`).
53
+ - 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.
54
+ - 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()`.
55
+ - 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.
56
+
57
+ BREAKING CHANGES (fail-fast at boot, intended):
58
+
59
+ - 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.
60
+ - An auth strategy declaring `configVersion > 1` without a complete chain throws at registration.
61
+ - 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.
62
+
63
+ 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.
64
+
65
+ This is a beta minor.
66
+
67
+ - 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.
68
+
69
+ - 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`.
70
+ - 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.
71
+ - 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).
72
+ - 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.
73
+ - 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.
74
+
75
+ 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.
76
+
77
+ 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.
78
+
79
+ 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.
80
+
81
+ This is a beta minor.
82
+
83
+ - 9dcc848: Cut initial-load JS: lazy plugin contributions, a hardened lazy-by-default contribution contract, on-demand Monaco, and a lighter icon/chart load.
84
+
85
+ - 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.
86
+ - 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.
87
+ - 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.
88
+ - 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.
89
+
90
+ BREAKING CHANGES:
91
+
92
+ - Frontend plugin contract: routes/slot contributions are lazy-by-default (`load` instead of `element`/eager elements) as described above.
93
+ - 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.
94
+
95
+ This is a beta minor.
96
+
97
+ - 9dcc848: Layered OS-level script sandbox, secure and fail-closed by default (epic #247).
98
+
99
+ 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.
100
+
101
+ Layers:
102
+
103
+ - 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.
104
+ - 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).
105
+ - 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.
106
+ - 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.
107
+ - 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).
108
+ - 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`.
109
+
110
+ Global policy and operator surface:
111
+
112
+ - 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`).
113
+ - 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.
114
+
115
+ BREAKING CHANGES (platform in BETA, shipped as minor):
116
+
117
+ - 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.
118
+ - 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.
119
+ - 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).
120
+ - The shared runners' `run()` no longer accepts a `sandbox` option; callers rely on the global policy provider.
121
+ - 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.
122
+
123
+ 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.
124
+
125
+ This is a beta minor.
126
+
127
+ - 9dcc848: Align workspace dependency versions and migrate React Router to v7.
128
+
129
+ 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.
130
+
131
+ 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`.
132
+
133
+ 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).
134
+
135
+ 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`.
136
+
137
+ ### Patch Changes
138
+
139
+ - Updated dependencies [9dcc848]
140
+ - Updated dependencies [9dcc848]
141
+ - Updated dependencies [9dcc848]
142
+ - Updated dependencies [9dcc848]
143
+ - Updated dependencies [9dcc848]
144
+ - Updated dependencies [9dcc848]
145
+ - Updated dependencies [9dcc848]
146
+ - Updated dependencies [9dcc848]
147
+ - @checkstack/healthcheck-common@1.5.0
148
+ - @checkstack/common@0.13.0
149
+ - @checkstack/template-engine@0.4.0
150
+ - @checkstack/cache-api@0.3.9
151
+ - @checkstack/queue-api@0.3.9
152
+ - @checkstack/signal-common@0.2.6
153
+
154
+ ## 0.20.0
155
+
156
+ ### Minor Changes
157
+
158
+ - a57f7db: fix(backend): give advisory locks a dedicated connection pool to prevent pool-starvation deadlock
159
+
160
+ Both the session-lock service and `withXactLock` HOLD a Postgres connection for
161
+ the lock's whole lifetime while the gated work runs on a _different_ connection.
162
+ Both lock and work were drawing from the single shared `adminPool` (which, with
163
+ no explicit config, defaulted to `max: 10` and `connectionTimeoutMillis: 0` -
164
+ wait forever). Under concurrency >= pool size, every slot became a lock-holding
165
+ connection waiting for a work connection that could never free up: a permanent
166
+ deadlock. It surfaced as all connections stuck `idle in transaction` on
167
+ `pg_advisory_xact_lock` and every API request hanging into an upstream 502,
168
+ only after the server had been running long enough to hit that concurrency
169
+ (e.g. a burst of health-check evaluations or incident dedups).
170
+
171
+ Advisory locks now run on a dedicated `lockPool`, separate from `adminPool`, so
172
+ the acquire graph is acyclic (`lockPool -> adminPool`, never back) and the
173
+ deadlock class is impossible. `AdvisoryLockService` gains a pooled
174
+ `withXactLock({ key, fn })` method (lock on the lock pool, work on the admin
175
+ pool); healthcheck's per-system serializer, incident's dedup-create, and the
176
+ automation single-mode concurrency lock now use it. The deadlock-prone
177
+ standalone `withXactLock({ db, ... })` helper is REMOVED.
178
+
179
+ Both pools are explicitly configured with `connectionTimeoutMillis` so any
180
+ future exhaustion fails fast and self-heals instead of hanging, and both get a
181
+ pool-level `error` handler (an idle pooled client whose backend dies otherwise
182
+ crashes the pod). The lock pool additionally sets
183
+ `idle_in_transaction_session_timeout` and `lock_timeout` so a stalled critical
184
+ section is reaped server-side (auto-releasing the lock) rather than stranding a
185
+ key forever. The advisory-lock service also now removes its per-client error
186
+ listener on release (it previously leaked one listener per acquisition on each
187
+ reused pooled connection - an unbounded `MaxListenersExceeded` leak).
188
+
189
+ New env vars (all optional): `DATABASE_POOL_MAX` (default 20),
190
+ `DATABASE_LOCK_POOL_MAX` (default 10), `DATABASE_POOL_CONNECTION_TIMEOUT_MS`
191
+ (default 10000), `DATABASE_POOL_IDLE_TIMEOUT_MS` (default 30000),
192
+ `DATABASE_LOCK_IDLE_TX_TIMEOUT_MS` (default 30000), `DATABASE_LOCK_TIMEOUT_MS`
193
+ (default 30000). Size pools off
194
+ `N_pods * (DATABASE_POOL_MAX + DATABASE_LOCK_POOL_MAX) <= max_connections`.
195
+
196
+ BREAKING CHANGE: the standalone `withXactLock({ db, key, fn })` export is
197
+ removed - use `coreServices.advisoryLock.withXactLock({ key, fn })` instead.
198
+ `IncidentService`'s constructor now requires an `AdvisoryLockService` as its
199
+ second argument, and the healthcheck `createHealthEntitySerializer` /
200
+ `executeHealthCheckJob` / `setupHealthCheckWorker` helpers take `advisoryLock`
201
+ instead of `db` for the serializer.
202
+
203
+ ### Patch Changes
204
+
205
+ - @checkstack/cache-api@0.3.8
206
+ - @checkstack/queue-api@0.3.8
207
+
3
208
  ## 0.19.0
4
209
 
5
210
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend-api",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -10,18 +10,19 @@
10
10
  "lint:code": "eslint . --max-warnings 0"
11
11
  },
12
12
  "dependencies": {
13
+ "@checkstack/cache-api": "0.3.8",
13
14
  "@checkstack/common": "0.12.0",
14
- "@checkstack/healthcheck-common": "1.3.0",
15
- "@checkstack/cache-api": "0.3.6",
16
- "@checkstack/queue-api": "0.3.6",
15
+ "@checkstack/healthcheck-common": "1.4.0",
16
+ "@checkstack/queue-api": "0.3.8",
17
17
  "@checkstack/signal-common": "0.2.5",
18
- "@orpc/client": "^1.13.14",
19
- "@orpc/contract": "^1.13.14",
20
- "@orpc/openapi": "^1.13.2",
21
- "@orpc/server": "^1.13.2",
22
- "@orpc/zod": "^1.13.2",
18
+ "@checkstack/template-engine": "0.3.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.14",
25
+ "hono": "^4.12.23",
25
26
  "marked": "^17.0.1",
26
27
  "zod": "^4.2.1"
27
28
  },
@@ -33,7 +34,7 @@
33
34
  "pg": "^8.21.0"
34
35
  },
35
36
  "peerDependencies": {
36
- "hono": "^4.12.14",
37
+ "hono": "^4.12.23",
37
38
  "drizzle-orm": "^0.45.0"
38
39
  },
39
40
  "checkstack": {
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Integration test (real Postgres) for the advisory-lock CONNECTION-POOL
3
+ * contract — the behaviour that silently wedged production and that fakes
4
+ * cannot model: a held advisory lock keeps its connection checked out while the
5
+ * gated work runs on a *different* connection, so lock-pool / work-pool sizing
6
+ * decides whether the system makes progress or deadlocks.
7
+ *
8
+ * It pins three things against a live server:
9
+ *
10
+ * 1. REPRODUCE THE BUG: when the lock and its work share ONE pool, concurrency
11
+ * at the pool size deadlocks (every slot is a lock-holder waiting for a
12
+ * work connection that can never free up). This is a guard — if a refactor
13
+ * makes this stop deadlocking, the throughput test below is no longer
14
+ * proving anything.
15
+ * 2. THE FIX: with the lock on a DEDICATED pool, the same (and much higher)
16
+ * concurrency completes with zero failures.
17
+ * 3. CORRECTNESS ACROSS INSTANCES: independent service instances with their
18
+ * OWN pools (simulating N pods on one database) serialize a find-then-
19
+ * create on a shared key down to exactly ONE row — with a no-lock control
20
+ * proving the lock is what enforces it.
21
+ *
22
+ * Gated behind `CHECKSTACK_IT=1`; the integration CI job provides the Postgres
23
+ * service container. Connection from `CHECKSTACK_IT_PG_URL`.
24
+ */
25
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
26
+ import { Pool } from "pg";
27
+ import { createAdvisoryLockService } from "./advisory-lock";
28
+
29
+ const PG_URL =
30
+ process.env.CHECKSTACK_IT_PG_URL ??
31
+ "postgres://postgres:postgres@localhost:5432/postgres";
32
+
33
+ const DEDUP_TABLE = "it_advisory_dedup";
34
+
35
+ describe.skipIf(!process.env.CHECKSTACK_IT)(
36
+ "advisory-lock pool contract (real Postgres)",
37
+ () => {
38
+ /** Pools created during a test; ended in afterEach-style cleanup helpers. */
39
+ const tracked: Pool[] = [];
40
+ function makePool(max: number, connectionTimeoutMillis = 5000): Pool {
41
+ const pool = new Pool({
42
+ connectionString: PG_URL,
43
+ max,
44
+ connectionTimeoutMillis,
45
+ idleTimeoutMillis: 1000,
46
+ });
47
+ // A held-lock client can error asynchronously (timeout / termination);
48
+ // swallow so it never surfaces as an unhandled error and fails the file.
49
+ pool.on("error", () => {});
50
+ tracked.push(pool);
51
+ return pool;
52
+ }
53
+ async function endTrackedPools(): Promise<void> {
54
+ await Promise.all(tracked.splice(0).map((p) => p.end().catch(() => {})));
55
+ }
56
+
57
+ let setupPool: Pool;
58
+ beforeAll(async () => {
59
+ setupPool = new Pool({ connectionString: PG_URL });
60
+ await setupPool.query(
61
+ `CREATE TABLE IF NOT EXISTS ${DEDUP_TABLE} (lock_key text NOT NULL, id text NOT NULL)`,
62
+ );
63
+ });
64
+ afterAll(async () => {
65
+ await setupPool.query(`DROP TABLE IF EXISTS ${DEDUP_TABLE}`);
66
+ await setupPool.end();
67
+ await endTrackedPools();
68
+ });
69
+
70
+ /**
71
+ * Find-then-create on `workPool`: insert exactly once per key. The 15ms gap
72
+ * between the read and the write widens the race window so an UNSERIALIZED
73
+ * run reliably double-inserts — making the lock's effect observable.
74
+ */
75
+ async function dedupCreate(workPool: Pool, key: string): Promise<boolean> {
76
+ const client = await workPool.connect();
77
+ try {
78
+ const { rows } = await client.query(
79
+ `SELECT id FROM ${DEDUP_TABLE} WHERE lock_key = $1 LIMIT 1`,
80
+ [key],
81
+ );
82
+ if (rows.length > 0) return false;
83
+ await new Promise((r) => setTimeout(r, 15));
84
+ await client.query(
85
+ `INSERT INTO ${DEDUP_TABLE} (lock_key, id) VALUES ($1, $2)`,
86
+ [key, crypto.randomUUID()],
87
+ );
88
+ return true;
89
+ } finally {
90
+ client.release();
91
+ }
92
+ }
93
+
94
+ async function countFor(key: string): Promise<number> {
95
+ const { rows } = await setupPool.query<{ n: string }>(
96
+ `SELECT count(*)::text AS n FROM ${DEDUP_TABLE} WHERE lock_key = $1`,
97
+ [key],
98
+ );
99
+ return Number(rows[0]?.n ?? "0");
100
+ }
101
+
102
+ it(
103
+ "REPRODUCES the deadlock when lock + work share one pool (the bug)",
104
+ async () => {
105
+ const POOL_MAX = 4;
106
+ // Single shared pool — the pre-fix wiring. The lock client AND the work
107
+ // client both come from here. Short connect timeout so the deadlock
108
+ // surfaces as a fast rejection rather than a long hang.
109
+ const pool = makePool(POOL_MAX, 1500);
110
+ const svc = createAdvisoryLockService(pool);
111
+ const runId = crypto.randomUUID();
112
+
113
+ // Exactly POOL_MAX concurrent ops, each on a DISTINCT key (so there is
114
+ // NO lock contention — the only thing that can stall is connection
115
+ // accounting). Each holds a lock client, then asks the same pool for a
116
+ // work client that will never come.
117
+ const results = await Promise.allSettled(
118
+ Array.from({ length: POOL_MAX }, (_, i) =>
119
+ svc.withXactLock({
120
+ key: `deadlock:${runId}:${i}`,
121
+ fn: async () => {
122
+ const c = await pool.connect();
123
+ try {
124
+ await c.query("SELECT 1");
125
+ } finally {
126
+ c.release();
127
+ }
128
+ },
129
+ }),
130
+ ),
131
+ );
132
+
133
+ const rejected = results.filter((r) => r.status === "rejected").length;
134
+ // The deadlock manifests as connection-acquire timeouts on the work
135
+ // checkout. If this ever becomes 0, the single-pool design no longer
136
+ // deadlocks and the throughput proof below must be re-examined.
137
+ expect(rejected).toBeGreaterThan(0);
138
+
139
+ await endTrackedPools();
140
+ },
141
+ 30_000,
142
+ );
143
+
144
+ it(
145
+ "does NOT deadlock under high throughput with a dedicated lock pool (the fix)",
146
+ async () => {
147
+ // Deliberately TINY pools so any deadlock would be hit immediately; the
148
+ // fix is that lock and work draw from different pools.
149
+ const lockPool = makePool(4);
150
+ const workPool = makePool(4);
151
+ const svc = createAdvisoryLockService(lockPool);
152
+ const runId = crypto.randomUUID();
153
+
154
+ const CONCURRENCY = 200;
155
+ const results = await Promise.allSettled(
156
+ Array.from({ length: CONCURRENCY }, (_, i) =>
157
+ svc.withXactLock({
158
+ key: `throughput:${runId}:${i}`,
159
+ fn: async () => {
160
+ const c = await workPool.connect();
161
+ try {
162
+ await c.query("SELECT 1");
163
+ } finally {
164
+ c.release();
165
+ }
166
+ },
167
+ }),
168
+ ),
169
+ );
170
+
171
+ const rejected = results.filter((r) => r.status === "rejected");
172
+ // Every single operation must complete: no deadlock, no timeout.
173
+ expect(rejected).toHaveLength(0);
174
+
175
+ await endTrackedPools();
176
+ },
177
+ 30_000,
178
+ );
179
+
180
+ it(
181
+ "serializes find-then-create across INSTANCES to exactly one row",
182
+ async () => {
183
+ // Each "pod" is an independent service instance with its OWN pools, all
184
+ // pointing at the same database — the real multi-instance topology. The
185
+ // advisory lock space is global to the server, so they must serialize.
186
+ const PODS = 6;
187
+ const ATTEMPTS_PER_POD = 5;
188
+ const key = `dedupe:${crypto.randomUUID()}`;
189
+
190
+ const pods = Array.from({ length: PODS }, () => {
191
+ const workPool = makePool(2);
192
+ const svc = createAdvisoryLockService(makePool(2));
193
+ return { workPool, svc };
194
+ });
195
+
196
+ const attempts = pods.flatMap((pod) =>
197
+ Array.from({ length: ATTEMPTS_PER_POD }, () =>
198
+ pod.svc.withXactLock({
199
+ key,
200
+ fn: () => dedupCreate(pod.workPool, key),
201
+ }),
202
+ ),
203
+ );
204
+
205
+ const settled = await Promise.allSettled(attempts);
206
+ const created = settled.filter(
207
+ (r) => r.status === "fulfilled" && r.value === true,
208
+ ).length;
209
+
210
+ // Exactly one attempt created the row; the rest observed it and no-oped.
211
+ expect(await countFor(key)).toBe(1);
212
+ expect(created).toBe(1);
213
+
214
+ await endTrackedPools();
215
+ },
216
+ 30_000,
217
+ );
218
+
219
+ it(
220
+ "a STALLED critical section is reaped by idle_in_transaction_session_timeout, freeing the key",
221
+ async () => {
222
+ // The lock pool sets a short idle-in-transaction timeout. A held lock
223
+ // sits "idle in transaction" for the whole time `fn` runs, so a hung
224
+ // `fn` trips it: Postgres aborts the session, auto-releasing the lock -
225
+ // proving a stall self-heals instead of stranding the key forever.
226
+ const lockPool = new Pool({
227
+ connectionString: PG_URL,
228
+ max: 4,
229
+ connectionTimeoutMillis: 5000,
230
+ idle_in_transaction_session_timeout: 1000,
231
+ });
232
+ lockPool.on("error", () => {});
233
+ tracked.push(lockPool);
234
+ const svc = createAdvisoryLockService(lockPool);
235
+ const key = `stall:${crypto.randomUUID()}`;
236
+
237
+ let releaseHang!: () => void;
238
+ const hang = new Promise<void>((r) => (releaseHang = r));
239
+
240
+ // Holder whose critical section hangs (never issues another query).
241
+ const stalled = svc
242
+ .withXactLock({ key, fn: () => hang })
243
+ .catch(() => "rejected-as-expected");
244
+
245
+ // Wait past the 1s idle timeout so the server reaps the stalled holder.
246
+ await new Promise((r) => setTimeout(r, 1800));
247
+
248
+ // The key must be acquirable again now that the stalled session was
249
+ // aborted server-side.
250
+ const t0 = Date.now();
251
+ const got = await svc.withXactLock({ key, fn: async () => "ok" });
252
+ expect(got).toBe("ok");
253
+ expect(Date.now() - t0).toBeLessThan(3000);
254
+
255
+ releaseHang();
256
+ await stalled; // let the stalled call unwind (COMMIT fails on dead conn)
257
+ await endTrackedPools();
258
+ },
259
+ 30_000,
260
+ );
261
+
262
+ it(
263
+ "CONTROL: the same workload WITHOUT the lock races into duplicates",
264
+ async () => {
265
+ // Proves the lock — not some incidental ordering — is what enforces
266
+ // single-creation above. Same widened-window find-then-create, run
267
+ // concurrently with NO advisory lock, must double-insert.
268
+ const workPool = makePool(8);
269
+ const key = `dedupe-nolock:${crypto.randomUUID()}`;
270
+
271
+ await Promise.all(
272
+ Array.from({ length: 8 }, () => dedupCreate(workPool, key)),
273
+ );
274
+
275
+ expect(await countFor(key)).toBeGreaterThan(1);
276
+
277
+ await endTrackedPools();
278
+ },
279
+ 30_000,
280
+ );
281
+ },
282
+ );