@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 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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/common.json",
3
+ "include": [
4
+ "src"
5
+ ],
6
+ "references": [
7
+ {
8
+ "path": "../common"
9
+ },
10
+ {
11
+ "path": "../signal-common"
12
+ }
13
+ ]
14
+ }