@checkstack/script-packages-common 0.2.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 +105 -0
- package/package.json +31 -0
- package/src/access.ts +45 -0
- package/src/hooks.ts +20 -0
- package/src/index.ts +12 -0
- package/src/plugin-metadata.ts +10 -0
- package/src/routes.ts +10 -0
- package/src/rpc-contract.ts +283 -0
- package/src/schemas.test.ts +48 -0
- package/src/schemas.ts +344 -0
- package/src/signals.ts +16 -0
- package/src/type-acquisition.test.ts +56 -0
- package/src/type-acquisition.ts +70 -0
- package/tsconfig.json +14 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# @checkstack/script-packages-common
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 270ef29: Fix several correctness defects around distributed coordination and stored-data handling.
|
|
8
|
+
|
|
9
|
+
- Dwell `for:` timers now fire via an atomic `DELETE ... RETURNING` claim, so two pods (or the stalled sweeper vs the queue consumer) can no longer both fire the same dwell.
|
|
10
|
+
- Postgres session-level advisory locks now keep connection affinity. A shared `AdvisoryLockService` (backed by a dedicated pooled client) replaces the previous acquire/release-on-different-connection pattern that leaked locks. Used by the script-packages installer election, the automation run resume + stalled sweeper, and (via a new transaction-scoped `withXactLock`) incident dedup.
|
|
11
|
+
- A storage migration that crashed mid-flight is now resumed on startup under the installer-election lock, instead of permanently wedging installs.
|
|
12
|
+
- Distributed script-package blobs carry a `blobSha256` and are verified before extraction (the SRI `integrity` hashes the npm tarball, not the transported archive). Backward-safe: entries without the field skip verification until a re-install regenerates the manifest.
|
|
13
|
+
- Archive extraction rejects zip-slip paths (absolute or `..` entries) before writing anything.
|
|
14
|
+
- `incident.create` with `dedupe_open_for_system` serializes its check-then-create per system, so concurrent triggers for the same system can't both open a duplicate incident.
|
|
15
|
+
- Seeded auto-incident filter expressions JSON-encode interpolated ids so a quote/backslash can't corrupt the expression.
|
|
16
|
+
- Stored jsonb snapshots (dwell `actorSnapshot`, wait-lock `waitConfig`) are validated with zod on load and degrade safely instead of flowing through as the wrong type.
|
|
17
|
+
|
|
18
|
+
- 270ef29: Core-side satellite script-package distribution.
|
|
19
|
+
|
|
20
|
+
- `satellite-backend`: the WS handler now carries the desired script-package
|
|
21
|
+
lockfile hash in `authenticated` / `config_updated` payloads (the durable
|
|
22
|
+
backstop), exposes `pushRefreshScriptPackagesToAll` (wired to the
|
|
23
|
+
`script-packages.changed` broadcast hook in `mode: "broadcast"`, so each
|
|
24
|
+
core instance fans the refresh out to its own connected satellites), and
|
|
25
|
+
persists `script_package_sync_state` reports from satellites.
|
|
26
|
+
- `script-packages-*`: new `reportSatelliteSyncState` RPC + store method so
|
|
27
|
+
satellite-backend can record per-satellite reconcile state for the admin
|
|
28
|
+
UI. Satellites pull blobs from core via the existing `getManifest` /
|
|
29
|
+
`downloadBlob` endpoints, never the registry.
|
|
30
|
+
|
|
31
|
+
- 270ef29: Add the script-packages common package: zod schemas (package allowlist
|
|
32
|
+
spec, registry config, lockfile manifest, install / storage / migration
|
|
33
|
+
state, per-host sync state, package types, size-cap config), the oRPC
|
|
34
|
+
contract, frontend signal + `script-packages.changed` hook id/payload,
|
|
35
|
+
the dedicated `script-packages.manage` and `script-packages.read` access
|
|
36
|
+
rules, and plugin metadata.
|
|
37
|
+
- 270ef29: Add garbage collection for script packages, reclaiming both shared blob storage and per-host disk.
|
|
38
|
+
|
|
39
|
+
Two things accumulated and were never reclaimed; both are now collected with provably-safe guards (referenced-set + grace + lock / active-run protection - when in doubt, retain).
|
|
40
|
+
|
|
41
|
+
- **Blob GC (Postgres / S3 reclamation).** A new `gcBlobs` RPC (manage-gated) plus a daily scheduled job prune content-addressed blobs no longer referenced by any retained lockfile manifest (the current desired hash plus the previous N, default 1, for rollback / in-flight reconciles). Candidates are only deleted after a grace window (default 24h, keyed on `created_at`), each blob's bytes are removed from its recorded backend before its index row, and the pass holds the installer-election advisory lock so it is mutually exclusive with installs and storage migrations. The settings UI surfaces last-run and total-reclaimed figures and a "Run cleanup now" action. The installer now records each successful manifest in a new `script_package_lockfile_history` table so the retained set is computable; GC state lives in `script_package_blob_gc_state`.
|
|
42
|
+
- **Tree GC (per-host disk).** After a successful symlink flip, each host (core pod or satellite) sweeps its `trees/<lockfileHash>/` dirs, deleting non-current trees older than a grace window (default 1h). `current`'s target is never deleted. Active-run safety uses a conservative mtime-keyed grace window chosen to exceed the longest run timeout, since runs are throwaway subprocesses with no robust cross-process refcount - a tree only becomes eligible once no live run could still be pinned to it.
|
|
43
|
+
|
|
44
|
+
Adds the `gcBlobs` / `getBlobGcState` RPCs, the `BlobGcSummary` / `BlobGcState` schemas, and two new Drizzle tables (`script_package_lockfile_history`, `script_package_blob_gc_state`).
|
|
45
|
+
|
|
46
|
+
- b995afb: Fix package IntelliSense in script editors: lazy Automatic Type Acquisition (ATA) with proper `@types/*` resolution.
|
|
47
|
+
|
|
48
|
+
Script editors (automation "Run Script (TypeScript)" and healthcheck collectors) now provide real autocomplete for installed npm packages. Importing a package whose types live in DefinitelyTyped - e.g. `import { debounce } from "lodash"` (lodash ships no own types; `@types/lodash` does) - now yields member completions. Previously no package completions appeared at all.
|
|
49
|
+
|
|
50
|
+
Root cause: the old rollup wrapped each package's raw, multi-file `.d.ts` (with `export =`, `export as namespace`, and triple-slash `/// <reference path>` chains) inside a single `declare module "<name>" { ... }`, which the TypeScript worker silently rejected, and it truncated large type sets (lodash is ~866 KB across ~700 files) at a 256 KB cap.
|
|
51
|
+
|
|
52
|
+
The fix registers the REAL declaration files at their `node_modules/...` virtual paths and lets TypeScript's own NodeJs + `@types` resolution do the work:
|
|
53
|
+
|
|
54
|
+
- `@checkstack/script-packages-backend`: replaced `rollupPackageTypes` with a tree-driven closure extractor (`resolvePackageTypeClosure`). Given a bare specifier, it resolves against the materialized tree - own types via `package.json` `types`/`typings`/`exports` (bundled-types packages like `zod`/`dayjs`), the `@types/<mangled>` companion when it exists (`lodash` -> `@types/lodash`, scoped `@babel/core` -> `@types/babel__core`), or both, or neither (graceful empty, never a throw). It follows `/// <reference path|types>` and relative imports, includes each package's `package.json`, leaves every file UNWRAPPED, and surfaces a `truncated` flag instead of silently capping. Served from a new raw, HTTP-cacheable route `GET /api/script-packages/types/:lockfileHash/:specifier` (`Cache-Control: private, max-age=1y, immutable`), auth-gated by `script-packages.read`.
|
|
55
|
+
- `@checkstack/script-packages-common`: **BREAKING** - replaced the `listPackageTypes` RPC procedure and `PackageTypesSchema { name, version, dts }` with `PackageTypeClosureSchema` (a `{ path, content }` file-map plus `hasOwnTypes`/`hasAtTypes`/`notFound`/`truncated`) served over the cacheable HTTP route. Added a shared `buildTypeAcquisitionPath`/`parseTypeAcquisitionPath` path contract.
|
|
56
|
+
- `@checkstack/ui`: `CodeEditor`/`TypefoxEditor` gained an injected `acquireTypes` resolver + `acquireResetKey`. On debounced buffer change it parses bare `import`/`require` specifiers (pure, unit-tested) and lazily fetches + registers each NEW package's closure via `addExtraLib` at `file:///node_modules/...`, deduped by a shared acquired-set that resets when the install hash changes. Compiler options set `moduleResolution: NodeJs`, `baseUrl: "file:///"`, and `typeRoots` so a bare import resolves to its `@types` companion. The `context` ambient global keeps working unchanged.
|
|
57
|
+
- `@checkstack/script-packages-frontend`: replaced the old `useScriptPackageTypes` (which concatenated the broken `dts`) with `useScriptPackageTypeAcquisition()`, returning the `acquireTypes` resolver (targets the cacheable route, zod-validates the response) and the current `lockfileHash` as `acquireResetKey`.
|
|
58
|
+
- `@checkstack/automation-frontend` / `@checkstack/healthcheck-frontend`: wired the resolver into the Run Script and collector editors.
|
|
59
|
+
|
|
60
|
+
State & scale: the type closure is derived on read from the materialized package tree (no new durable state). The editor's acquired-set is pod-local UI bookkeeping; the route is keyed by the cluster-wide `lockfileHash`, so the browser HTTP cache is correct across pods and only refetches after a new install changes the hash.
|
|
61
|
+
|
|
62
|
+
- b995afb: Add live, backend-proxied npm package-name autocomplete and version lookup to the Script Packages "Allowed packages" form.
|
|
63
|
+
|
|
64
|
+
The package-name field now searches the configured registry as you type (debounced). The version field suggests the package's published versions newest-first, defaulting to the registry's `latest` dist-tag while staying free-typeable for an exact manual pin. Picking a package now auto-fills its version (from the search hit, then upgraded to `latest`) instead of clearing the field, and the version dropdown stays open so a version is actually selectable.
|
|
65
|
+
|
|
66
|
+
This is fully backend-proxied so it reuses the SAME configured registry + auth the install path uses: the registry can be private with a server-side-only auth token (the client only ever sees `hasAuthToken`), and browsers can't reach arbitrary registries due to CORS.
|
|
67
|
+
|
|
68
|
+
- `@checkstack/script-packages-common`: two new `manage`-gated query procedures - `searchPackages` (input `{ text }`, output `{ items: [{ name, version?, description? }] }`) and `getPackageVersions` (input `{ name }`, output `{ versions, distTags? }`). Output `version`/`versions` are relaxed to plain strings so valid-but-unusual registry versions surface as suggestions; strict `PackageVersionSchema` validation still applies on `addPackage`.
|
|
69
|
+
- `@checkstack/script-packages-backend`: new `registry-client` (fetch-based, AbortController timeout, size cap, zod-validated registry responses, scoped-registry selection, tolerant semver-descending sort, a best-effort pod-local read cache that is never a source of truth, and errors that never leak the auth token). Search results use the registry's own relevance ranking from `-/v1/search`. The registry + token resolution used by the install path is factored into a shared `resolveRegistryRequestConfig` helper reused by both the new RPC handlers and the installer.
|
|
70
|
+
- `@checkstack/script-packages-frontend`: the package and version inputs become live comboboxes (Popover + Input) with inline pinned-version validation before "Add" is enabled. Selecting a suggestion routes through a dedicated `onSelect(hit)` callback (separate from manual-typing `onValueChange`) so a pick auto-fills the version instead of clearing it; the popover dismissable layer ignores interactions originating on the anchor input, fixing the version dropdown that previously opened then immediately closed. The version-autofill decision logic is extracted into a pure, unit-tested helper.
|
|
71
|
+
|
|
72
|
+
State & scale: the registry-client TTL cache is an explicitly non-authoritative, pod-local best-effort read cache (search/version lookups are non-authoritative reads); a cache miss on another pod simply re-fetches, so pod-local divergence is harmless. No new durable state of record is introduced.
|
|
73
|
+
|
|
74
|
+
- 270ef29: Wire up the script-packages RPC router, admin UI, and editor IntelliSense.
|
|
75
|
+
|
|
76
|
+
- `script-packages-backend`: the oRPC router implementing the full
|
|
77
|
+
contract (allowlist CRUD, registry config with encrypted write-only auth
|
|
78
|
+
token, `installNow` via the elected installer, size cap, storage backend
|
|
79
|
+
selection, install state, `getManifest` / `downloadBlob` for reconcilers,
|
|
80
|
+
and `listPackageTypes`), the `installNow` controller (election, size-cap
|
|
81
|
+
enforcement, `script-packages.changed` emit, blocked during migration),
|
|
82
|
+
the `.d.ts` rollup, the singleton config stores, and the full plugin
|
|
83
|
+
wiring (broadcast-hook reconcile + startup backstop).
|
|
84
|
+
- `script-packages-common`: admin route for the settings page.
|
|
85
|
+
- `script-packages-frontend`: the Settings -> Script Packages admin page
|
|
86
|
+
(allowlist, install state + size, registry/storage summary, satellite
|
|
87
|
+
sync) and the `useScriptPackageTypes()` hook.
|
|
88
|
+
- `automation-frontend` / `healthcheck-frontend`: merge installed-package
|
|
89
|
+
`.d.ts` into the script-editor `typeDefinitions` so `import` from an
|
|
90
|
+
allowlisted package autocompletes in every script field.
|
|
91
|
+
|
|
92
|
+
- 270ef29: Add storage-backend migration for script packages.
|
|
93
|
+
|
|
94
|
+
- `migrateStorage({ target })` copies every blob from the active backend to
|
|
95
|
+
the target, verifies each copy byte-for-byte (read back + SHA-256 compare),
|
|
96
|
+
flips the per-blob `backend` only after a verified copy, then atomically
|
|
97
|
+
switches the active backend. Resumable from a partial state (the work set
|
|
98
|
+
is re-derived from the index), aborts cleanly on an integrity mismatch
|
|
99
|
+
(active backend untouched), and supports optional source GC. Built on the
|
|
100
|
+
Phase 2 dual-backend read fallback, so reads keep working mid-migration.
|
|
101
|
+
- Migration and `installNow` are mutually exclusive via the installer
|
|
102
|
+
advisory lock; `setStorageBackend` is refused while a migration runs.
|
|
103
|
+
- New `listStorageBackends` RPC + admin UI: a storage-backend card with a
|
|
104
|
+
target selector, "Migrate" action, and live progress / completion / error
|
|
105
|
+
state.
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/script-packages-common",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"license": "Elastic-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./src/index.ts"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"typecheck": "tsgo -b",
|
|
13
|
+
"lint": "bun run lint:code",
|
|
14
|
+
"lint:code": "eslint . --max-warnings 0",
|
|
15
|
+
"test": "bun test"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@checkstack/common": "0.12.0",
|
|
19
|
+
"@checkstack/signal-common": "0.2.5",
|
|
20
|
+
"@orpc/contract": "^1.13.14",
|
|
21
|
+
"zod": "^4.0.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"typescript": "^5.7.2",
|
|
25
|
+
"@checkstack/tsconfig": "0.0.7",
|
|
26
|
+
"@checkstack/scripts": "0.3.4"
|
|
27
|
+
},
|
|
28
|
+
"checkstack": {
|
|
29
|
+
"type": "common"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/access.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { access } from "@checkstack/common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Access rules for the script-packages plugin.
|
|
5
|
+
*
|
|
6
|
+
* Installing npm packages is an install-time RCE / supply-chain vector
|
|
7
|
+
* (postinstall scripts, transitive deps), so management gets its own
|
|
8
|
+
* dedicated, grantable permission (`script-packages.manage`) rather than
|
|
9
|
+
* riding a general role. The read-only editor / runtime endpoints
|
|
10
|
+
* (`getInstallState`, `getManifest`, `downloadBlob`, and the cacheable
|
|
11
|
+
* package-type-closure HTTP route) are gated by the existing
|
|
12
|
+
* script-authoring access so editors and reconcilers can use them; we
|
|
13
|
+
* model that as `script-packages.read`.
|
|
14
|
+
*/
|
|
15
|
+
export const scriptPackagesAccess = {
|
|
16
|
+
/**
|
|
17
|
+
* Read-only access for editor IntelliSense + runtime reconcilers:
|
|
18
|
+
* desired manifest, install state, blob download, package `.d.ts`.
|
|
19
|
+
*/
|
|
20
|
+
read: access(
|
|
21
|
+
"script-packages",
|
|
22
|
+
"read",
|
|
23
|
+
"Read installed script packages, manifest, and types",
|
|
24
|
+
),
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Management access: edit the allowlist, registry config, storage
|
|
28
|
+
* backend, trigger installs / migrations. Dedicated grantable
|
|
29
|
+
* permission because installing packages can execute code at install
|
|
30
|
+
* time.
|
|
31
|
+
*/
|
|
32
|
+
manage: access(
|
|
33
|
+
"script-packages",
|
|
34
|
+
"manage",
|
|
35
|
+
"Manage script packages (allowlist, registry, storage, installs)",
|
|
36
|
+
),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* All access rules for registration with the plugin system.
|
|
41
|
+
*/
|
|
42
|
+
export const scriptPackagesAccessRules = [
|
|
43
|
+
scriptPackagesAccess.read,
|
|
44
|
+
scriptPackagesAccess.manage,
|
|
45
|
+
];
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Id + payload schema for the backend `script-packages.changed` hook.
|
|
5
|
+
*
|
|
6
|
+
* The hook itself (`createHook`) lives in the backend package (hooks are a
|
|
7
|
+
* backend concern), but the id + payload contract is shared here so the
|
|
8
|
+
* satellite-distribution push message and any subscriber agree on the
|
|
9
|
+
* shape. Emitted by the elected installer after a successful install;
|
|
10
|
+
* core instances subscribe in `broadcast` mode and kick their reconciler.
|
|
11
|
+
*/
|
|
12
|
+
export const SCRIPT_PACKAGES_CHANGED_HOOK_ID = "script-packages.changed";
|
|
13
|
+
|
|
14
|
+
export const ScriptPackagesChangedPayloadSchema = z.object({
|
|
15
|
+
/** The new desired lockfile hash hosts should reconcile to. */
|
|
16
|
+
lockfileHash: z.string(),
|
|
17
|
+
});
|
|
18
|
+
export type ScriptPackagesChangedPayload = z.infer<
|
|
19
|
+
typeof ScriptPackagesChangedPayloadSchema
|
|
20
|
+
>;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from "./schemas";
|
|
2
|
+
export * from "./type-acquisition";
|
|
3
|
+
export * from "./access";
|
|
4
|
+
export * from "./signals";
|
|
5
|
+
export * from "./hooks";
|
|
6
|
+
export * from "./routes";
|
|
7
|
+
export {
|
|
8
|
+
scriptPackagesContract,
|
|
9
|
+
ScriptPackagesApi,
|
|
10
|
+
type ScriptPackagesContract,
|
|
11
|
+
} from "./rpc-contract";
|
|
12
|
+
export * from "./plugin-metadata";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { definePluginMetadata } from "@checkstack/common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Plugin metadata for the script-packages plugin.
|
|
5
|
+
* Exported from the common package so backend, frontend, and reconcilers
|
|
6
|
+
* can reference the same id.
|
|
7
|
+
*/
|
|
8
|
+
export const pluginMetadata = definePluginMetadata({
|
|
9
|
+
pluginId: "script-packages",
|
|
10
|
+
});
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createRoutes } from "@checkstack/common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Route definitions for the script-packages admin UI (Settings -> Script
|
|
5
|
+
* Packages).
|
|
6
|
+
*/
|
|
7
|
+
export const scriptPackagesRoutes = createRoutes("script-packages", {
|
|
8
|
+
/** Admin settings page: allowlist, registry, storage, sync status. */
|
|
9
|
+
settings: "/",
|
|
10
|
+
});
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { createClientDefinition, proc } from "@checkstack/common";
|
|
3
|
+
import { scriptPackagesAccess } from "./access";
|
|
4
|
+
import { pluginMetadata } from "./plugin-metadata";
|
|
5
|
+
import {
|
|
6
|
+
BlobGcStateSchema,
|
|
7
|
+
BlobGcSummarySchema,
|
|
8
|
+
GetPackageVersionsInputSchema,
|
|
9
|
+
GetPackageVersionsOutputSchema,
|
|
10
|
+
InstallStateSchema,
|
|
11
|
+
ManifestEntrySchema,
|
|
12
|
+
PackageNameSchema,
|
|
13
|
+
PackageSpecSchema,
|
|
14
|
+
PackageVersionSchema,
|
|
15
|
+
RegistryConfigSchema,
|
|
16
|
+
SatelliteSyncStateSchema,
|
|
17
|
+
SearchPackagesInputSchema,
|
|
18
|
+
SearchPackagesOutputSchema,
|
|
19
|
+
SetRegistryConfigInputSchema,
|
|
20
|
+
SizeCapConfigSchema,
|
|
21
|
+
StorageConfigSchema,
|
|
22
|
+
} from "./schemas";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Script-packages RPC contract.
|
|
26
|
+
*
|
|
27
|
+
* Management endpoints are gated by the dedicated `script-packages.manage`
|
|
28
|
+
* permission (install-time RCE / supply-chain vector). The read-only
|
|
29
|
+
* editor / runtime endpoints (`getInstallState`, `getManifest`,
|
|
30
|
+
* `downloadBlob`) are gated by `script-packages.read` so editors and
|
|
31
|
+
* reconcilers can use them.
|
|
32
|
+
*
|
|
33
|
+
* NOTE: package-type IntelliSense is NOT an RPC procedure. It is served as
|
|
34
|
+
* a raw, HTTP-cacheable route (`GET /api/script-packages/types/:hash/:spec`)
|
|
35
|
+
* so the browser can cache each closure per-install via `Cache-Control`;
|
|
36
|
+
* see `createTypeClosureHttpHandler` in `script-packages-backend`.
|
|
37
|
+
*/
|
|
38
|
+
export const scriptPackagesContract = {
|
|
39
|
+
// ─── Allowlist (manage) ────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
listPackages: proc({
|
|
42
|
+
operationType: "query",
|
|
43
|
+
userType: "authenticated",
|
|
44
|
+
access: [scriptPackagesAccess.manage],
|
|
45
|
+
}).output(z.object({ items: z.array(PackageSpecSchema) })),
|
|
46
|
+
|
|
47
|
+
addPackage: proc({
|
|
48
|
+
operationType: "mutation",
|
|
49
|
+
userType: "authenticated",
|
|
50
|
+
access: [scriptPackagesAccess.manage],
|
|
51
|
+
})
|
|
52
|
+
.input(
|
|
53
|
+
z.object({ name: PackageNameSchema, version: PackageVersionSchema }),
|
|
54
|
+
)
|
|
55
|
+
.output(PackageSpecSchema),
|
|
56
|
+
|
|
57
|
+
removePackage: proc({
|
|
58
|
+
operationType: "mutation",
|
|
59
|
+
userType: "authenticated",
|
|
60
|
+
access: [scriptPackagesAccess.manage],
|
|
61
|
+
})
|
|
62
|
+
.route({ method: "DELETE" })
|
|
63
|
+
.input(z.object({ name: PackageNameSchema }))
|
|
64
|
+
.output(z.object({ success: z.boolean() })),
|
|
65
|
+
|
|
66
|
+
setPackageEnabled: proc({
|
|
67
|
+
operationType: "mutation",
|
|
68
|
+
userType: "authenticated",
|
|
69
|
+
access: [scriptPackagesAccess.manage],
|
|
70
|
+
})
|
|
71
|
+
.input(z.object({ name: PackageNameSchema, enabled: z.boolean() }))
|
|
72
|
+
.output(PackageSpecSchema),
|
|
73
|
+
|
|
74
|
+
// ─── Registry autocomplete (manage) ────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Live package-name search against the configured registry, backend-proxied
|
|
78
|
+
* (the registry may be private with a server-side-only token, and browsers
|
|
79
|
+
* can't reach arbitrary registries due to CORS). Gated by `manage` like the
|
|
80
|
+
* other registry endpoints.
|
|
81
|
+
*/
|
|
82
|
+
searchPackages: proc({
|
|
83
|
+
operationType: "query",
|
|
84
|
+
userType: "authenticated",
|
|
85
|
+
access: [scriptPackagesAccess.manage],
|
|
86
|
+
})
|
|
87
|
+
.input(SearchPackagesInputSchema)
|
|
88
|
+
.output(SearchPackagesOutputSchema),
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Fetch published versions (newest-first) + dist-tags for a package from
|
|
92
|
+
* the configured registry. Backend-proxied for the same reasons as
|
|
93
|
+
* `searchPackages`. The version list feeds the version field's suggestions.
|
|
94
|
+
*/
|
|
95
|
+
getPackageVersions: proc({
|
|
96
|
+
operationType: "query",
|
|
97
|
+
userType: "authenticated",
|
|
98
|
+
access: [scriptPackagesAccess.manage],
|
|
99
|
+
})
|
|
100
|
+
.input(GetPackageVersionsInputSchema)
|
|
101
|
+
.output(GetPackageVersionsOutputSchema),
|
|
102
|
+
|
|
103
|
+
// ─── Registry config (manage) ──────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
getRegistryConfig: proc({
|
|
106
|
+
operationType: "query",
|
|
107
|
+
userType: "authenticated",
|
|
108
|
+
access: [scriptPackagesAccess.manage],
|
|
109
|
+
}).output(RegistryConfigSchema),
|
|
110
|
+
|
|
111
|
+
setRegistryConfig: proc({
|
|
112
|
+
operationType: "mutation",
|
|
113
|
+
userType: "authenticated",
|
|
114
|
+
access: [scriptPackagesAccess.manage],
|
|
115
|
+
})
|
|
116
|
+
.input(SetRegistryConfigInputSchema)
|
|
117
|
+
.output(RegistryConfigSchema),
|
|
118
|
+
|
|
119
|
+
// ─── Install (manage) ──────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Elect the installer (advisory lock), resolve the pinned allowlist,
|
|
123
|
+
* publish new blobs, record the manifest, emit `script-packages.changed`.
|
|
124
|
+
* Blocked while a storage migration is in flight.
|
|
125
|
+
*/
|
|
126
|
+
installNow: proc({
|
|
127
|
+
operationType: "mutation",
|
|
128
|
+
userType: "authenticated",
|
|
129
|
+
access: [scriptPackagesAccess.manage],
|
|
130
|
+
}).output(
|
|
131
|
+
z.object({
|
|
132
|
+
started: z.boolean(),
|
|
133
|
+
/** Populated when the install was refused (e.g. migration in flight). */
|
|
134
|
+
reason: z.string().optional(),
|
|
135
|
+
}),
|
|
136
|
+
),
|
|
137
|
+
|
|
138
|
+
getSizeCapConfig: proc({
|
|
139
|
+
operationType: "query",
|
|
140
|
+
userType: "authenticated",
|
|
141
|
+
access: [scriptPackagesAccess.manage],
|
|
142
|
+
}).output(SizeCapConfigSchema),
|
|
143
|
+
|
|
144
|
+
setSizeCapConfig: proc({
|
|
145
|
+
operationType: "mutation",
|
|
146
|
+
userType: "authenticated",
|
|
147
|
+
access: [scriptPackagesAccess.manage],
|
|
148
|
+
})
|
|
149
|
+
.input(SizeCapConfigSchema)
|
|
150
|
+
.output(SizeCapConfigSchema),
|
|
151
|
+
|
|
152
|
+
// ─── Storage (manage) ──────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
getStorageConfig: proc({
|
|
155
|
+
operationType: "query",
|
|
156
|
+
userType: "authenticated",
|
|
157
|
+
access: [scriptPackagesAccess.manage],
|
|
158
|
+
}).output(StorageConfigSchema),
|
|
159
|
+
|
|
160
|
+
/** Registered blob-store backend ids available as migration targets. */
|
|
161
|
+
listStorageBackends: proc({
|
|
162
|
+
operationType: "query",
|
|
163
|
+
userType: "authenticated",
|
|
164
|
+
access: [scriptPackagesAccess.manage],
|
|
165
|
+
}).output(z.object({ backends: z.array(z.string()) })),
|
|
166
|
+
|
|
167
|
+
setStorageBackend: proc({
|
|
168
|
+
operationType: "mutation",
|
|
169
|
+
userType: "authenticated",
|
|
170
|
+
access: [scriptPackagesAccess.manage],
|
|
171
|
+
})
|
|
172
|
+
.input(z.object({ backend: z.string().min(1) }))
|
|
173
|
+
.output(StorageConfigSchema),
|
|
174
|
+
|
|
175
|
+
migrateStorage: proc({
|
|
176
|
+
operationType: "mutation",
|
|
177
|
+
userType: "authenticated",
|
|
178
|
+
access: [scriptPackagesAccess.manage],
|
|
179
|
+
})
|
|
180
|
+
.input(z.object({ target: z.string().min(1) }))
|
|
181
|
+
.output(z.object({ started: z.boolean(), reason: z.string().optional() })),
|
|
182
|
+
|
|
183
|
+
getStorageMigrationState: proc({
|
|
184
|
+
operationType: "query",
|
|
185
|
+
userType: "authenticated",
|
|
186
|
+
access: [scriptPackagesAccess.manage],
|
|
187
|
+
}).output(StorageConfigSchema),
|
|
188
|
+
|
|
189
|
+
// ─── Garbage collection (manage) ───────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Admin-triggered blob GC: prune content-addressed blobs no longer
|
|
193
|
+
* referenced by any RETAINED lockfile manifest (current + the previous N),
|
|
194
|
+
* older than the grace window. Refuses while an install or storage
|
|
195
|
+
* migration is in flight (takes the installer-election advisory lock).
|
|
196
|
+
* Returns a summary (candidates, deleted, bytes reclaimed).
|
|
197
|
+
*/
|
|
198
|
+
gcBlobs: proc({
|
|
199
|
+
operationType: "mutation",
|
|
200
|
+
userType: "authenticated",
|
|
201
|
+
access: [scriptPackagesAccess.manage],
|
|
202
|
+
}).output(BlobGcSummarySchema),
|
|
203
|
+
|
|
204
|
+
/** Last blob-GC run state (for the settings UI). */
|
|
205
|
+
getBlobGcState: proc({
|
|
206
|
+
operationType: "query",
|
|
207
|
+
userType: "authenticated",
|
|
208
|
+
access: [scriptPackagesAccess.manage],
|
|
209
|
+
}).output(BlobGcStateSchema),
|
|
210
|
+
|
|
211
|
+
// ─── Per-host status (manage) ──────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
listSatelliteSyncState: proc({
|
|
214
|
+
operationType: "query",
|
|
215
|
+
userType: "authenticated",
|
|
216
|
+
access: [scriptPackagesAccess.manage],
|
|
217
|
+
}).output(z.object({ items: z.array(SatelliteSyncStateSchema) })),
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Persist a satellite's reconcile state. Called by satellite-backend
|
|
221
|
+
* (server-side, on a `script_package_sync_state` WS report) so the admin
|
|
222
|
+
* UI can show per-satellite sync status. Read-gated because it's an
|
|
223
|
+
* internal server-to-server call, not a user mutation of managed config.
|
|
224
|
+
*/
|
|
225
|
+
reportSatelliteSyncState: proc({
|
|
226
|
+
operationType: "mutation",
|
|
227
|
+
userType: "authenticated",
|
|
228
|
+
access: [scriptPackagesAccess.read],
|
|
229
|
+
})
|
|
230
|
+
.input(
|
|
231
|
+
z.object({
|
|
232
|
+
satelliteId: z.string(),
|
|
233
|
+
lockfileHash: z.string().nullable(),
|
|
234
|
+
status: z.enum(["pending", "syncing", "ready", "error"]),
|
|
235
|
+
errorMessage: z.string().optional(),
|
|
236
|
+
}),
|
|
237
|
+
)
|
|
238
|
+
.output(z.object({ success: z.boolean() })),
|
|
239
|
+
|
|
240
|
+
// ─── Authoring / runtime (read) ────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
getInstallState: proc({
|
|
243
|
+
operationType: "query",
|
|
244
|
+
userType: "authenticated",
|
|
245
|
+
access: [scriptPackagesAccess.read],
|
|
246
|
+
}).output(InstallStateSchema),
|
|
247
|
+
|
|
248
|
+
/** Manifest for a given lockfile hash - used by reconcilers for delta diffing. */
|
|
249
|
+
getManifest: proc({
|
|
250
|
+
operationType: "query",
|
|
251
|
+
userType: "authenticated",
|
|
252
|
+
access: [scriptPackagesAccess.read],
|
|
253
|
+
})
|
|
254
|
+
.input(z.object({ lockfileHash: z.string() }))
|
|
255
|
+
.output(z.object({ entries: z.array(ManifestEntrySchema) })),
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Download one content-addressed blob (zstd-compressed) by integrity hash.
|
|
259
|
+
* The payload is base64 so it survives the JSON transport; satellites and
|
|
260
|
+
* cold core pods pull missing blobs this way.
|
|
261
|
+
*/
|
|
262
|
+
downloadBlob: proc({
|
|
263
|
+
operationType: "query",
|
|
264
|
+
userType: "authenticated",
|
|
265
|
+
access: [scriptPackagesAccess.read],
|
|
266
|
+
})
|
|
267
|
+
.input(z.object({ integrity: z.string() }))
|
|
268
|
+
.output(
|
|
269
|
+
z.object({
|
|
270
|
+
integrity: z.string(),
|
|
271
|
+
/** base64-encoded compressed blob bytes. */
|
|
272
|
+
data: z.string(),
|
|
273
|
+
sizeBytes: z.number().int().nonnegative(),
|
|
274
|
+
}),
|
|
275
|
+
),
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
export type ScriptPackagesContract = typeof scriptPackagesContract;
|
|
279
|
+
|
|
280
|
+
export const ScriptPackagesApi = createClientDefinition(
|
|
281
|
+
scriptPackagesContract,
|
|
282
|
+
pluginMetadata,
|
|
283
|
+
);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_BLOCK_BYTES,
|
|
4
|
+
DEFAULT_WARN_BYTES,
|
|
5
|
+
PackageNameSchema,
|
|
6
|
+
PackageVersionSchema,
|
|
7
|
+
RegistryConfigSchema,
|
|
8
|
+
SizeCapConfigSchema,
|
|
9
|
+
} from "./schemas";
|
|
10
|
+
|
|
11
|
+
describe("PackageNameSchema", () => {
|
|
12
|
+
test("accepts plain and scoped names", () => {
|
|
13
|
+
expect(PackageNameSchema.safeParse("lodash").success).toBe(true);
|
|
14
|
+
expect(PackageNameSchema.safeParse("@acme/utils").success).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
test("rejects uppercase / invalid names", () => {
|
|
17
|
+
expect(PackageNameSchema.safeParse("Lodash").success).toBe(false);
|
|
18
|
+
expect(PackageNameSchema.safeParse("../evil").success).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("PackageVersionSchema", () => {
|
|
23
|
+
test("accepts exact versions and rejects ranges", () => {
|
|
24
|
+
expect(PackageVersionSchema.safeParse("4.17.21").success).toBe(true);
|
|
25
|
+
expect(PackageVersionSchema.safeParse("1.0.0-beta.1").success).toBe(true);
|
|
26
|
+
expect(PackageVersionSchema.safeParse("^4.17.0").success).toBe(false);
|
|
27
|
+
expect(PackageVersionSchema.safeParse("latest").success).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("RegistryConfigSchema", () => {
|
|
32
|
+
test("ignoreScripts defaults to true (RCE mitigation)", () => {
|
|
33
|
+
const cfg = RegistryConfigSchema.parse({});
|
|
34
|
+
expect(cfg.ignoreScripts).toBe(true);
|
|
35
|
+
expect(cfg.hasAuthToken).toBe(false);
|
|
36
|
+
expect(cfg.registryUrl).toBe("https://registry.npmjs.org/");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("SizeCapConfigSchema", () => {
|
|
41
|
+
test("defaults to 150MB warn / 300MB block", () => {
|
|
42
|
+
const cap = SizeCapConfigSchema.parse({});
|
|
43
|
+
expect(cap.warnBytes).toBe(DEFAULT_WARN_BYTES);
|
|
44
|
+
expect(cap.blockBytes).toBe(DEFAULT_BLOCK_BYTES);
|
|
45
|
+
expect(DEFAULT_WARN_BYTES).toBe(150 * 1024 * 1024);
|
|
46
|
+
expect(DEFAULT_BLOCK_BYTES).toBe(300 * 1024 * 1024);
|
|
47
|
+
});
|
|
48
|
+
});
|
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Schemas for the script-packages platform.
|
|
5
|
+
*
|
|
6
|
+
* The admin curates a global allowlist of pinned npm packages
|
|
7
|
+
* (`name@exact-version`). The central backend resolves + bundles the
|
|
8
|
+
* package tree, publishes per-package content-addressed blobs to a
|
|
9
|
+
* pluggable blob store, and records a lockfile manifest. Every host that
|
|
10
|
+
* runs a user script (N core instances + each satellite) reconciles to
|
|
11
|
+
* the desired `lockfileHash` by delta-syncing only the blobs it lacks.
|
|
12
|
+
*
|
|
13
|
+
* See the feature plan for the full distribution model.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ─── Package allowlist ───────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/** A package name must be a valid (optionally scoped) npm package name. */
|
|
19
|
+
export const PackageNameSchema = z
|
|
20
|
+
.string()
|
|
21
|
+
.min(1)
|
|
22
|
+
.max(214)
|
|
23
|
+
.regex(
|
|
24
|
+
/^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/,
|
|
25
|
+
"Invalid npm package name",
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* An exact semver version. Pinned (no ranges) so installs are
|
|
30
|
+
* deterministic and the resulting tree is reproducible across hosts.
|
|
31
|
+
*/
|
|
32
|
+
export const PackageVersionSchema = z
|
|
33
|
+
.string()
|
|
34
|
+
.min(1)
|
|
35
|
+
.max(64)
|
|
36
|
+
.regex(
|
|
37
|
+
/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z-.]+)?$/,
|
|
38
|
+
"Version must be exact (e.g. 4.17.21), not a range",
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
/** One allowlist entry: a pinned package and whether it's currently enabled. */
|
|
42
|
+
export const PackageSpecSchema = z.object({
|
|
43
|
+
name: PackageNameSchema,
|
|
44
|
+
version: PackageVersionSchema,
|
|
45
|
+
enabled: z.boolean().default(true),
|
|
46
|
+
addedBy: z.string().nullable().optional(),
|
|
47
|
+
addedAt: z.coerce.date().optional(),
|
|
48
|
+
updatedAt: z.coerce.date().optional(),
|
|
49
|
+
});
|
|
50
|
+
export type PackageSpec = z.infer<typeof PackageSpecSchema>;
|
|
51
|
+
|
|
52
|
+
// ─── Registry autocomplete (live search / version lookup) ─────────────────
|
|
53
|
+
|
|
54
|
+
/** Input for `searchPackages`: a partial package-name query. */
|
|
55
|
+
export const SearchPackagesInputSchema = z.object({
|
|
56
|
+
text: z.string().min(1).max(214),
|
|
57
|
+
});
|
|
58
|
+
export type SearchPackagesInput = z.infer<typeof SearchPackagesInputSchema>;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One live search hit. `version` is relaxed to a plain string in the OUTPUT
|
|
62
|
+
* so a valid-but-unusual registry version is surfaced as a suggestion rather
|
|
63
|
+
* than dropped - strict `PackageVersionSchema` validation still applies on
|
|
64
|
+
* `addPackage`.
|
|
65
|
+
*/
|
|
66
|
+
export const PackageSearchHitSchema = z.object({
|
|
67
|
+
name: PackageNameSchema,
|
|
68
|
+
version: z.string().optional(),
|
|
69
|
+
description: z.string().optional(),
|
|
70
|
+
});
|
|
71
|
+
export type PackageSearchHit = z.infer<typeof PackageSearchHitSchema>;
|
|
72
|
+
|
|
73
|
+
export const SearchPackagesOutputSchema = z.object({
|
|
74
|
+
items: z.array(PackageSearchHitSchema),
|
|
75
|
+
});
|
|
76
|
+
export type SearchPackagesOutput = z.infer<typeof SearchPackagesOutputSchema>;
|
|
77
|
+
|
|
78
|
+
/** Input for `getPackageVersions`: an exact (optionally scoped) package name. */
|
|
79
|
+
export const GetPackageVersionsInputSchema = z.object({
|
|
80
|
+
name: PackageNameSchema,
|
|
81
|
+
});
|
|
82
|
+
export type GetPackageVersionsInput = z.infer<
|
|
83
|
+
typeof GetPackageVersionsInputSchema
|
|
84
|
+
>;
|
|
85
|
+
|
|
86
|
+
/** Versions newest-first plus dist-tags (e.g. `latest`). Versions relaxed. */
|
|
87
|
+
export const GetPackageVersionsOutputSchema = z.object({
|
|
88
|
+
versions: z.array(z.string()),
|
|
89
|
+
distTags: z.record(z.string(), z.string()).optional(),
|
|
90
|
+
});
|
|
91
|
+
export type GetPackageVersionsOutput = z.infer<
|
|
92
|
+
typeof GetPackageVersionsOutputSchema
|
|
93
|
+
>;
|
|
94
|
+
|
|
95
|
+
// ─── Registry config ─────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/** A scoped-registry override: `@scope` → registry URL. */
|
|
98
|
+
export const ScopedRegistrySchema = z.object({
|
|
99
|
+
scope: z
|
|
100
|
+
.string()
|
|
101
|
+
.regex(/^@[a-z0-9-~][a-z0-9-._~]*$/, "Scope must look like @acme"),
|
|
102
|
+
registryUrl: z.string().url(),
|
|
103
|
+
});
|
|
104
|
+
export type ScopedRegistry = z.infer<typeof ScopedRegistrySchema>;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Registry configuration rendered to `.npmrc` at install time. The auth
|
|
108
|
+
* token is stored as a connection-store secret and NEVER returned to the
|
|
109
|
+
* client in plaintext - the DTO carries only a boolean `hasAuthToken`.
|
|
110
|
+
*/
|
|
111
|
+
export const RegistryConfigSchema = z.object({
|
|
112
|
+
registryUrl: z.string().url().default("https://registry.npmjs.org/"),
|
|
113
|
+
scopedRegistries: z.array(ScopedRegistrySchema).default([]),
|
|
114
|
+
/** True when an auth token secret is configured (never the value). */
|
|
115
|
+
hasAuthToken: z.boolean().default(false),
|
|
116
|
+
/**
|
|
117
|
+
* `--ignore-scripts` toggle. Default ON: postinstall is the RCE vector
|
|
118
|
+
* and the size guardrail (native builds / binary downloads won't run).
|
|
119
|
+
*/
|
|
120
|
+
ignoreScripts: z.boolean().default(true),
|
|
121
|
+
updatedAt: z.coerce.date().optional(),
|
|
122
|
+
});
|
|
123
|
+
export type RegistryConfig = z.infer<typeof RegistryConfigSchema>;
|
|
124
|
+
|
|
125
|
+
/** Input for `setRegistryConfig`. The token is write-only. */
|
|
126
|
+
export const SetRegistryConfigInputSchema = z.object({
|
|
127
|
+
registryUrl: z.string().url(),
|
|
128
|
+
scopedRegistries: z.array(ScopedRegistrySchema).default([]),
|
|
129
|
+
ignoreScripts: z.boolean().default(true),
|
|
130
|
+
/**
|
|
131
|
+
* New auth token. Omit to leave the existing secret untouched; pass an
|
|
132
|
+
* empty string to clear it. Never echoed back.
|
|
133
|
+
*/
|
|
134
|
+
authToken: z.string().optional(),
|
|
135
|
+
});
|
|
136
|
+
export type SetRegistryConfigInput = z.infer<
|
|
137
|
+
typeof SetRegistryConfigInputSchema
|
|
138
|
+
>;
|
|
139
|
+
|
|
140
|
+
// ─── Lockfile manifest ─────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/** One resolved package in the lockfile manifest. */
|
|
143
|
+
export const ManifestEntrySchema = z.object({
|
|
144
|
+
name: PackageNameSchema,
|
|
145
|
+
version: PackageVersionSchema,
|
|
146
|
+
/** Integrity hash (sri, e.g. `sha512-...`) - the content-addressed key. */
|
|
147
|
+
integrity: z.string().min(1),
|
|
148
|
+
/**
|
|
149
|
+
* SHA-256 (hex) of the DISTRIBUTED blob (our gzip-tar of the Bun cache
|
|
150
|
+
* entry), computed at publish time. Unlike `integrity` (which hashes the
|
|
151
|
+
* upstream npm tarball, not our archive), this lets a host verify the
|
|
152
|
+
* transported bytes before extraction. Optional for backward
|
|
153
|
+
* compatibility: entries published before this field skip verification
|
|
154
|
+
* until the manifest is regenerated by a re-install.
|
|
155
|
+
*/
|
|
156
|
+
blobSha256: z.string().length(64).optional(),
|
|
157
|
+
});
|
|
158
|
+
export type ManifestEntry = z.infer<typeof ManifestEntrySchema>;
|
|
159
|
+
|
|
160
|
+
export const InstallStatusSchema = z.enum([
|
|
161
|
+
"idle",
|
|
162
|
+
"installing",
|
|
163
|
+
"ready",
|
|
164
|
+
"error",
|
|
165
|
+
]);
|
|
166
|
+
export type InstallStatus = z.infer<typeof InstallStatusSchema>;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Desired install state: the `lockfileHash` every host reconciles to,
|
|
170
|
+
* its manifest, and resolved total size. Surfaced to editors (for the
|
|
171
|
+
* "packages ready" gate) and reconcilers (for delta diffing).
|
|
172
|
+
*/
|
|
173
|
+
export const InstallStateSchema = z.object({
|
|
174
|
+
status: InstallStatusSchema,
|
|
175
|
+
lockfileHash: z.string().nullable(),
|
|
176
|
+
manifest: z.array(ManifestEntrySchema),
|
|
177
|
+
totalSizeBytes: z.number().int().nonnegative(),
|
|
178
|
+
lastInstalledAt: z.coerce.date().nullable(),
|
|
179
|
+
errorMessage: z.string().nullable(),
|
|
180
|
+
});
|
|
181
|
+
export type InstallState = z.infer<typeof InstallStateSchema>;
|
|
182
|
+
|
|
183
|
+
// ─── Storage config / migration ────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
/** Backend identifier for the active blob store. Open-ended (plugins). */
|
|
186
|
+
export const StorageBackendIdSchema = z.string().min(1);
|
|
187
|
+
|
|
188
|
+
export const MigrationStatusSchema = z.enum([
|
|
189
|
+
"idle",
|
|
190
|
+
"migrating",
|
|
191
|
+
"completed",
|
|
192
|
+
"error",
|
|
193
|
+
]);
|
|
194
|
+
export type MigrationStatus = z.infer<typeof MigrationStatusSchema>;
|
|
195
|
+
|
|
196
|
+
export const StorageConfigSchema = z.object({
|
|
197
|
+
activeBackend: StorageBackendIdSchema,
|
|
198
|
+
migrationStatus: MigrationStatusSchema,
|
|
199
|
+
migrationTarget: z.string().nullable(),
|
|
200
|
+
migratedCount: z.number().int().nonnegative(),
|
|
201
|
+
migrationError: z.string().nullable(),
|
|
202
|
+
updatedAt: z.coerce.date().optional(),
|
|
203
|
+
});
|
|
204
|
+
export type StorageConfig = z.infer<typeof StorageConfigSchema>;
|
|
205
|
+
|
|
206
|
+
// ─── Per-host reconcile state ───────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
export const HostSyncStatusSchema = z.enum([
|
|
209
|
+
"pending",
|
|
210
|
+
"syncing",
|
|
211
|
+
"ready",
|
|
212
|
+
"error",
|
|
213
|
+
]);
|
|
214
|
+
export type HostSyncStatus = z.infer<typeof HostSyncStatusSchema>;
|
|
215
|
+
|
|
216
|
+
export const SatelliteSyncStateSchema = z.object({
|
|
217
|
+
satelliteId: z.string(),
|
|
218
|
+
lockfileHash: z.string().nullable(),
|
|
219
|
+
status: HostSyncStatusSchema,
|
|
220
|
+
errorMessage: z.string().nullable(),
|
|
221
|
+
syncedAt: z.coerce.date().nullable(),
|
|
222
|
+
});
|
|
223
|
+
export type SatelliteSyncState = z.infer<typeof SatelliteSyncStateSchema>;
|
|
224
|
+
|
|
225
|
+
// ─── Editor IntelliSense (lazy ATA) ──────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* One declaration file in a package's type closure. The `path` is a real
|
|
229
|
+
* `node_modules/...`-relative path (e.g. `node_modules/@types/lodash/index.d.ts`
|
|
230
|
+
* or `node_modules/zod/index.d.ts`); the frontend registers it at
|
|
231
|
+
* `file:///<path>` so TypeScript's NodeJs + `@types` resolution finds it.
|
|
232
|
+
* Files are UNWRAPPED (no `declare module` envelope).
|
|
233
|
+
*/
|
|
234
|
+
export const PackageTypeFileSchema = z.object({
|
|
235
|
+
/** `node_modules/...`-relative virtual path. */
|
|
236
|
+
path: z.string(),
|
|
237
|
+
/** Verbatim file content. */
|
|
238
|
+
content: z.string(),
|
|
239
|
+
});
|
|
240
|
+
export type PackageTypeFile = z.infer<typeof PackageTypeFileSchema>;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The declaration-file closure for ONE requested specifier, resolved against
|
|
244
|
+
* the materialized package tree. Returned by the lazy ATA route.
|
|
245
|
+
*/
|
|
246
|
+
export const PackageTypeClosureSchema = z.object({
|
|
247
|
+
/** The requested bare specifier (e.g. `lodash`, `@babel/core`). */
|
|
248
|
+
specifier: z.string(),
|
|
249
|
+
/** Declaration files (own types and/or `@types` companion), unwrapped. */
|
|
250
|
+
files: z.array(PackageTypeFileSchema),
|
|
251
|
+
/** The package ships its own declarations. */
|
|
252
|
+
hasOwnTypes: z.boolean(),
|
|
253
|
+
/** A DefinitelyTyped `@types/...` companion exists in the tree. */
|
|
254
|
+
hasAtTypes: z.boolean(),
|
|
255
|
+
/** Neither own types nor an `@types` companion were found (graceful). */
|
|
256
|
+
notFound: z.boolean(),
|
|
257
|
+
/** The closure hit the size ceiling and dropped files (NOT silent). */
|
|
258
|
+
truncated: z.boolean(),
|
|
259
|
+
});
|
|
260
|
+
export type PackageTypeClosure = z.infer<typeof PackageTypeClosureSchema>;
|
|
261
|
+
|
|
262
|
+
// ─── Size guardrail ─────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Total-size cap. The admin UI warns above `warnBytes` and blocks installs
|
|
266
|
+
* above `blockBytes`. Defaults: warn 150 MB, block 300 MB (admin-configurable).
|
|
267
|
+
*/
|
|
268
|
+
export const SizeCapConfigSchema = z.object({
|
|
269
|
+
warnBytes: z
|
|
270
|
+
.number()
|
|
271
|
+
.int()
|
|
272
|
+
.positive()
|
|
273
|
+
.default(150 * 1024 * 1024),
|
|
274
|
+
blockBytes: z
|
|
275
|
+
.number()
|
|
276
|
+
.int()
|
|
277
|
+
.positive()
|
|
278
|
+
.default(300 * 1024 * 1024),
|
|
279
|
+
});
|
|
280
|
+
export type SizeCapConfig = z.infer<typeof SizeCapConfigSchema>;
|
|
281
|
+
|
|
282
|
+
export const DEFAULT_WARN_BYTES = 150 * 1024 * 1024;
|
|
283
|
+
export const DEFAULT_BLOCK_BYTES = 300 * 1024 * 1024;
|
|
284
|
+
|
|
285
|
+
// ─── Garbage collection ──────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* How many *previous* lockfile hashes (beyond the current desired one) the
|
|
289
|
+
* blob GC keeps blobs for. Small by design: enough for rollback / an
|
|
290
|
+
* in-flight reconcile toward a just-superseded hash, not an archive. The
|
|
291
|
+
* current hash is ALWAYS retained on top of this.
|
|
292
|
+
*/
|
|
293
|
+
export const DEFAULT_BLOB_GC_RETAIN_PREVIOUS = 1;
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Grace window (ms) below which an unreferenced blob is kept even though no
|
|
297
|
+
* retained manifest references it. Protects a core pod / satellite that is
|
|
298
|
+
* mid-reconcile toward a just-superseded hash: it may still pull a blob that
|
|
299
|
+
* was just dropped from the retained set. Keyed on `script_package_blob`
|
|
300
|
+
* `created_at`. Default: 24h.
|
|
301
|
+
*/
|
|
302
|
+
export const DEFAULT_BLOB_GC_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Grace window (ms) below which a non-current tree dir is kept even though
|
|
306
|
+
* `current` no longer points at it. Keyed on the tree dir's mtime and chosen
|
|
307
|
+
* to comfortably exceed the longest possible script-run timeout so a live run
|
|
308
|
+
* pinned to that tree (via its `resolutionRoot`) can never have its
|
|
309
|
+
* node_modules deleted out from under it. Default: 1h.
|
|
310
|
+
*/
|
|
311
|
+
export const DEFAULT_TREE_GC_GRACE_MS = 60 * 60 * 1000;
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Result summary of a blob-GC pass, returned by `gcBlobs` and surfaced in the
|
|
315
|
+
* Script Packages settings UI.
|
|
316
|
+
*/
|
|
317
|
+
export const BlobGcSummarySchema = z.object({
|
|
318
|
+
/** True when the pass actually ran (false when refused, e.g. lock held). */
|
|
319
|
+
ran: z.boolean(),
|
|
320
|
+
/** Populated when the pass was refused or aborted. */
|
|
321
|
+
reason: z.string().optional(),
|
|
322
|
+
/** Candidate blobs unreferenced by any retained manifest. */
|
|
323
|
+
candidates: z.number().int().nonnegative(),
|
|
324
|
+
/** Blobs actually deleted (past-grace candidates). */
|
|
325
|
+
deleted: z.number().int().nonnegative(),
|
|
326
|
+
/** Candidates kept because they are still within the grace window. */
|
|
327
|
+
keptWithinGrace: z.number().int().nonnegative(),
|
|
328
|
+
/** Bytes reclaimed by the deletions. */
|
|
329
|
+
bytesReclaimed: z.number().int().nonnegative(),
|
|
330
|
+
});
|
|
331
|
+
export type BlobGcSummary = z.infer<typeof BlobGcSummarySchema>;
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Persisted last-run state for the blob GC, so the admin UI can show "last
|
|
335
|
+
* reclaimed N bytes at T" without re-running the (destructive) pass.
|
|
336
|
+
*/
|
|
337
|
+
export const BlobGcStateSchema = z.object({
|
|
338
|
+
lastRunAt: z.coerce.date().nullable(),
|
|
339
|
+
lastDeleted: z.number().int().nonnegative(),
|
|
340
|
+
lastBytesReclaimed: z.number().int().nonnegative(),
|
|
341
|
+
/** Cumulative bytes reclaimed across every run. */
|
|
342
|
+
totalBytesReclaimed: z.number().int().nonnegative(),
|
|
343
|
+
});
|
|
344
|
+
export type BlobGcState = z.infer<typeof BlobGcStateSchema>;
|
package/src/signals.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createSignal } from "@checkstack/signal-common";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { pluginMetadata } from "./plugin-metadata";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fired when an install completes (or fails) so frontends invalidate the
|
|
7
|
+
* install-state + per-host status queries. The frontend signal is separate
|
|
8
|
+
* from the backend `script-packages.changed` hook (which drives reconcilers).
|
|
9
|
+
*/
|
|
10
|
+
export const SCRIPT_PACKAGES_CHANGED_SIGNAL = createSignal({
|
|
11
|
+
pluginMetadata,
|
|
12
|
+
event: "script_packages_changed",
|
|
13
|
+
payloadSchema: z.object({
|
|
14
|
+
lockfileHash: z.string().nullable(),
|
|
15
|
+
}),
|
|
16
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildTypeAcquisitionPath,
|
|
4
|
+
parseTypeAcquisitionPath,
|
|
5
|
+
TYPE_ACQUISITION_PATH_PREFIX,
|
|
6
|
+
} from "./type-acquisition";
|
|
7
|
+
|
|
8
|
+
describe("buildTypeAcquisitionPath", () => {
|
|
9
|
+
test("unscoped specifier", () => {
|
|
10
|
+
expect(
|
|
11
|
+
buildTypeAcquisitionPath({ lockfileHash: "abc123", specifier: "lodash" }),
|
|
12
|
+
).toBe(`${TYPE_ACQUISITION_PATH_PREFIX}/abc123/lodash`);
|
|
13
|
+
});
|
|
14
|
+
test("scoped specifier is percent-encoded into one segment", () => {
|
|
15
|
+
const built = buildTypeAcquisitionPath({
|
|
16
|
+
lockfileHash: "abc123",
|
|
17
|
+
specifier: "@babel/core",
|
|
18
|
+
});
|
|
19
|
+
expect(built).toBe(`${TYPE_ACQUISITION_PATH_PREFIX}/abc123/%40babel%2Fcore`);
|
|
20
|
+
// No un-encoded slash in the specifier segment.
|
|
21
|
+
expect(built.split("/").length).toBe(4); // "", "types", hash, encodedSpec
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("parseTypeAcquisitionPath", () => {
|
|
26
|
+
test("round-trips an unscoped specifier", () => {
|
|
27
|
+
const parsed = parseTypeAcquisitionPath("/abc123/lodash");
|
|
28
|
+
expect(parsed).toEqual({ lockfileHash: "abc123", specifier: "lodash" });
|
|
29
|
+
});
|
|
30
|
+
test("round-trips a scoped (encoded) specifier", () => {
|
|
31
|
+
const parsed = parseTypeAcquisitionPath("/abc123/%40babel%2Fcore");
|
|
32
|
+
expect(parsed).toEqual({
|
|
33
|
+
lockfileHash: "abc123",
|
|
34
|
+
specifier: "@babel/core",
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
test("build -> parse round trip", () => {
|
|
38
|
+
const path = buildTypeAcquisitionPath({
|
|
39
|
+
lockfileHash: "deadbeef",
|
|
40
|
+
specifier: "@scope/name",
|
|
41
|
+
}).slice(TYPE_ACQUISITION_PATH_PREFIX.length);
|
|
42
|
+
expect(parseTypeAcquisitionPath(path)).toEqual({
|
|
43
|
+
lockfileHash: "deadbeef",
|
|
44
|
+
specifier: "@scope/name",
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
test("rejects an un-encoded extra slash (path traversal guard)", () => {
|
|
48
|
+
expect(parseTypeAcquisitionPath("/abc123/foo/bar")).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
test("rejects missing segments", () => {
|
|
51
|
+
expect(parseTypeAcquisitionPath("/abc123")).toBeUndefined();
|
|
52
|
+
expect(parseTypeAcquisitionPath("/abc123/")).toBeUndefined();
|
|
53
|
+
expect(parseTypeAcquisitionPath("/")).toBeUndefined();
|
|
54
|
+
expect(parseTypeAcquisitionPath("")).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared contract for the lazy package-type Automatic Type Acquisition
|
|
3
|
+
* (ATA) HTTP route. The path is shared so the backend handler and the
|
|
4
|
+
* frontend resolver can never drift.
|
|
5
|
+
*
|
|
6
|
+
* The route is served as a RAW, HTTP-cacheable handler (not an oRPC
|
|
7
|
+
* procedure) so the browser caches each closure per-install: the path is
|
|
8
|
+
* keyed by the current `lockfileHash`, and the response carries
|
|
9
|
+
* `Cache-Control: private, max-age=..., immutable`. A new install changes
|
|
10
|
+
* the hash, so a stale install's URL is simply never requested again.
|
|
11
|
+
*
|
|
12
|
+
* Shape (within the plugin's `/api/script-packages` namespace):
|
|
13
|
+
*
|
|
14
|
+
* /types/:lockfileHash/:encodedSpecifier
|
|
15
|
+
*
|
|
16
|
+
* The specifier is percent-encoded so scoped names (`@babel/core`) survive
|
|
17
|
+
* the path segment.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Path prefix (within the plugin namespace) for the ATA route. */
|
|
21
|
+
export const TYPE_ACQUISITION_PATH_PREFIX = "/types";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build the plugin-relative request path for a specifier + lockfile hash.
|
|
25
|
+
* Pure; used by the frontend resolver. The specifier is percent-encoded so
|
|
26
|
+
* scoped packages (`@scope/name`) round-trip safely as one path segment.
|
|
27
|
+
*/
|
|
28
|
+
export function buildTypeAcquisitionPath({
|
|
29
|
+
lockfileHash,
|
|
30
|
+
specifier,
|
|
31
|
+
}: {
|
|
32
|
+
lockfileHash: string;
|
|
33
|
+
specifier: string;
|
|
34
|
+
}): string {
|
|
35
|
+
return `${TYPE_ACQUISITION_PATH_PREFIX}/${encodeURIComponent(
|
|
36
|
+
lockfileHash,
|
|
37
|
+
)}/${encodeURIComponent(specifier)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse a request pathname's trailing `:lockfileHash/:encodedSpecifier`
|
|
42
|
+
* segments back into the hash + decoded specifier. Pure; used by the
|
|
43
|
+
* backend handler. Returns undefined when the path doesn't match.
|
|
44
|
+
*
|
|
45
|
+
* `pathnameAfterPrefix` is the portion AFTER `TYPE_ACQUISITION_PATH_PREFIX`
|
|
46
|
+
* (e.g. `/<hash>/<encodedSpec>`), with or without a leading slash.
|
|
47
|
+
*/
|
|
48
|
+
export function parseTypeAcquisitionPath(
|
|
49
|
+
pathnameAfterPrefix: string,
|
|
50
|
+
): { lockfileHash: string; specifier: string } | undefined {
|
|
51
|
+
const trimmed = pathnameAfterPrefix.replace(/^\/+/, "");
|
|
52
|
+
// Exactly two segments: hash / encoded-specifier. A scoped specifier is a
|
|
53
|
+
// single encoded segment (its `/` is percent-encoded), so we split on the
|
|
54
|
+
// FIRST slash only.
|
|
55
|
+
const slash = trimmed.indexOf("/");
|
|
56
|
+
if (slash <= 0 || slash === trimmed.length - 1) return undefined;
|
|
57
|
+
const lockfileHash = trimmed.slice(0, slash);
|
|
58
|
+
const encodedSpecifier = trimmed.slice(slash + 1);
|
|
59
|
+
// Reject further path traversal: the specifier segment must not itself
|
|
60
|
+
// contain an un-encoded slash.
|
|
61
|
+
if (encodedSpecifier.includes("/")) return undefined;
|
|
62
|
+
let specifier: string;
|
|
63
|
+
try {
|
|
64
|
+
specifier = decodeURIComponent(encodedSpecifier);
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
if (lockfileHash.length === 0 || specifier.length === 0) return undefined;
|
|
69
|
+
return { lockfileHash, specifier };
|
|
70
|
+
}
|