@checkstack/script-packages-frontend 0.2.0 → 0.3.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 +115 -0
- package/package.json +7 -5
- package/src/index.tsx +33 -18
- package/src/pages/SandboxSettingsPage.tsx +393 -0
- package/src/pages/ScriptPackagesSettingsPage.tsx +158 -1
- package/src/pages/sandbox-settings.logic.test.ts +120 -0
- package/src/pages/sandbox-settings.logic.ts +165 -0
- package/src/useSdkTypeInjection.ts +70 -0
- package/tsconfig.json +6 -0
- package/src/components/ScriptPackagesMenuItems.tsx +0 -35
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,120 @@
|
|
|
1
1
|
# @checkstack/script-packages-frontend
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 9dcc848: Cut initial-load JS: lazy plugin contributions, a hardened lazy-by-default contribution contract, on-demand Monaco, and a lighter icon/chart load.
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
- 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.
|
|
11
|
+
- 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.
|
|
12
|
+
- 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.
|
|
13
|
+
|
|
14
|
+
BREAKING CHANGES:
|
|
15
|
+
|
|
16
|
+
- Frontend plugin contract: routes/slot contributions are lazy-by-default (`load` instead of `element`/eager elements) as described above.
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
19
|
+
This is a beta minor.
|
|
20
|
+
|
|
21
|
+
- 9dcc848: Add scheduled vulnerability auditing for Script Packages.
|
|
22
|
+
|
|
23
|
+
A daily recurring job runs `bun audit --json` against the installed script-packages tree, persists advisories to new plugin-owned Postgres tables (`script_package_audit_advisory` keyed by lockfile hash + advisory id, plus a `script_package_audit_state` singleton last-run summary), and notifies every holder of `script-packages.manage` when a new or severity-escalated advisory appears. All severities are recorded; notifications fire on medium/high/critical, with a stable per-advisory key + a durable `notified` flag suppressing repeat-notify on an unchanged set. The pass is single-flight across the cluster via the existing installer advisory lock (mutually exclusive with installs, storage migrations, and blob GC) and reuses the installer's scratch / `.npmrc` / registry setup, reporting purely from the lockfile. New `getAuditState` and `auditNow` RPCs (gated by `script-packages.manage`), a `SCRIPT_PACKAGES_AUDIT_COMPLETED` signal, and a "Vulnerability audit" section in the settings page with an "Audit now" button that live-refreshes on completion.
|
|
24
|
+
|
|
25
|
+
State and scale: audit results are the cluster-wide source of truth in Postgres (not the pod-local node_modules tree), so any pod returns the same advisories regardless of which pod ran the audit.
|
|
26
|
+
|
|
27
|
+
This is a beta minor.
|
|
28
|
+
|
|
29
|
+
- 9dcc848: Layered OS-level script sandbox, secure and fail-closed by default (epic #247).
|
|
30
|
+
|
|
31
|
+
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.
|
|
32
|
+
|
|
33
|
+
Layers:
|
|
34
|
+
|
|
35
|
+
- 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.
|
|
36
|
+
- 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).
|
|
37
|
+
- 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.
|
|
38
|
+
- 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.
|
|
39
|
+
- 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).
|
|
40
|
+
- 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`.
|
|
41
|
+
|
|
42
|
+
Global policy and operator surface:
|
|
43
|
+
|
|
44
|
+
- 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`).
|
|
45
|
+
- 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.
|
|
46
|
+
|
|
47
|
+
BREAKING CHANGES (platform in BETA, shipped as minor):
|
|
48
|
+
|
|
49
|
+
- 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.
|
|
50
|
+
- 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.
|
|
51
|
+
- 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).
|
|
52
|
+
- The shared runners' `run()` no longer accepts a `sandbox` option; callers rely on the global policy provider.
|
|
53
|
+
- 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.
|
|
54
|
+
|
|
55
|
+
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.
|
|
56
|
+
|
|
57
|
+
This is a beta minor.
|
|
58
|
+
|
|
59
|
+
- 9dcc848: Add the auto-generated, version-pinned `@checkstack/sdk` package + codegen, and serve its types live to the in-app editor.
|
|
60
|
+
|
|
61
|
+
- A new committed workspace package `@checkstack/sdk`, generated from the platform's source of truth by `scripts/generate-sdk.ts` (`generate:sdk` / `generate:sdk:check`): a fully-typed oRPC client (`createCheckstackClient`) over the REST surface with one `InferClient` per plugin contract, real script-authoring helpers (`@checkstack/sdk/healthcheck`, `@checkstack/sdk/integration`) whose runtime body is the same identity function the in-app runner injects, per-subpath `.d.ts` under the package `exports` map, and an editor-only ambient bundle. A `generate:sdk:check` CI guard fails when the committed SDK files drift from a fresh generation. The `@checkstack/sdk` version is stamped from `@checkstack/release` and MUST NOT appear in a changeset (a guard enforces this); the `@checkstack/release` bump here advances the release version so the generated SDK can be published later. The generated client also normalizes its base URL without a backtracking-prone regex, closing a CodeQL `js/polynomial-redos` finding.
|
|
62
|
+
- Live editor type injection: a new version-keyed route `GET /api/script-packages/sdk-types/:releaseVersion` (raw handler in `@checkstack/script-packages-backend`) serves the generated SDK editor bundle with `Cache-Control: private, max-age=1y, immutable`; the pure path-build/parse module lives in `@checkstack/script-packages-common`, shared by backend and frontend. A mismatched version returns `409` so the editor refetches and never serves stale types after an upgrade. The frontend `useSdkTypeInjection` hook fetches the bundle once per session and mounts it into Monaco via `addExtraLib`. Schema-narrowed `context.config` / `context.event.payload` editor types stay local; the package-resolving module declarations come from the one published `@checkstack/sdk` source.
|
|
63
|
+
|
|
64
|
+
BREAKING CHANGES: the script-authoring import surface moves from the bare `@checkstack/healthcheck` / `@checkstack/integration` virtual modules to the `@checkstack/sdk/healthcheck` / `@checkstack/sdk/integration` subpaths of the published `@checkstack/sdk` package. The old bare-name imports no longer resolve (an old import now errors in the editor, surfacing the migration). Existing scripts must update the module specifier:
|
|
65
|
+
|
|
66
|
+
- import { defineHealthCheck } from "@checkstack/healthcheck";
|
|
67
|
+
+ import { defineHealthCheck } from "@checkstack/sdk/healthcheck";
|
|
68
|
+
|
|
69
|
+
- import { defineIntegration } from "@checkstack/integration";
|
|
70
|
+
+ import { defineIntegration } from "@checkstack/sdk/integration";
|
|
71
|
+
|
|
72
|
+
The helper names and their runtime behaviour are unchanged - only the module specifier moves. The global (no-import) helper form continues to work unchanged.
|
|
73
|
+
|
|
74
|
+
This is a beta minor.
|
|
75
|
+
|
|
76
|
+
- 9dcc848: Align workspace dependency versions and migrate React Router to v7.
|
|
77
|
+
|
|
78
|
+
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.
|
|
79
|
+
|
|
80
|
+
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`.
|
|
81
|
+
|
|
82
|
+
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).
|
|
83
|
+
|
|
84
|
+
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`.
|
|
85
|
+
|
|
86
|
+
### Patch Changes
|
|
87
|
+
|
|
88
|
+
- 9dcc848: Move primary navigation into a left sidebar, and serve the user guide in-app.
|
|
89
|
+
|
|
90
|
+
Feature navigation (a ~20-item user-menu dropdown) now lives in a persistent left sidebar (a slide-over drawer on mobile), grouped by section with the active route highlighted; the user menu keeps only account actions. A route opts into the sidebar with new `nav` metadata (`{ group, icon, label?, order?, accessRule? }`) on its registration, co-located with path + access + title. The sidebar filters entries with the same access check as page guards. `@checkstack/common` gains `isAccessRuleSatisfied` and a centralized set of in-app doc slugs (`APP_DOC_SLUGS` + `docsPath`, with a test asserting each resolves to a real docs page); `@checkstack/auth-frontend` exports `useAccessRules`.
|
|
91
|
+
|
|
92
|
+
The backend now serves the Astro Starlight docs build same-origin at `/checkstack/*` (the same artifact deployed to GitHub Pages), so the user guide is available inside the app including for self-hosted / air-gapped installs (served verbatim, no rebuild, no link rewriting; from `CHECKSTACK_DOCS_DIST`, before the SPA catch-all, degrading gracefully when absent; the Docker image builds and ships `docs/dist`; Vite proxies `/checkstack` in dev). The "Docs" link is a shell-owned external sidebar entry under the Documentation group (book icon), opening `/checkstack/user-guide/` in a new tab; the group renders even when no plugin route contributes to it.
|
|
93
|
+
|
|
94
|
+
BREAKING (plugin authors): `UserMenuItemsSlot` is no longer the way to add navigation - registering a top user-menu item no longer surfaces it anywhere. Add `nav` to the page's route instead. `UserMenuItemsBottomSlot` (account items) is unchanged. All bundled plugins have been migrated.
|
|
95
|
+
|
|
96
|
+
This is a beta minor.
|
|
97
|
+
|
|
98
|
+
- Updated dependencies [9dcc848]
|
|
99
|
+
- Updated dependencies [9dcc848]
|
|
100
|
+
- Updated dependencies [9dcc848]
|
|
101
|
+
- Updated dependencies [9dcc848]
|
|
102
|
+
- Updated dependencies [9dcc848]
|
|
103
|
+
- Updated dependencies [9dcc848]
|
|
104
|
+
- Updated dependencies [9dcc848]
|
|
105
|
+
- Updated dependencies [9dcc848]
|
|
106
|
+
- Updated dependencies [9dcc848]
|
|
107
|
+
- Updated dependencies [9dcc848]
|
|
108
|
+
- Updated dependencies [9dcc848]
|
|
109
|
+
- Updated dependencies [9dcc848]
|
|
110
|
+
- Updated dependencies [9dcc848]
|
|
111
|
+
- @checkstack/ui@1.13.0
|
|
112
|
+
- @checkstack/common@0.13.0
|
|
113
|
+
- @checkstack/script-packages-common@0.3.0
|
|
114
|
+
- @checkstack/frontend-api@0.7.0
|
|
115
|
+
- @checkstack/signal-frontend@0.2.0
|
|
116
|
+
- @checkstack/sdk@0.93.1
|
|
117
|
+
|
|
3
118
|
## 0.2.0
|
|
4
119
|
|
|
5
120
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/script-packages-frontend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -24,11 +24,13 @@
|
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@checkstack/common": "0.12.0",
|
|
26
26
|
"@checkstack/frontend-api": "0.6.0",
|
|
27
|
-
"@checkstack/
|
|
28
|
-
"@checkstack/
|
|
29
|
-
"
|
|
27
|
+
"@checkstack/sdk": "0.93.0",
|
|
28
|
+
"@checkstack/script-packages-common": "0.2.0",
|
|
29
|
+
"@checkstack/signal-frontend": "0.1.5",
|
|
30
|
+
"@checkstack/ui": "1.12.0",
|
|
31
|
+
"lucide-react": "^1.17.0",
|
|
30
32
|
"react": "^18.3.1",
|
|
31
|
-
"react-router-dom": "^
|
|
33
|
+
"react-router-dom": "^7.16.0"
|
|
32
34
|
},
|
|
33
35
|
"devDependencies": {
|
|
34
36
|
"@types/react": "^18.3.18",
|
package/src/index.tsx
CHANGED
|
@@ -1,22 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createFrontendPlugin,
|
|
3
|
-
createSlotExtension,
|
|
4
|
-
UserMenuItemsSlot,
|
|
5
|
-
} from "@checkstack/frontend-api";
|
|
1
|
+
import { createFrontendPlugin } from "@checkstack/frontend-api";
|
|
6
2
|
import {
|
|
7
3
|
scriptPackagesRoutes,
|
|
8
4
|
scriptPackagesAccess,
|
|
5
|
+
scriptSandboxAccess,
|
|
9
6
|
pluginMetadata,
|
|
10
7
|
} from "@checkstack/script-packages-common";
|
|
11
|
-
import {
|
|
12
|
-
import { ScriptPackagesMenuItems } from "./components/ScriptPackagesMenuItems";
|
|
8
|
+
import { Package, ShieldCheck } from "lucide-react";
|
|
13
9
|
|
|
14
10
|
/**
|
|
15
11
|
* Frontend plugin for script-package management.
|
|
16
12
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
13
|
+
* Routes:
|
|
14
|
+
* - `/script-packages/` -> the admin settings page (allowlist,
|
|
15
|
+
* registry/storage summary, install state + size, satellite sync). Gated
|
|
16
|
+
* on `script-packages.manage`.
|
|
17
|
+
* - `/script-packages/sandbox` -> the global script-sandbox policy editor,
|
|
18
|
+
* gated on the dedicated admin `script-sandbox.manage` permission.
|
|
20
19
|
*
|
|
21
20
|
* The `useScriptPackageTypeAcquisition()` hook (exported below) gives editor
|
|
22
21
|
* pages a lazy ATA resolver + install reset-key to pass to `DynamicForm`'s
|
|
@@ -28,18 +27,34 @@ export default createFrontendPlugin({
|
|
|
28
27
|
routes: [
|
|
29
28
|
{
|
|
30
29
|
route: scriptPackagesRoutes.routes.settings,
|
|
31
|
-
|
|
30
|
+
load: () =>
|
|
31
|
+
import("./pages/ScriptPackagesSettingsPage").then((m) => ({
|
|
32
|
+
default: m.ScriptPackagesSettingsPage,
|
|
33
|
+
})),
|
|
32
34
|
title: "Script packages",
|
|
33
35
|
accessRule: scriptPackagesAccess.manage,
|
|
36
|
+
nav: {
|
|
37
|
+
group: "Configuration",
|
|
38
|
+
icon: Package,
|
|
39
|
+
label: "Script Packages",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
route: scriptPackagesRoutes.routes.sandbox,
|
|
44
|
+
load: () =>
|
|
45
|
+
import("./pages/SandboxSettingsPage").then((m) => ({
|
|
46
|
+
default: m.SandboxSettingsPage,
|
|
47
|
+
})),
|
|
48
|
+
title: "Script sandbox",
|
|
49
|
+
accessRule: scriptSandboxAccess.manage,
|
|
50
|
+
nav: {
|
|
51
|
+
group: "Configuration",
|
|
52
|
+
icon: ShieldCheck,
|
|
53
|
+
label: "Script Sandbox",
|
|
54
|
+
},
|
|
34
55
|
},
|
|
35
|
-
],
|
|
36
|
-
extensions: [
|
|
37
|
-
createSlotExtension(UserMenuItemsSlot, {
|
|
38
|
-
id: "script-packages.user-menu.items",
|
|
39
|
-
component: ScriptPackagesMenuItems,
|
|
40
|
-
metadata: { group: "Configuration" },
|
|
41
|
-
}),
|
|
42
56
|
],
|
|
43
57
|
});
|
|
44
58
|
|
|
45
59
|
export { useScriptPackageTypeAcquisition } from "./useScriptPackageTypeAcquisition";
|
|
60
|
+
export { useSdkTypeInjection } from "./useSdkTypeInjection";
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ShieldCheck } from "lucide-react";
|
|
3
|
+
import {
|
|
4
|
+
usePluginClient,
|
|
5
|
+
accessApiRef,
|
|
6
|
+
useApi,
|
|
7
|
+
wrapInSuspense,
|
|
8
|
+
} from "@checkstack/frontend-api";
|
|
9
|
+
import {
|
|
10
|
+
ScriptPackagesApi,
|
|
11
|
+
scriptSandboxAccess,
|
|
12
|
+
} from "@checkstack/script-packages-common";
|
|
13
|
+
import {
|
|
14
|
+
PageLayout,
|
|
15
|
+
Card,
|
|
16
|
+
CardHeader,
|
|
17
|
+
CardTitle,
|
|
18
|
+
CardContent,
|
|
19
|
+
Button,
|
|
20
|
+
Input,
|
|
21
|
+
Label,
|
|
22
|
+
Textarea,
|
|
23
|
+
Toggle,
|
|
24
|
+
Alert,
|
|
25
|
+
AlertTitle,
|
|
26
|
+
AlertDescription,
|
|
27
|
+
AccessDenied,
|
|
28
|
+
LoadingSpinner,
|
|
29
|
+
Select,
|
|
30
|
+
SelectTrigger,
|
|
31
|
+
SelectValue,
|
|
32
|
+
SelectContent,
|
|
33
|
+
SelectItem,
|
|
34
|
+
useInitOnceForKey,
|
|
35
|
+
} from "@checkstack/ui";
|
|
36
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
37
|
+
import {
|
|
38
|
+
policyToForm,
|
|
39
|
+
formToPolicyInput,
|
|
40
|
+
validateForm,
|
|
41
|
+
DEFAULT_SANDBOX_FORM,
|
|
42
|
+
type SandboxFormState,
|
|
43
|
+
} from "./sandbox-settings.logic";
|
|
44
|
+
|
|
45
|
+
// Fail-closed seed default for a fresh global policy: the editor opens on the
|
|
46
|
+
// secure default (`onUnavailable: "fail"`) until the loader query resolves the
|
|
47
|
+
// durable policy and re-seeds the form. The backend resolves the SAME secure
|
|
48
|
+
// default for an empty settings table, so on a fresh install there is no flash.
|
|
49
|
+
const SEED_FORM = DEFAULT_SANDBOX_FORM;
|
|
50
|
+
|
|
51
|
+
const SettingsContent: React.FC = () => {
|
|
52
|
+
const client = usePluginClient(ScriptPackagesApi);
|
|
53
|
+
const accessApi = useApi(accessApiRef);
|
|
54
|
+
const { allowed, loading: accessLoading } = accessApi.useAccess(
|
|
55
|
+
scriptSandboxAccess.manage,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// gcTime: 0 so stale-while-revalidate never races the one-shot init below.
|
|
59
|
+
const policyQuery = client.getSandboxPolicy.useQuery(undefined, {
|
|
60
|
+
gcTime: 0,
|
|
61
|
+
});
|
|
62
|
+
const setMutation = client.setSandboxPolicy.useMutation();
|
|
63
|
+
|
|
64
|
+
const [form, setForm] = React.useState<SandboxFormState>(SEED_FORM);
|
|
65
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
66
|
+
const [saved, setSaved] = React.useState(false);
|
|
67
|
+
|
|
68
|
+
// Seed the editable form once per persisted version. The mutation invalidates
|
|
69
|
+
// the query on success → a fresh object identity re-seeds the form.
|
|
70
|
+
useInitOnceForKey(
|
|
71
|
+
policyQuery.data,
|
|
72
|
+
policyQuery.dataUpdatedAt,
|
|
73
|
+
(policy) => {
|
|
74
|
+
setForm(policyToForm(policy));
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (accessLoading) return <LoadingSpinner />;
|
|
79
|
+
if (!allowed) {
|
|
80
|
+
return (
|
|
81
|
+
<PageLayout title="Script sandbox" icon={ShieldCheck}>
|
|
82
|
+
<AccessDenied />
|
|
83
|
+
</PageLayout>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Wait for the durable policy before showing the editor so a stored custom
|
|
88
|
+
// policy never flashes the fail-closed SEED_FORM. `form` itself is never null
|
|
89
|
+
// (it opens on the secure SEED_FORM and is re-seeded once the query resolves).
|
|
90
|
+
if (policyQuery.isLoading) return <LoadingSpinner />;
|
|
91
|
+
|
|
92
|
+
const patch = (next: Partial<SandboxFormState>) => {
|
|
93
|
+
setSaved(false);
|
|
94
|
+
setForm((cur) => ({ ...cur, ...next }));
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const validationError = validateForm(form);
|
|
98
|
+
|
|
99
|
+
const handleSave = async () => {
|
|
100
|
+
setError(null);
|
|
101
|
+
setSaved(false);
|
|
102
|
+
const problem = validateForm(form);
|
|
103
|
+
if (problem) {
|
|
104
|
+
setError(problem);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
await setMutation.mutateAsync(formToPolicyInput(form));
|
|
109
|
+
setSaved(true);
|
|
110
|
+
} catch (error_) {
|
|
111
|
+
setError(extractErrorMessage(error_));
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<PageLayout title="Script sandbox" icon={ShieldCheck}>
|
|
117
|
+
<div className="space-y-6">
|
|
118
|
+
{error && (
|
|
119
|
+
<Alert variant="error">
|
|
120
|
+
<AlertTitle>Something went wrong</AlertTitle>
|
|
121
|
+
<AlertDescription>{error}</AlertDescription>
|
|
122
|
+
</Alert>
|
|
123
|
+
)}
|
|
124
|
+
{saved && (
|
|
125
|
+
<Alert variant="success">
|
|
126
|
+
<AlertTitle>Saved</AlertTitle>
|
|
127
|
+
<AlertDescription>
|
|
128
|
+
The global script-sandbox policy was updated. It applies to every
|
|
129
|
+
script run cluster-wide, and connected satellites receive it
|
|
130
|
+
immediately.
|
|
131
|
+
</AlertDescription>
|
|
132
|
+
</Alert>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
<Card>
|
|
136
|
+
<CardHeader>
|
|
137
|
+
<CardTitle className="text-base">Global policy</CardTitle>
|
|
138
|
+
</CardHeader>
|
|
139
|
+
<CardContent className="space-y-4 text-sm">
|
|
140
|
+
<p className="text-xs text-muted-foreground">
|
|
141
|
+
This single policy hardens every user-authored script and shell
|
|
142
|
+
run (health checks and automation actions) across all pods and
|
|
143
|
+
satellites. It is secure by default: egress is denied until you
|
|
144
|
+
add allow-list entries.
|
|
145
|
+
</p>
|
|
146
|
+
<div className="flex items-center gap-2">
|
|
147
|
+
<Toggle
|
|
148
|
+
checked={form.enabled}
|
|
149
|
+
onCheckedChange={(enabled) => patch({ enabled })}
|
|
150
|
+
aria-label="Sandbox enabled"
|
|
151
|
+
/>
|
|
152
|
+
<span className="text-muted-foreground">
|
|
153
|
+
Sandbox enabled (turn off to run scripts unsandboxed - not
|
|
154
|
+
recommended)
|
|
155
|
+
</span>
|
|
156
|
+
</div>
|
|
157
|
+
<div className="w-64">
|
|
158
|
+
<Label htmlFor="on-unavailable">
|
|
159
|
+
When a layer can't be enforced
|
|
160
|
+
</Label>
|
|
161
|
+
<Select
|
|
162
|
+
value={form.onUnavailable}
|
|
163
|
+
onValueChange={(v) =>
|
|
164
|
+
patch({ onUnavailable: v as SandboxFormState["onUnavailable"] })
|
|
165
|
+
}
|
|
166
|
+
>
|
|
167
|
+
<SelectTrigger id="on-unavailable">
|
|
168
|
+
<SelectValue />
|
|
169
|
+
</SelectTrigger>
|
|
170
|
+
<SelectContent>
|
|
171
|
+
<SelectItem value="degrade">
|
|
172
|
+
Degrade (drop to portable subset, surface it)
|
|
173
|
+
</SelectItem>
|
|
174
|
+
<SelectItem value="fail">
|
|
175
|
+
Fail (refuse to run the script)
|
|
176
|
+
</SelectItem>
|
|
177
|
+
</SelectContent>
|
|
178
|
+
</Select>
|
|
179
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
180
|
+
Fail is the secure default: a script never runs with a layer
|
|
181
|
+
missing. Choose Degrade only for hosts that cannot enforce a
|
|
182
|
+
layer (then runs proceed under the portable subset, with the gap
|
|
183
|
+
surfaced per run).
|
|
184
|
+
</p>
|
|
185
|
+
</div>
|
|
186
|
+
</CardContent>
|
|
187
|
+
</Card>
|
|
188
|
+
|
|
189
|
+
<Card>
|
|
190
|
+
<CardHeader>
|
|
191
|
+
<CardTitle className="text-base">Network egress</CardTitle>
|
|
192
|
+
</CardHeader>
|
|
193
|
+
<CardContent className="space-y-4 text-sm">
|
|
194
|
+
<div className="w-64">
|
|
195
|
+
<Label htmlFor="network-mode">Mode</Label>
|
|
196
|
+
<Select
|
|
197
|
+
value={form.networkMode}
|
|
198
|
+
onValueChange={(v) =>
|
|
199
|
+
patch({ networkMode: v as SandboxFormState["networkMode"] })
|
|
200
|
+
}
|
|
201
|
+
>
|
|
202
|
+
<SelectTrigger id="network-mode">
|
|
203
|
+
<SelectValue />
|
|
204
|
+
</SelectTrigger>
|
|
205
|
+
<SelectContent>
|
|
206
|
+
<SelectItem value="deny">Deny (no egress)</SelectItem>
|
|
207
|
+
<SelectItem value="allowlist">
|
|
208
|
+
Allowlist (only listed destinations)
|
|
209
|
+
</SelectItem>
|
|
210
|
+
<SelectItem value="unrestricted">Unrestricted</SelectItem>
|
|
211
|
+
</SelectContent>
|
|
212
|
+
</Select>
|
|
213
|
+
</div>
|
|
214
|
+
{form.networkMode === "allowlist" && (
|
|
215
|
+
<div>
|
|
216
|
+
<Label htmlFor="allow-list">
|
|
217
|
+
Allowed destinations (one IP / CIDR per line)
|
|
218
|
+
</Label>
|
|
219
|
+
<Textarea
|
|
220
|
+
id="allow-list"
|
|
221
|
+
value={form.allowText}
|
|
222
|
+
onChange={(e) => patch({ allowText: e.target.value })}
|
|
223
|
+
placeholder={"203.0.113.0/24\n10.0.0.5"}
|
|
224
|
+
className="font-mono min-h-24"
|
|
225
|
+
/>
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
<div className="flex items-center gap-2">
|
|
229
|
+
<Toggle
|
|
230
|
+
checked={form.denyLinkLocalAndMetadata}
|
|
231
|
+
onCheckedChange={(denyLinkLocalAndMetadata) =>
|
|
232
|
+
patch({ denyLinkLocalAndMetadata })
|
|
233
|
+
}
|
|
234
|
+
aria-label="Block link-local and metadata IPs"
|
|
235
|
+
/>
|
|
236
|
+
<span className="text-muted-foreground">
|
|
237
|
+
Always block link-local (169.254/16, fc00::/7) and cloud
|
|
238
|
+
metadata IPs
|
|
239
|
+
</span>
|
|
240
|
+
</div>
|
|
241
|
+
</CardContent>
|
|
242
|
+
</Card>
|
|
243
|
+
|
|
244
|
+
<Card>
|
|
245
|
+
<CardHeader>
|
|
246
|
+
<CardTitle className="text-base">Filesystem & privilege</CardTitle>
|
|
247
|
+
</CardHeader>
|
|
248
|
+
<CardContent className="space-y-4 text-sm">
|
|
249
|
+
<div className="w-72">
|
|
250
|
+
<Label htmlFor="fs-mode">Filesystem confinement</Label>
|
|
251
|
+
<Select
|
|
252
|
+
value={form.filesystemMode}
|
|
253
|
+
onValueChange={(v) =>
|
|
254
|
+
patch({
|
|
255
|
+
filesystemMode: v as SandboxFormState["filesystemMode"],
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
>
|
|
259
|
+
<SelectTrigger id="fs-mode">
|
|
260
|
+
<SelectValue />
|
|
261
|
+
</SelectTrigger>
|
|
262
|
+
<SelectContent>
|
|
263
|
+
<SelectItem value="off">Off (full host filesystem)</SelectItem>
|
|
264
|
+
<SelectItem value="scratch-only">
|
|
265
|
+
Scratch only (per-run dir + minimal read-only base)
|
|
266
|
+
</SelectItem>
|
|
267
|
+
<SelectItem value="scratch-plus-ro">
|
|
268
|
+
Scratch + read-only managed packages
|
|
269
|
+
</SelectItem>
|
|
270
|
+
</SelectContent>
|
|
271
|
+
</Select>
|
|
272
|
+
</div>
|
|
273
|
+
<div className="w-72">
|
|
274
|
+
<Label htmlFor="priv-mode">Privilege</Label>
|
|
275
|
+
<Select
|
|
276
|
+
value={form.privilegeMode}
|
|
277
|
+
onValueChange={(v) =>
|
|
278
|
+
patch({
|
|
279
|
+
privilegeMode: v as SandboxFormState["privilegeMode"],
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
>
|
|
283
|
+
<SelectTrigger id="priv-mode">
|
|
284
|
+
<SelectValue />
|
|
285
|
+
</SelectTrigger>
|
|
286
|
+
<SelectContent>
|
|
287
|
+
<SelectItem value="inherit">
|
|
288
|
+
Inherit (run as host process UID)
|
|
289
|
+
</SelectItem>
|
|
290
|
+
<SelectItem value="drop-to-uid">
|
|
291
|
+
Drop to dedicated low-privilege UID/GID
|
|
292
|
+
</SelectItem>
|
|
293
|
+
</SelectContent>
|
|
294
|
+
</Select>
|
|
295
|
+
</div>
|
|
296
|
+
</CardContent>
|
|
297
|
+
</Card>
|
|
298
|
+
|
|
299
|
+
<Card>
|
|
300
|
+
<CardHeader>
|
|
301
|
+
<CardTitle className="text-base">Resource caps</CardTitle>
|
|
302
|
+
</CardHeader>
|
|
303
|
+
<CardContent className="space-y-3 text-sm">
|
|
304
|
+
<p className="text-xs text-muted-foreground">
|
|
305
|
+
Leave a field blank to not cap that dimension. Memory / output /
|
|
306
|
+
file-size are in MB.
|
|
307
|
+
</p>
|
|
308
|
+
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
|
309
|
+
<div>
|
|
310
|
+
<Label htmlFor="cpu">CPU seconds</Label>
|
|
311
|
+
<Input
|
|
312
|
+
id="cpu"
|
|
313
|
+
type="number"
|
|
314
|
+
min={1}
|
|
315
|
+
value={form.cpuSeconds}
|
|
316
|
+
onChange={(e) => patch({ cpuSeconds: e.target.value })}
|
|
317
|
+
/>
|
|
318
|
+
</div>
|
|
319
|
+
<div>
|
|
320
|
+
<Label htmlFor="mem">Memory (MB)</Label>
|
|
321
|
+
<Input
|
|
322
|
+
id="mem"
|
|
323
|
+
type="number"
|
|
324
|
+
min={1}
|
|
325
|
+
value={form.memoryMb}
|
|
326
|
+
onChange={(e) => patch({ memoryMb: e.target.value })}
|
|
327
|
+
/>
|
|
328
|
+
</div>
|
|
329
|
+
<div>
|
|
330
|
+
<Label htmlFor="nofile">Max open files</Label>
|
|
331
|
+
<Input
|
|
332
|
+
id="nofile"
|
|
333
|
+
type="number"
|
|
334
|
+
min={1}
|
|
335
|
+
value={form.maxOpenFiles}
|
|
336
|
+
onChange={(e) => patch({ maxOpenFiles: e.target.value })}
|
|
337
|
+
/>
|
|
338
|
+
</div>
|
|
339
|
+
<div>
|
|
340
|
+
<Label htmlFor="nproc">Max processes</Label>
|
|
341
|
+
<Input
|
|
342
|
+
id="nproc"
|
|
343
|
+
type="number"
|
|
344
|
+
min={1}
|
|
345
|
+
value={form.maxProcesses}
|
|
346
|
+
onChange={(e) => patch({ maxProcesses: e.target.value })}
|
|
347
|
+
/>
|
|
348
|
+
</div>
|
|
349
|
+
<div>
|
|
350
|
+
<Label htmlFor="out">Max output (MB)</Label>
|
|
351
|
+
<Input
|
|
352
|
+
id="out"
|
|
353
|
+
type="number"
|
|
354
|
+
min={1}
|
|
355
|
+
value={form.maxOutputMb}
|
|
356
|
+
onChange={(e) => patch({ maxOutputMb: e.target.value })}
|
|
357
|
+
/>
|
|
358
|
+
</div>
|
|
359
|
+
<div>
|
|
360
|
+
<Label htmlFor="fsize">Max file size (MB)</Label>
|
|
361
|
+
<Input
|
|
362
|
+
id="fsize"
|
|
363
|
+
type="number"
|
|
364
|
+
min={1}
|
|
365
|
+
value={form.maxFileSizeMb}
|
|
366
|
+
onChange={(e) => patch({ maxFileSizeMb: e.target.value })}
|
|
367
|
+
/>
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
</CardContent>
|
|
371
|
+
</Card>
|
|
372
|
+
|
|
373
|
+
{validationError && (
|
|
374
|
+
<p className="text-xs text-destructive">{validationError}</p>
|
|
375
|
+
)}
|
|
376
|
+
<Button
|
|
377
|
+
type="button"
|
|
378
|
+
onClick={handleSave}
|
|
379
|
+
disabled={setMutation.isPending || validationError !== null}
|
|
380
|
+
>
|
|
381
|
+
{setMutation.isPending ? "Saving…" : "Save policy"}
|
|
382
|
+
</Button>
|
|
383
|
+
</div>
|
|
384
|
+
</PageLayout>
|
|
385
|
+
);
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Admin Settings -> Script Sandbox page. Edits the single, cluster-wide global
|
|
390
|
+
* script-sandbox policy. Gated by the dedicated `script-sandbox.manage`
|
|
391
|
+
* permission (distinct from `script-packages.manage`).
|
|
392
|
+
*/
|
|
393
|
+
export const SandboxSettingsPage = wrapInSuspense(SettingsContent);
|
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Package,
|
|
4
|
+
Trash2,
|
|
5
|
+
Download,
|
|
6
|
+
RefreshCw,
|
|
7
|
+
Recycle,
|
|
8
|
+
ShieldCheck,
|
|
9
|
+
ShieldAlert,
|
|
10
|
+
} from "lucide-react";
|
|
3
11
|
import {
|
|
4
12
|
usePluginClient,
|
|
5
13
|
accessApiRef,
|
|
6
14
|
useApi,
|
|
7
15
|
wrapInSuspense,
|
|
8
16
|
} from "@checkstack/frontend-api";
|
|
17
|
+
import { useSignal } from "@checkstack/signal-frontend";
|
|
9
18
|
import {
|
|
10
19
|
ScriptPackagesApi,
|
|
11
20
|
scriptPackagesAccess,
|
|
12
21
|
PackageVersionSchema,
|
|
22
|
+
SCRIPT_PACKAGES_AUDIT_COMPLETED_SIGNAL,
|
|
23
|
+
type AuditSeverity,
|
|
13
24
|
} from "@checkstack/script-packages-common";
|
|
14
25
|
import { PackageNameCombobox } from "../components/PackageNameCombobox";
|
|
15
26
|
import { PackageVersionCombobox } from "../components/PackageVersionCombobox";
|
|
@@ -58,6 +69,15 @@ function mbToBytes(value: number): number {
|
|
|
58
69
|
return Math.round(value * 1024 * 1024);
|
|
59
70
|
}
|
|
60
71
|
|
|
72
|
+
/** Badge variant for an advisory severity. */
|
|
73
|
+
function severityVariant(
|
|
74
|
+
severity: AuditSeverity,
|
|
75
|
+
): "destructive" | "secondary" {
|
|
76
|
+
return severity === "critical" || severity === "high"
|
|
77
|
+
? "destructive"
|
|
78
|
+
: "secondary";
|
|
79
|
+
}
|
|
80
|
+
|
|
61
81
|
const SettingsContent: React.FC = () => {
|
|
62
82
|
const client = usePluginClient(ScriptPackagesApi);
|
|
63
83
|
const accessApi = useApi(accessApiRef);
|
|
@@ -85,6 +105,13 @@ const SettingsContent: React.FC = () => {
|
|
|
85
105
|
const backendsQuery = client.listStorageBackends.useQuery();
|
|
86
106
|
const satellitesQuery = client.listSatelliteSyncState.useQuery();
|
|
87
107
|
const blobGcQuery = client.getBlobGcState.useQuery();
|
|
108
|
+
const auditQuery = client.getAuditState.useQuery();
|
|
109
|
+
|
|
110
|
+
// Live-refresh the audit findings when a scheduled or on-demand pass
|
|
111
|
+
// completes (the runner broadcasts on completion).
|
|
112
|
+
useSignal(SCRIPT_PACKAGES_AUDIT_COMPLETED_SIGNAL, () => {
|
|
113
|
+
void auditQuery.refetch();
|
|
114
|
+
});
|
|
88
115
|
|
|
89
116
|
const addMutation = client.addPackage.useMutation();
|
|
90
117
|
const removeMutation = client.removePackage.useMutation();
|
|
@@ -95,6 +122,7 @@ const SettingsContent: React.FC = () => {
|
|
|
95
122
|
const setRegistryMutation = client.setRegistryConfig.useMutation();
|
|
96
123
|
const setSizeCapMutation = client.setSizeCapConfig.useMutation();
|
|
97
124
|
const setStorageBackendMutation = client.setStorageBackend.useMutation();
|
|
125
|
+
const auditMutation = client.auditNow.useMutation();
|
|
98
126
|
|
|
99
127
|
const { isLowPower } = usePerformance();
|
|
100
128
|
const [name, setName] = React.useState("");
|
|
@@ -198,6 +226,16 @@ const SettingsContent: React.FC = () => {
|
|
|
198
226
|
}
|
|
199
227
|
};
|
|
200
228
|
|
|
229
|
+
const handleAudit = async () => {
|
|
230
|
+
setError(null);
|
|
231
|
+
try {
|
|
232
|
+
const res = await auditMutation.mutateAsync({});
|
|
233
|
+
if (!res.ran && res.reason) setError(res.reason);
|
|
234
|
+
} catch (error_) {
|
|
235
|
+
setError(extractErrorMessage(error_));
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
201
239
|
const handleSaveRegistry = async () => {
|
|
202
240
|
setError(null);
|
|
203
241
|
try {
|
|
@@ -278,6 +316,9 @@ const SettingsContent: React.FC = () => {
|
|
|
278
316
|
: false;
|
|
279
317
|
|
|
280
318
|
const blobGc = blobGcQuery.data;
|
|
319
|
+
const audit = auditQuery.data;
|
|
320
|
+
const auditAdvisories = audit?.advisories ?? [];
|
|
321
|
+
const auditState = audit?.state;
|
|
281
322
|
const availableBackends = backendsQuery.data?.backends ?? [];
|
|
282
323
|
const migrating = storage?.migrationStatus === "migrating";
|
|
283
324
|
const migrationTargets = availableBackends.filter(
|
|
@@ -331,6 +372,122 @@ const SettingsContent: React.FC = () => {
|
|
|
331
372
|
</CardContent>
|
|
332
373
|
</Card>
|
|
333
374
|
|
|
375
|
+
{/* Vulnerability audit */}
|
|
376
|
+
<Card>
|
|
377
|
+
<CardHeader className="flex flex-row items-center justify-between">
|
|
378
|
+
<CardTitle className="text-base flex items-center gap-2">
|
|
379
|
+
{auditAdvisories.length > 0 ? (
|
|
380
|
+
<ShieldAlert className="h-4 w-4 text-destructive" />
|
|
381
|
+
) : (
|
|
382
|
+
<ShieldCheck className="h-4 w-4 text-emerald-600" />
|
|
383
|
+
)}
|
|
384
|
+
Vulnerability audit
|
|
385
|
+
{auditAdvisories.length > 0 && (
|
|
386
|
+
<Badge variant="destructive">{auditAdvisories.length}</Badge>
|
|
387
|
+
)}
|
|
388
|
+
</CardTitle>
|
|
389
|
+
<Button
|
|
390
|
+
type="button"
|
|
391
|
+
size="sm"
|
|
392
|
+
variant="outline"
|
|
393
|
+
onClick={handleAudit}
|
|
394
|
+
disabled={auditMutation.isPending || migrating}
|
|
395
|
+
>
|
|
396
|
+
<RefreshCw
|
|
397
|
+
className={cn(
|
|
398
|
+
"h-4 w-4",
|
|
399
|
+
auditMutation.isPending && !isLowPower && "animate-spin",
|
|
400
|
+
)}
|
|
401
|
+
/>
|
|
402
|
+
{auditMutation.isPending ? "Auditing…" : "Audit now"}
|
|
403
|
+
</Button>
|
|
404
|
+
</CardHeader>
|
|
405
|
+
<CardContent className="space-y-3 text-sm">
|
|
406
|
+
<p className="text-xs text-muted-foreground">
|
|
407
|
+
Runs <code>bun audit</code> against the installed package tree once
|
|
408
|
+
a day and notifies managers when a new vulnerability appears.
|
|
409
|
+
Findings below cover every severity.
|
|
410
|
+
</p>
|
|
411
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
412
|
+
<span className="text-muted-foreground">Last run:</span>
|
|
413
|
+
<Badge variant="secondary">
|
|
414
|
+
{auditState?.lastRunAt
|
|
415
|
+
? new Date(auditState.lastRunAt).toLocaleString()
|
|
416
|
+
: "never"}
|
|
417
|
+
</Badge>
|
|
418
|
+
{auditState && auditState.total > 0 && (
|
|
419
|
+
<>
|
|
420
|
+
{auditState.counts.critical > 0 && (
|
|
421
|
+
<Badge variant="destructive">
|
|
422
|
+
{auditState.counts.critical} critical
|
|
423
|
+
</Badge>
|
|
424
|
+
)}
|
|
425
|
+
{auditState.counts.high > 0 && (
|
|
426
|
+
<Badge variant="destructive">
|
|
427
|
+
{auditState.counts.high} high
|
|
428
|
+
</Badge>
|
|
429
|
+
)}
|
|
430
|
+
{auditState.counts.moderate > 0 && (
|
|
431
|
+
<Badge variant="secondary">
|
|
432
|
+
{auditState.counts.moderate} moderate
|
|
433
|
+
</Badge>
|
|
434
|
+
)}
|
|
435
|
+
{auditState.counts.low > 0 && (
|
|
436
|
+
<Badge variant="secondary">
|
|
437
|
+
{auditState.counts.low} low
|
|
438
|
+
</Badge>
|
|
439
|
+
)}
|
|
440
|
+
</>
|
|
441
|
+
)}
|
|
442
|
+
</div>
|
|
443
|
+
{auditState?.errorMessage && (
|
|
444
|
+
<p className="text-destructive text-xs">
|
|
445
|
+
Last audit failed: {auditState.errorMessage}
|
|
446
|
+
</p>
|
|
447
|
+
)}
|
|
448
|
+
{auditAdvisories.length === 0 ? (
|
|
449
|
+
<p className="text-sm text-muted-foreground italic">
|
|
450
|
+
No known vulnerabilities in the installed tree.
|
|
451
|
+
</p>
|
|
452
|
+
) : (
|
|
453
|
+
<ul className="divide-y divide-border rounded-md border border-border">
|
|
454
|
+
{auditAdvisories.map((a) => (
|
|
455
|
+
<li
|
|
456
|
+
key={`${a.packageName} ${a.advisoryId}`}
|
|
457
|
+
className="flex items-center justify-between gap-3 px-3 py-2"
|
|
458
|
+
>
|
|
459
|
+
<span className="flex flex-col gap-0.5 min-w-0">
|
|
460
|
+
<span className="font-mono text-sm truncate">
|
|
461
|
+
{a.packageName}{" "}
|
|
462
|
+
<span className="text-muted-foreground">
|
|
463
|
+
{a.vulnerableVersions}
|
|
464
|
+
</span>
|
|
465
|
+
</span>
|
|
466
|
+
<span className="text-xs text-muted-foreground truncate">
|
|
467
|
+
{a.url ? (
|
|
468
|
+
<a
|
|
469
|
+
href={a.url}
|
|
470
|
+
target="_blank"
|
|
471
|
+
rel="noreferrer"
|
|
472
|
+
className="hover:underline"
|
|
473
|
+
>
|
|
474
|
+
{a.title || a.advisoryId}
|
|
475
|
+
</a>
|
|
476
|
+
) : (
|
|
477
|
+
(a.title || a.advisoryId)
|
|
478
|
+
)}
|
|
479
|
+
</span>
|
|
480
|
+
</span>
|
|
481
|
+
<Badge variant={severityVariant(a.severity)}>
|
|
482
|
+
{a.severity}
|
|
483
|
+
</Badge>
|
|
484
|
+
</li>
|
|
485
|
+
))}
|
|
486
|
+
</ul>
|
|
487
|
+
)}
|
|
488
|
+
</CardContent>
|
|
489
|
+
</Card>
|
|
490
|
+
|
|
334
491
|
{/* Allowlist */}
|
|
335
492
|
<Card>
|
|
336
493
|
<CardHeader>
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { sandboxPolicySchema } from "@checkstack/common";
|
|
3
|
+
import {
|
|
4
|
+
policyToForm,
|
|
5
|
+
formToPolicyInput,
|
|
6
|
+
parseAllowList,
|
|
7
|
+
validateForm,
|
|
8
|
+
DEFAULT_SANDBOX_FORM,
|
|
9
|
+
type SandboxFormState,
|
|
10
|
+
} from "./sandbox-settings.logic";
|
|
11
|
+
|
|
12
|
+
const SAFE_DEFAULT = sandboxPolicySchema.parse({
|
|
13
|
+
enabled: true,
|
|
14
|
+
onUnavailable: "degrade",
|
|
15
|
+
resources: {
|
|
16
|
+
cpuSeconds: 60,
|
|
17
|
+
memoryBytes: 512 * 1024 * 1024,
|
|
18
|
+
maxOpenFiles: 1024,
|
|
19
|
+
maxProcesses: 256,
|
|
20
|
+
maxOutputBytes: 5 * 1024 * 1024,
|
|
21
|
+
maxFileSizeBytes: 256 * 1024 * 1024,
|
|
22
|
+
},
|
|
23
|
+
filesystem: { mode: "scratch-plus-ro" },
|
|
24
|
+
network: { mode: "allowlist", allow: [], denyLinkLocalAndMetadata: true },
|
|
25
|
+
privilege: { mode: "drop-to-uid" },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("DEFAULT_SANDBOX_FORM (UI seed default)", () => {
|
|
29
|
+
it("is FAIL-CLOSED by default (onUnavailable=fail, never silent degrade)", () => {
|
|
30
|
+
// The editor opens on this seed for a fresh global policy; it must default
|
|
31
|
+
// to refusing the run when a layer can't be enforced, matching the shipped
|
|
32
|
+
// backend secure default.
|
|
33
|
+
expect(DEFAULT_SANDBOX_FORM.onUnavailable).toBe("fail");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("is a valid, secure-by-default policy input", () => {
|
|
37
|
+
const input = formToPolicyInput(DEFAULT_SANDBOX_FORM);
|
|
38
|
+
const parsed = sandboxPolicySchema.safeParse(input);
|
|
39
|
+
expect(parsed.success).toBe(true);
|
|
40
|
+
// Secure-by-default: egress denied (empty allowlist), FS confined,
|
|
41
|
+
// privilege dropped.
|
|
42
|
+
expect(input.enabled).toBe(true);
|
|
43
|
+
expect(input.onUnavailable).toBe("fail");
|
|
44
|
+
expect(input.network?.mode).toBe("allowlist");
|
|
45
|
+
expect(input.network?.allow).toEqual([]);
|
|
46
|
+
expect(input.filesystem?.mode).toBe("scratch-plus-ro");
|
|
47
|
+
expect(input.privilege?.mode).toBe("drop-to-uid");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("policyToForm / formToPolicyInput round-trip", () => {
|
|
52
|
+
it("round-trips the safe default without drift", () => {
|
|
53
|
+
const form = policyToForm(SAFE_DEFAULT);
|
|
54
|
+
const back = sandboxPolicySchema.parse(formToPolicyInput(form));
|
|
55
|
+
expect(back).toEqual(SAFE_DEFAULT);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("MB fields convert to bytes correctly", () => {
|
|
59
|
+
const form = policyToForm(SAFE_DEFAULT);
|
|
60
|
+
expect(form.memoryMb).toBe("512");
|
|
61
|
+
const input = formToPolicyInput(form);
|
|
62
|
+
expect(input.resources?.memoryBytes).toBe(512 * 1024 * 1024);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("blank resource caps are omitted (unset, not zero)", () => {
|
|
66
|
+
const form: SandboxFormState = {
|
|
67
|
+
...policyToForm(SAFE_DEFAULT),
|
|
68
|
+
cpuSeconds: "",
|
|
69
|
+
memoryMb: "",
|
|
70
|
+
};
|
|
71
|
+
const input = formToPolicyInput(form);
|
|
72
|
+
expect(input.resources?.cpuSeconds).toBeUndefined();
|
|
73
|
+
expect(input.resources?.memoryBytes).toBeUndefined();
|
|
74
|
+
// Still valid (unset caps allowed).
|
|
75
|
+
expect(sandboxPolicySchema.safeParse(input).success).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("parseAllowList", () => {
|
|
80
|
+
it("trims, drops blanks, keeps order", () => {
|
|
81
|
+
expect(parseAllowList(" 10.0.0.1 \n\n 192.168.0.0/24\n")).toEqual([
|
|
82
|
+
"10.0.0.1",
|
|
83
|
+
"192.168.0.0/24",
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("network mode + allow list survive form round-trip", () => {
|
|
88
|
+
const form: SandboxFormState = {
|
|
89
|
+
...policyToForm(SAFE_DEFAULT),
|
|
90
|
+
networkMode: "allowlist",
|
|
91
|
+
allowText: "10.0.0.1\n203.0.113.0/24",
|
|
92
|
+
};
|
|
93
|
+
const input = formToPolicyInput(form);
|
|
94
|
+
expect(input.network?.mode).toBe("allowlist");
|
|
95
|
+
expect(input.network?.allow).toEqual(["10.0.0.1", "203.0.113.0/24"]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("validateForm", () => {
|
|
100
|
+
it("returns null for a valid form", () => {
|
|
101
|
+
expect(validateForm(policyToForm(SAFE_DEFAULT))).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("flags a non-positive resource cap before submit", () => {
|
|
105
|
+
const form: SandboxFormState = {
|
|
106
|
+
...policyToForm(SAFE_DEFAULT),
|
|
107
|
+
cpuSeconds: "-5",
|
|
108
|
+
};
|
|
109
|
+
expect(validateForm(form)).not.toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("a global disable still produces a valid input", () => {
|
|
113
|
+
const form: SandboxFormState = {
|
|
114
|
+
...policyToForm(SAFE_DEFAULT),
|
|
115
|
+
enabled: false,
|
|
116
|
+
};
|
|
117
|
+
expect(validateForm(form)).toBeNull();
|
|
118
|
+
expect(formToPolicyInput(form).enabled).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import {
|
|
2
|
+
sandboxPolicySchema,
|
|
3
|
+
type SandboxPolicy,
|
|
4
|
+
type SandboxPolicyInput,
|
|
5
|
+
} from "@checkstack/common";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* DOM-free logic for the global script-sandbox settings page. Kept separate
|
|
9
|
+
* from the `.tsx` so it is unit-testable under `bun test` (run from the repo
|
|
10
|
+
* root WITHOUT happy-dom). The page component is a thin React shell over these
|
|
11
|
+
* pure helpers.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Editable, flattened form state mirroring the policy editor's controls. */
|
|
15
|
+
export interface SandboxFormState {
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
onUnavailable: "degrade" | "fail";
|
|
18
|
+
networkMode: "deny" | "allowlist" | "unrestricted";
|
|
19
|
+
/** Raw multiline textarea content; one IP / CIDR per line. */
|
|
20
|
+
allowText: string;
|
|
21
|
+
denyLinkLocalAndMetadata: boolean;
|
|
22
|
+
filesystemMode: "off" | "scratch-only" | "scratch-plus-ro";
|
|
23
|
+
privilegeMode: "inherit" | "drop-to-uid";
|
|
24
|
+
/** Resource caps as strings (text inputs); blank = leave unset. */
|
|
25
|
+
cpuSeconds: string;
|
|
26
|
+
memoryMb: string;
|
|
27
|
+
maxOpenFiles: string;
|
|
28
|
+
maxProcesses: string;
|
|
29
|
+
maxOutputMb: string;
|
|
30
|
+
maxFileSizeMb: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const BYTES_PER_MB = 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The seed default the admin sees for a FRESH global policy, before any row is
|
|
37
|
+
* stored. It mirrors the shipped secure default profile and is FAIL-CLOSED
|
|
38
|
+
* (`onUnavailable: "fail"`): when a layer cannot be enforced the run is refused
|
|
39
|
+
* rather than silently degraded. The backend resolves the same secure default
|
|
40
|
+
* for an empty settings table, so this constant only seeds the editor's initial
|
|
41
|
+
* state (and keeps the UI deterministic before the loader query resolves); it
|
|
42
|
+
* never overrides a stored policy.
|
|
43
|
+
*/
|
|
44
|
+
export const DEFAULT_SANDBOX_FORM: SandboxFormState = {
|
|
45
|
+
enabled: true,
|
|
46
|
+
onUnavailable: "fail",
|
|
47
|
+
networkMode: "allowlist",
|
|
48
|
+
allowText: "",
|
|
49
|
+
denyLinkLocalAndMetadata: true,
|
|
50
|
+
filesystemMode: "scratch-plus-ro",
|
|
51
|
+
privilegeMode: "drop-to-uid",
|
|
52
|
+
cpuSeconds: "60",
|
|
53
|
+
memoryMb: "512",
|
|
54
|
+
maxOpenFiles: "1024",
|
|
55
|
+
maxProcesses: "256",
|
|
56
|
+
maxOutputMb: "5",
|
|
57
|
+
maxFileSizeMb: "256",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Bytes -> MB string for a text input, or "" when the cap is unset. */
|
|
61
|
+
function bytesToMbText(bytes: number | undefined): string {
|
|
62
|
+
if (bytes === undefined) return "";
|
|
63
|
+
return String(bytes / BYTES_PER_MB);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Integer -> text, or "" when unset. */
|
|
67
|
+
function numToText(value: number | undefined): string {
|
|
68
|
+
return value === undefined ? "" : String(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Seed the editable form state from a resolved policy (the loader query result).
|
|
73
|
+
*/
|
|
74
|
+
export function policyToForm(policy: SandboxPolicy): SandboxFormState {
|
|
75
|
+
return {
|
|
76
|
+
enabled: policy.enabled,
|
|
77
|
+
onUnavailable: policy.onUnavailable,
|
|
78
|
+
networkMode: policy.network.mode,
|
|
79
|
+
allowText: policy.network.allow.join("\n"),
|
|
80
|
+
denyLinkLocalAndMetadata: policy.network.denyLinkLocalAndMetadata,
|
|
81
|
+
filesystemMode: policy.filesystem.mode,
|
|
82
|
+
privilegeMode: policy.privilege.mode,
|
|
83
|
+
cpuSeconds: numToText(policy.resources.cpuSeconds),
|
|
84
|
+
memoryMb: bytesToMbText(policy.resources.memoryBytes),
|
|
85
|
+
maxOpenFiles: numToText(policy.resources.maxOpenFiles),
|
|
86
|
+
maxProcesses: numToText(policy.resources.maxProcesses),
|
|
87
|
+
maxOutputMb: bytesToMbText(policy.resources.maxOutputBytes),
|
|
88
|
+
maxFileSizeMb: bytesToMbText(policy.resources.maxFileSizeBytes),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Parse a multiline allow list into trimmed, non-empty entries. */
|
|
93
|
+
export function parseAllowList(allowText: string): string[] {
|
|
94
|
+
return allowText
|
|
95
|
+
.split("\n")
|
|
96
|
+
.map((line) => line.trim())
|
|
97
|
+
.filter((line) => line.length > 0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A positive-int text field -> number, or undefined when blank/invalid-empty. */
|
|
101
|
+
function parsePositiveInt(value: string): number | undefined {
|
|
102
|
+
const trimmed = value.trim();
|
|
103
|
+
if (trimmed.length === 0) return undefined;
|
|
104
|
+
const n = Number(trimmed);
|
|
105
|
+
if (!Number.isFinite(n)) return undefined;
|
|
106
|
+
return Math.round(n);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** An MB text field -> bytes, or undefined when blank. */
|
|
110
|
+
function parseMbToBytes(value: string): number | undefined {
|
|
111
|
+
const trimmed = value.trim();
|
|
112
|
+
if (trimmed.length === 0) return undefined;
|
|
113
|
+
const n = Number(trimmed);
|
|
114
|
+
if (!Number.isFinite(n)) return undefined;
|
|
115
|
+
return Math.round(n * BYTES_PER_MB);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build the FULL policy input to send to `setSandboxPolicy` from form state.
|
|
120
|
+
*
|
|
121
|
+
* The write endpoint treats its input as a partial merged over the safe
|
|
122
|
+
* default, but the editor exposes every layer, so we send a complete object
|
|
123
|
+
* (each layer explicit) - that is still a valid `SandboxPolicyInput`. Unset
|
|
124
|
+
* resource caps are OMITTED (left out of the object) so the schema treats them
|
|
125
|
+
* as "not capped by this layer" rather than coercing a 0/NaN.
|
|
126
|
+
*/
|
|
127
|
+
export function formToPolicyInput(form: SandboxFormState): SandboxPolicyInput {
|
|
128
|
+
const resources: Record<string, number> = {};
|
|
129
|
+
const cpu = parsePositiveInt(form.cpuSeconds);
|
|
130
|
+
if (cpu !== undefined) resources.cpuSeconds = cpu;
|
|
131
|
+
const mem = parseMbToBytes(form.memoryMb);
|
|
132
|
+
if (mem !== undefined) resources.memoryBytes = mem;
|
|
133
|
+
const nofile = parsePositiveInt(form.maxOpenFiles);
|
|
134
|
+
if (nofile !== undefined) resources.maxOpenFiles = nofile;
|
|
135
|
+
const nproc = parsePositiveInt(form.maxProcesses);
|
|
136
|
+
if (nproc !== undefined) resources.maxProcesses = nproc;
|
|
137
|
+
const out = parseMbToBytes(form.maxOutputMb);
|
|
138
|
+
if (out !== undefined) resources.maxOutputBytes = out;
|
|
139
|
+
const fsize = parseMbToBytes(form.maxFileSizeMb);
|
|
140
|
+
if (fsize !== undefined) resources.maxFileSizeBytes = fsize;
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
enabled: form.enabled,
|
|
144
|
+
onUnavailable: form.onUnavailable,
|
|
145
|
+
resources,
|
|
146
|
+
filesystem: { mode: form.filesystemMode },
|
|
147
|
+
network: {
|
|
148
|
+
mode: form.networkMode,
|
|
149
|
+
allow: parseAllowList(form.allowText),
|
|
150
|
+
denyLinkLocalAndMetadata: form.denyLinkLocalAndMetadata,
|
|
151
|
+
},
|
|
152
|
+
privilege: { mode: form.privilegeMode },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Validate the form's would-be policy input against the canonical schema.
|
|
158
|
+
* Returns the first error message, or `null` when valid. Used to surface a
|
|
159
|
+
* problem (e.g. a non-positive resource cap) before submit.
|
|
160
|
+
*/
|
|
161
|
+
export function validateForm(form: SandboxFormState): string | null {
|
|
162
|
+
const parsed = sandboxPolicySchema.safeParse(formToPolicyInput(form));
|
|
163
|
+
if (parsed.success) return null;
|
|
164
|
+
return parsed.error.issues[0]?.message ?? "Invalid sandbox policy";
|
|
165
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { fetchApiRef, useApi } from "@checkstack/frontend-api";
|
|
3
|
+
import type { AcquiredTypeFile } from "@checkstack/ui";
|
|
4
|
+
import {
|
|
5
|
+
buildSdkTypesPath,
|
|
6
|
+
pluginMetadata,
|
|
7
|
+
SdkTypesResponseSchema,
|
|
8
|
+
} from "@checkstack/script-packages-common";
|
|
9
|
+
import { SDK_RELEASE_VERSION } from "@checkstack/sdk/version";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fetch the running release's `@checkstack/sdk` editor bundle (the ambient
|
|
13
|
+
* `.d.ts` for the script-authoring helpers + typed client) so the in-app
|
|
14
|
+
* editor resolves `import { defineHealthCheck } from "@checkstack/sdk/healthcheck"`
|
|
15
|
+
* with real types - never a hand-rolled ambient string that drifts from the
|
|
16
|
+
* published package.
|
|
17
|
+
*
|
|
18
|
+
* Version-keyed + cacheable (mirrors `useScriptPackageTypeAcquisition`): the
|
|
19
|
+
* route is `GET /api/script-packages/sdk-types/:releaseVersion`, served with
|
|
20
|
+
* `Cache-Control: private, max-age=..., immutable`. `SDK_RELEASE_VERSION` is
|
|
21
|
+
* stamped into the frontend bundle at build time and equals the release the
|
|
22
|
+
* backend serves (frontend + backend ship together). The version doubles as
|
|
23
|
+
* the `sdkTypesResetKey` so a deployment upgrade refreshes the SDK libs.
|
|
24
|
+
*
|
|
25
|
+
* If the backend's running version differs (e.g. a tab open across a rolling
|
|
26
|
+
* deploy) the route returns 409 and the hook yields no files that round; a
|
|
27
|
+
* frontend redeploy carries the new stamped version and the editor remounts.
|
|
28
|
+
*
|
|
29
|
+
* Returns `sdkTypes: undefined` until the fetch resolves so the editor simply
|
|
30
|
+
* doesn't mount the SDK libs before they're available.
|
|
31
|
+
*/
|
|
32
|
+
export function useSdkTypeInjection(): {
|
|
33
|
+
sdkTypes: AcquiredTypeFile[] | undefined;
|
|
34
|
+
sdkTypesResetKey: string;
|
|
35
|
+
} {
|
|
36
|
+
const fetchApi = useApi(fetchApiRef);
|
|
37
|
+
const [sdkTypes, setSdkTypes] = React.useState<AcquiredTypeFile[]>();
|
|
38
|
+
|
|
39
|
+
const pluginFetch = React.useMemo(
|
|
40
|
+
() => fetchApi.forPlugin(pluginMetadata.pluginId),
|
|
41
|
+
[fetchApi],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
React.useEffect(() => {
|
|
45
|
+
let cancelled = false;
|
|
46
|
+
const load = async (): Promise<void> => {
|
|
47
|
+
const path = buildSdkTypesPath({ releaseVersion: SDK_RELEASE_VERSION });
|
|
48
|
+
try {
|
|
49
|
+
const response = await pluginFetch.fetch(path);
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
// 409 stale-version or any error: no SDK types this round. A redeploy
|
|
52
|
+
// bumps the stamped version and re-runs this effect.
|
|
53
|
+
if (!cancelled) setSdkTypes([]);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const json: unknown = await response.json();
|
|
57
|
+
const parsed = SdkTypesResponseSchema.safeParse(json);
|
|
58
|
+
if (!cancelled) setSdkTypes(parsed.success ? parsed.data.files : []);
|
|
59
|
+
} catch {
|
|
60
|
+
if (!cancelled) setSdkTypes([]);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
void load();
|
|
64
|
+
return () => {
|
|
65
|
+
cancelled = true;
|
|
66
|
+
};
|
|
67
|
+
}, [pluginFetch]);
|
|
68
|
+
|
|
69
|
+
return { sdkTypes, sdkTypesResetKey: SDK_RELEASE_VERSION };
|
|
70
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { Link } from "react-router-dom";
|
|
3
|
-
import { Package } from "lucide-react";
|
|
4
|
-
import type { UserMenuItemsContext } from "@checkstack/frontend-api";
|
|
5
|
-
import { DropdownMenuItem } from "@checkstack/ui";
|
|
6
|
-
import { resolveRoute } from "@checkstack/common";
|
|
7
|
-
import {
|
|
8
|
-
scriptPackagesRoutes,
|
|
9
|
-
scriptPackagesAccess,
|
|
10
|
-
pluginMetadata,
|
|
11
|
-
} from "@checkstack/script-packages-common";
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* "Script Packages" entry in the user/settings menu. Gated on
|
|
15
|
-
* `script-packages.manage` (the page itself is also access-gated by the
|
|
16
|
-
* route); hiding the link is the cleaner UX for unauthorised users.
|
|
17
|
-
*/
|
|
18
|
-
export const ScriptPackagesMenuItems = ({
|
|
19
|
-
accessRules: userPerms,
|
|
20
|
-
}: UserMenuItemsContext) => {
|
|
21
|
-
const qualifiedId = `${pluginMetadata.pluginId}.${scriptPackagesAccess.manage.id}`;
|
|
22
|
-
const canManage = userPerms.includes("*") || userPerms.includes(qualifiedId);
|
|
23
|
-
|
|
24
|
-
if (!canManage) {
|
|
25
|
-
return <React.Fragment />;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
return (
|
|
29
|
-
<Link to={resolveRoute(scriptPackagesRoutes.routes.settings)}>
|
|
30
|
-
<DropdownMenuItem icon={<Package className="w-4 h-4" />}>
|
|
31
|
-
Script Packages
|
|
32
|
-
</DropdownMenuItem>
|
|
33
|
-
</Link>
|
|
34
|
-
);
|
|
35
|
-
};
|