@checkstack/script-packages-backend 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.
Files changed (66) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/drizzle/0000_flashy_squadron_supreme.sql +63 -0
  3. package/drizzle/0001_flawless_drax.sql +15 -0
  4. package/drizzle/meta/0000_snapshot.json +395 -0
  5. package/drizzle/meta/0001_snapshot.json +491 -0
  6. package/drizzle/meta/_journal.json +20 -0
  7. package/drizzle.config.ts +7 -0
  8. package/package.json +32 -0
  9. package/src/atomic-symlink.test.ts +47 -0
  10. package/src/atomic-symlink.ts +66 -0
  11. package/src/blob-gc-runner.test.ts +120 -0
  12. package/src/blob-gc-runner.ts +139 -0
  13. package/src/blob-gc.test.ts +182 -0
  14. package/src/blob-gc.ts +161 -0
  15. package/src/blob-hash.test.ts +70 -0
  16. package/src/blob-hash.ts +56 -0
  17. package/src/blob-store-registry.test.ts +78 -0
  18. package/src/blob-store-registry.ts +75 -0
  19. package/src/blob-store.ts +51 -0
  20. package/src/cache-archive.test.ts +164 -0
  21. package/src/cache-archive.ts +192 -0
  22. package/src/cache-layout.ts +64 -0
  23. package/src/data-dir.test.ts +41 -0
  24. package/src/data-dir.ts +42 -0
  25. package/src/e2e-install-reconcile.test.ts +121 -0
  26. package/src/hooks.ts +20 -0
  27. package/src/index.ts +594 -0
  28. package/src/install-controller.test.ts +257 -0
  29. package/src/install-controller.ts +144 -0
  30. package/src/install-service.test.ts +104 -0
  31. package/src/install-service.ts +116 -0
  32. package/src/install-state-store.ts +131 -0
  33. package/src/lockfile.test.ts +60 -0
  34. package/src/lockfile.ts +0 -0
  35. package/src/npmrc.test.ts +48 -0
  36. package/src/npmrc.ts +42 -0
  37. package/src/package-types.test.ts +293 -0
  38. package/src/package-types.ts +408 -0
  39. package/src/parse-bun-lock.test.ts +62 -0
  40. package/src/parse-bun-lock.ts +59 -0
  41. package/src/reconcile-diff.test.ts +41 -0
  42. package/src/reconcile-diff.ts +26 -0
  43. package/src/reconcile-fs.ts +199 -0
  44. package/src/reconciler.test.ts +289 -0
  45. package/src/reconciler.ts +81 -0
  46. package/src/registry-client.test.ts +314 -0
  47. package/src/registry-client.ts +0 -0
  48. package/src/registry-request-config.ts +63 -0
  49. package/src/registry-token.test.ts +124 -0
  50. package/src/registry-token.ts +104 -0
  51. package/src/resolution-root.test.ts +82 -0
  52. package/src/resolution-root.ts +127 -0
  53. package/src/resolver.test.ts +133 -0
  54. package/src/resolver.ts +132 -0
  55. package/src/router.ts +273 -0
  56. package/src/schema.ts +166 -0
  57. package/src/size-cap.test.ts +32 -0
  58. package/src/size-cap.ts +40 -0
  59. package/src/storage-migration.test.ts +318 -0
  60. package/src/storage-migration.ts +213 -0
  61. package/src/stores.ts +533 -0
  62. package/src/tree-gc.test.ts +184 -0
  63. package/src/tree-gc.ts +160 -0
  64. package/src/tree-retirement.ts +81 -0
  65. package/src/type-acquisition-route.ts +178 -0
  66. package/tsconfig.json +23 -0
@@ -0,0 +1,184 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, writeFile, rm, stat, utimes } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { sweepTreeGc } from "./tree-gc";
6
+ import { atomicSymlinkSwap } from "./atomic-symlink";
7
+ import { storePaths } from "./data-dir";
8
+ import { RETIRED_AT_MARKER } from "./tree-retirement";
9
+
10
+ const HOUR = 60 * 60 * 1000;
11
+
12
+ describe("sweepTreeGc", () => {
13
+ let storeRoot: string;
14
+
15
+ beforeEach(async () => {
16
+ storeRoot = await mkdtemp(path.join(tmpdir(), "cs-treegc-"));
17
+ });
18
+ afterEach(async () => {
19
+ await rm(storeRoot, { recursive: true, force: true });
20
+ });
21
+
22
+ /** Create `trees/<hash>/node_modules` and set its mtime `ageMs` in the past. */
23
+ async function makeTree(hash: string, ageMs: number) {
24
+ const paths = storePaths(storeRoot);
25
+ const dir = path.join(paths.trees, hash);
26
+ await mkdir(path.join(dir, "node_modules"), { recursive: true });
27
+ await writeFile(path.join(dir, "package.json"), "{}");
28
+ const when = new Date(Date.now() - ageMs);
29
+ await utimes(dir, when, when);
30
+ return dir;
31
+ }
32
+
33
+ /** Stamp a `.retired-at` marker `ageMs` in the past onto a tree dir. */
34
+ async function retireTree(hash: string, ageMs: number) {
35
+ const dir = path.join(storePaths(storeRoot).trees, hash);
36
+ await writeFile(
37
+ path.join(dir, RETIRED_AT_MARKER),
38
+ `${Date.now() - ageMs}\n`,
39
+ );
40
+ }
41
+
42
+ async function exists(dir: string): Promise<boolean> {
43
+ try {
44
+ await stat(dir);
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ async function pointCurrentAt(hash: string) {
52
+ const paths = storePaths(storeRoot);
53
+ await atomicSymlinkSwap({
54
+ linkPath: paths.current,
55
+ target: path.join("trees", hash),
56
+ });
57
+ }
58
+
59
+ test("never deletes the current tree, regardless of age", async () => {
60
+ const cur = await makeTree("hashCur", 100 * HOUR); // very old
61
+ await pointCurrentAt("hashCur");
62
+
63
+ const res = await sweepTreeGc({ storeRoot, graceMs: HOUR });
64
+ expect(res.currentHash).toBe("hashCur");
65
+ expect(res.deleted).toEqual([]);
66
+ expect(await exists(cur)).toBe(true);
67
+ });
68
+
69
+ test("prunes a non-current tree retired past the grace window", async () => {
70
+ const cur = await makeTree("hashCur", 0);
71
+ const old = await makeTree("hashOld", 100 * HOUR); // ancient mtime
72
+ await retireTree("hashOld", 5 * HOUR); // but retired 5h ago > grace
73
+ await pointCurrentAt("hashCur");
74
+
75
+ const res = await sweepTreeGc({ storeRoot, graceMs: HOUR });
76
+ expect(res.deleted).toEqual(["hashOld"]);
77
+ expect(await exists(old)).toBe(false);
78
+ expect(await exists(cur)).toBe(true);
79
+ });
80
+
81
+ test("keeps a non-current tree still within the grace window (live run may be pinned)", async () => {
82
+ const recent = await makeTree("hashRecent", 100 * HOUR); // ancient mtime
83
+ await retireTree("hashRecent", 0.25 * HOUR); // retired 15 min ago < grace
84
+ await makeTree("hashCur", 0);
85
+ await pointCurrentAt("hashCur");
86
+
87
+ const res = await sweepTreeGc({ storeRoot, graceMs: HOUR });
88
+ expect(res.deleted).toEqual([]);
89
+ expect(res.keptWithinGrace).toContain("hashRecent");
90
+ expect(await exists(recent)).toBe(true);
91
+ });
92
+
93
+ test("respects the grace boundary using the injected clock", async () => {
94
+ await makeTree("hashCur", 0);
95
+ const old = path.join(storePaths(storeRoot).trees, "hashEdge");
96
+ await mkdir(path.join(old, "node_modules"), { recursive: true });
97
+ await retireTree("hashEdge", 2 * HOUR);
98
+ await pointCurrentAt("hashCur");
99
+
100
+ // now far in the future → well past grace-since-retirement → deleted.
101
+ const res = await sweepTreeGc({
102
+ storeRoot,
103
+ graceMs: HOUR,
104
+ now: Date.now() + 10 * HOUR,
105
+ });
106
+ expect(res.deleted).toContain("hashEdge");
107
+ });
108
+
109
+ test("back-fills a marker for an unmarked non-current tree and retains it that pass", async () => {
110
+ await makeTree("hashCur", 0);
111
+ const orphan = await makeTree("hashOrphan", 100 * HOUR); // ancient, no marker
112
+ await pointCurrentAt("hashCur");
113
+
114
+ // First pass: no retirement marker → retained + back-filled.
115
+ const first = await sweepTreeGc({ storeRoot, graceMs: HOUR });
116
+ expect(first.deleted).toEqual([]);
117
+ expect(first.keptWithinGrace).toContain("hashOrphan");
118
+ expect(await exists(orphan)).toBe(true);
119
+ expect(await exists(path.join(orphan, RETIRED_AT_MARKER))).toBe(true);
120
+
121
+ // A later pass past grace (from the back-filled time) deletes it.
122
+ const later = await sweepTreeGc({
123
+ storeRoot,
124
+ graceMs: HOUR,
125
+ now: Date.now() + 10 * HOUR,
126
+ });
127
+ expect(later.deleted).toContain("hashOrphan");
128
+ expect(await exists(orphan)).toBe(false);
129
+ });
130
+
131
+ test("no trees dir → no-op", async () => {
132
+ const res = await sweepTreeGc({ storeRoot, graceMs: HOUR });
133
+ expect(res.deleted).toEqual([]);
134
+ expect(res.keptWithinGrace).toEqual([]);
135
+ });
136
+
137
+ // ── H3 regression: grace must key on RETIREMENT time, not materialize mtime ──
138
+ //
139
+ // A tree that was `current` for days has an old mtime. The instant it is
140
+ // superseded by a flip it would (under the old mtime-keyed logic) be
141
+ // INSTANTLY eligible — and sweepTreeGc runs right after a flip, so it would
142
+ // delete a tree an in-flight run is still pinned to. The grace window must
143
+ // start ticking from when the tree BECAME non-current, not from its mtime.
144
+
145
+ test("retains a long-current tree the instant it is superseded by a flip", async () => {
146
+ // Tree A has been current for ~days (very old mtime).
147
+ const treeA = await makeTree("hashA", 100 * HOUR);
148
+ // Tree B has just been materialized.
149
+ await makeTree("hashB", 0);
150
+
151
+ // Flip current A -> ... -> B (A becomes non-current right now).
152
+ await pointCurrentAt("hashA");
153
+ await pointCurrentAt("hashB");
154
+
155
+ // Immediately sweep. A's mtime is ancient, but it only just retired, so it
156
+ // MUST be retained (a run started before the flip is still pinned to it).
157
+ const res = await sweepTreeGc({ storeRoot, graceMs: HOUR });
158
+ expect(res.currentHash).toBe("hashB");
159
+ expect(res.deleted).toEqual([]);
160
+ expect(res.keptWithinGrace).toContain("hashA");
161
+ expect(await exists(treeA)).toBe(true);
162
+ });
163
+
164
+ test("deletes a superseded tree only once grace-since-RETIREMENT elapses", async () => {
165
+ const treeA = await makeTree("hashA", 100 * HOUR);
166
+ await makeTree("hashB", 0);
167
+ await pointCurrentAt("hashA");
168
+ await pointCurrentAt("hashB"); // A retires now
169
+
170
+ // Within grace from retirement → retained.
171
+ const within = await sweepTreeGc({ storeRoot, graceMs: HOUR });
172
+ expect(within.deleted).toEqual([]);
173
+ expect(await exists(treeA)).toBe(true);
174
+
175
+ // Well past grace from retirement (injected clock) → deleted.
176
+ const after = await sweepTreeGc({
177
+ storeRoot,
178
+ graceMs: HOUR,
179
+ now: Date.now() + 10 * HOUR,
180
+ });
181
+ expect(after.deleted).toContain("hashA");
182
+ expect(await exists(treeA)).toBe(false);
183
+ });
184
+ });
package/src/tree-gc.ts ADDED
@@ -0,0 +1,160 @@
1
+ import { readdir, stat, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { DEFAULT_TREE_GC_GRACE_MS } from "@checkstack/script-packages-common";
4
+ import { extractErrorMessage } from "@checkstack/common";
5
+ import { storePaths } from "./data-dir";
6
+ import { readCurrentTarget } from "./atomic-symlink";
7
+ import { markTreeRetired, readTreeRetiredAt } from "./tree-retirement";
8
+
9
+ /**
10
+ * Host-local tree garbage collection: prune materialized
11
+ * `trees/<lockfileHash>/` dirs whose hash is NOT the one `current` points at,
12
+ * after a grace period. Applies identically to core pods and satellites (the
13
+ * materialize/flip code is shared, so this sweep is too).
14
+ *
15
+ * ## Why the conservative grace window (active-run safety)
16
+ *
17
+ * The runner pins in-flight runs to the tree they STARTED on by snapshotting
18
+ * `resolutionRoot` (which dereferences `<store>/current` at run start). After
19
+ * an atomic flip, a live run keeps resolving against the old tree's
20
+ * `node_modules`; deleting that tree mid-run would break the run.
21
+ *
22
+ * There is no robust cross-process refcount available here: runs execute in
23
+ * throwaway Bun subprocesses under `.runs/<uuid>/` whose `resolutionRoot`
24
+ * points at `current` (a symlink), not at a specific tree, and a crashed
25
+ * run leaves no reliable "release" signal. A naive refcount would leak on
26
+ * crash and could under-count, risking deletion of a live tree. So we take
27
+ * the **robust** option: a grace window chosen to comfortably exceed the
28
+ * longest possible script-run timeout. A tree only becomes eligible once it
29
+ * has been non-current for longer than any run could still be using it. When
30
+ * in doubt, we retain.
31
+ *
32
+ * ## Why grace is keyed on RETIREMENT time, not dir mtime
33
+ *
34
+ * A tree's mtime reflects when it was MATERIALIZED, not when it stopped being
35
+ * current. A tree that served as `current` for days has an ancient mtime, so
36
+ * keying the grace window on mtime makes it eligible for deletion the instant
37
+ * it is superseded — and this sweep runs right after a flip. That would
38
+ * delete a tree an in-flight run (which snapshotted `resolutionRoot` before
39
+ * the flip) is still pinned to. Instead the flip stamps a `.retired-at`
40
+ * marker into the superseded tree (see `tree-retirement.ts`), and the grace
41
+ * window is measured from THAT timestamp.
42
+ *
43
+ * A non-current tree with NO marker (e.g. one superseded before this scheme
44
+ * existed, or where the flip-time marker write failed) is RETAINED and the
45
+ * marker is lazily back-filled with `now`, so it ages out on subsequent
46
+ * sweeps instead of leaking forever — but is never deleted on a missing
47
+ * signal.
48
+ *
49
+ * `current`'s target is NEVER a candidate, regardless of age.
50
+ */
51
+
52
+ export interface TreeGcResult {
53
+ /** Tree hashes deleted this pass. */
54
+ deleted: string[];
55
+ /** Non-current tree hashes kept because still within the grace window. */
56
+ keptWithinGrace: string[];
57
+ /** The hash `current` points at (never a candidate), if any. */
58
+ currentHash?: string;
59
+ }
60
+
61
+ /**
62
+ * Sweep the `trees/` dir of one host store, deleting non-current trees older
63
+ * than the grace window. Idempotent and crash-safe (a partially-deleted tree
64
+ * is simply re-swept next pass).
65
+ *
66
+ * @param storeRoot the package-store root (`<dataDir>/script-packages`).
67
+ * @param graceMs grace window in ms (default 1h, > the max run timeout).
68
+ * @param now injectable clock for deterministic tests.
69
+ */
70
+ export async function sweepTreeGc({
71
+ storeRoot,
72
+ graceMs = DEFAULT_TREE_GC_GRACE_MS,
73
+ now = Date.now(),
74
+ logger,
75
+ }: {
76
+ storeRoot: string;
77
+ graceMs?: number;
78
+ now?: number;
79
+ logger?: { debug(msg: string): void; error(msg: string): void };
80
+ }): Promise<TreeGcResult> {
81
+ const paths = storePaths(storeRoot);
82
+
83
+ // The hash `current` points at (basename of `trees/<hash>`). NEVER deleted.
84
+ const currentTarget = await readCurrentTarget(paths.current);
85
+ const currentHash = currentTarget
86
+ ? path.basename(currentTarget)
87
+ : undefined;
88
+
89
+ let entries: string[];
90
+ try {
91
+ entries = await readdir(paths.trees);
92
+ } catch {
93
+ // No trees dir yet → nothing to GC.
94
+ return { deleted: [], keptWithinGrace: [], currentHash };
95
+ }
96
+
97
+ const deleted: string[] = [];
98
+ const keptWithinGrace: string[] = [];
99
+
100
+ for (const hash of entries) {
101
+ if (hash === currentHash) continue; // never delete the live tree
102
+
103
+ const treeDir = path.join(paths.trees, hash);
104
+ let info: Awaited<ReturnType<typeof stat>>;
105
+ try {
106
+ info = await stat(treeDir);
107
+ } catch {
108
+ continue; // vanished between readdir and stat; nothing to do
109
+ }
110
+ if (!info.isDirectory()) continue;
111
+
112
+ // Grace keyed on RETIREMENT time (the flip stamps `.retired-at` into the
113
+ // superseded tree), NOT the dir mtime. A long-current tree has an ancient
114
+ // mtime but only just retired, so mtime would make it instantly eligible
115
+ // and this sweep (run right after a flip) would delete a tree a live run
116
+ // is still pinned to.
117
+ const retiredAt = await readTreeRetiredAt({ treeDir });
118
+ if (retiredAt === undefined) {
119
+ // No marker yet: never delete on a missing signal. Back-fill `now` so
120
+ // the tree ages out on later sweeps instead of leaking forever, and
121
+ // retain it this pass.
122
+ await markTreeRetired({ treeDir, at: now });
123
+ keptWithinGrace.push(hash);
124
+ logger?.debug(
125
+ `Tree GC: non-current tree ${hash} has no retirement marker; ` +
126
+ `back-filling now and retaining this pass.`,
127
+ );
128
+ continue;
129
+ }
130
+
131
+ const ageMs = now - retiredAt;
132
+ if (ageMs < graceMs) {
133
+ keptWithinGrace.push(hash);
134
+ logger?.debug(
135
+ `Tree GC: keeping non-current tree ${hash} (retired ${Math.round(
136
+ ageMs / 1000,
137
+ )}s ago < grace ${Math.round(graceMs / 1000)}s; a run may still be pinned).`,
138
+ );
139
+ continue;
140
+ }
141
+
142
+ try {
143
+ await rm(treeDir, { recursive: true, force: true });
144
+ deleted.push(hash);
145
+ logger?.debug(`Tree GC: deleted non-current tree ${hash}.`);
146
+ } catch (error) {
147
+ logger?.error(
148
+ `Tree GC: failed to delete tree ${hash}: ${extractErrorMessage(
149
+ error,
150
+ )}. Retaining for the next pass.`,
151
+ );
152
+ }
153
+ }
154
+
155
+ logger?.debug(
156
+ `Tree GC complete: current=${currentHash ?? "(none)"}, ${deleted.length} deleted, ${keptWithinGrace.length} kept within grace.`,
157
+ );
158
+
159
+ return { deleted, keptWithinGrace, currentHash };
160
+ }
@@ -0,0 +1,81 @@
1
+ import { readFile, writeFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Per-tree "became-non-current-at" sentinel.
6
+ *
7
+ * Tree GC must key its grace window on the moment a tree RETIRED (stopped
8
+ * being `current`), not on the tree dir's mtime. A tree that was `current`
9
+ * for days has an ancient mtime, so keying grace on mtime makes it eligible
10
+ * for deletion the instant it is superseded — while an in-flight run is still
11
+ * pinned to it (the runner snapshots `resolutionRoot` at run start). Keying on
12
+ * retirement time guarantees a tree is only collected once it has been
13
+ * non-current for longer than any run could still be using it.
14
+ *
15
+ * The marker is a tiny file `<treeDir>/.retired-at` holding the epoch-ms
16
+ * timestamp of retirement. It is written at flip time (in
17
+ * {@link import("./atomic-symlink").atomicSymlinkSwap}) for the tree being
18
+ * superseded. Tree GC reads it; a tree with no marker yet is RETAINED (and
19
+ * lazily back-filled — see `tree-gc.ts`) so we never delete on a missing
20
+ * signal.
21
+ */
22
+
23
+ /** Sentinel file name written into a tree dir when it stops being current. */
24
+ export const RETIRED_AT_MARKER = ".retired-at";
25
+
26
+ /**
27
+ * Record that the tree at `treeDir` became non-current at `at` (epoch ms).
28
+ * Best-effort and idempotent-by-overwrite; never throws (the caller is on a
29
+ * flip hot path where a marker write must not fail the flip).
30
+ */
31
+ export async function markTreeRetired({
32
+ treeDir,
33
+ at,
34
+ }: {
35
+ treeDir: string;
36
+ at: number;
37
+ }): Promise<void> {
38
+ try {
39
+ await writeFile(path.join(treeDir, RETIRED_AT_MARKER), `${at}\n`, "utf8");
40
+ } catch {
41
+ // A failed marker write degrades to the "no marker" path in tree-gc,
42
+ // which RETAINS the tree — safe by construction.
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Read the retirement timestamp (epoch ms) for the tree at `treeDir`, or
48
+ * `undefined` when no marker exists (or it is unreadable / malformed).
49
+ */
50
+ export async function readTreeRetiredAt({
51
+ treeDir,
52
+ }: {
53
+ treeDir: string;
54
+ }): Promise<number | undefined> {
55
+ let raw: string;
56
+ try {
57
+ raw = await readFile(path.join(treeDir, RETIRED_AT_MARKER), "utf8");
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ const parsed = Number.parseInt(raw.trim(), 10);
62
+ return Number.isFinite(parsed) ? parsed : undefined;
63
+ }
64
+
65
+ /**
66
+ * True when `treeDir` already carries a retirement marker. Used by tree-GC to
67
+ * decide whether to back-fill one for a tree that is non-current but has no
68
+ * marker yet (e.g. a tree superseded before this scheme existed).
69
+ */
70
+ export async function hasRetirementMarker({
71
+ treeDir,
72
+ }: {
73
+ treeDir: string;
74
+ }): Promise<boolean> {
75
+ try {
76
+ await stat(path.join(treeDir, RETIRED_AT_MARKER));
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
@@ -0,0 +1,178 @@
1
+ import path from "node:path";
2
+ import type { AuthService, Logger } from "@checkstack/backend-api";
3
+ import {
4
+ parseTypeAcquisitionPath,
5
+ scriptPackagesAccess,
6
+ TYPE_ACQUISITION_PATH_PREFIX,
7
+ type PackageTypeClosure,
8
+ } from "@checkstack/script-packages-common";
9
+ import { extractErrorMessage } from "@checkstack/common";
10
+ import { resolvePackageTypeClosure } from "./package-types";
11
+ import { storePaths } from "./data-dir";
12
+
13
+ /**
14
+ * Raw, HTTP-cacheable route serving one package's `.d.ts` closure for editor
15
+ * lazy ATA. Mounted (via `rpc.registerHttpHandler`) at
16
+ * `/api/script-packages/types/:lockfileHash/:encodedSpecifier`.
17
+ *
18
+ * Why a raw route and not an oRPC procedure: oRPC responses in this codebase
19
+ * cannot set custom HTTP response headers (the Hono `/api` handler builds the
20
+ * RpcContext WITHOUT a `responseHeaders` bag, so a procedure can't emit
21
+ * `Cache-Control`). The user explicitly wants HTTP-level caching keyed to the
22
+ * install, so we serve a raw handler and set the headers directly.
23
+ *
24
+ * Caching: the path carries the current `lockfileHash`, and the response sets
25
+ * `Cache-Control: private, max-age=<1y>, immutable`. A given (hash, specifier)
26
+ * closure is immutable for the life of that install; a new install changes the
27
+ * hash, so the browser simply requests a fresh URL and never reuses the old
28
+ * cache entry. `private` because the registry (and thus the types) may be
29
+ * private.
30
+ *
31
+ * Auth: this is a raw route, so it bypasses the oRPC auto-auth middleware. We
32
+ * authenticate the request and enforce the same global `script-packages.read`
33
+ * access the (removed) oRPC procedure used. The resource has no instance-level
34
+ * access, so a global-access check is exactly equivalent.
35
+ */
36
+
37
+ /** One year. The (hash, specifier) closure is immutable for the install. */
38
+ const CACHE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
39
+
40
+ interface CreateTypeClosureHttpHandlerDeps {
41
+ auth: AuthService;
42
+ /** Returns the current desired lockfile hash (null = no packages). */
43
+ getLockfileHash: () => Promise<string | null>;
44
+ storeRoot: string;
45
+ logger: Logger;
46
+ }
47
+
48
+ /**
49
+ * Whether the authenticated user holds global `script-packages.read`.
50
+ * Services are trusted; users/applications must carry the rule (or `*`).
51
+ */
52
+ function hasReadAccess(
53
+ user: Awaited<ReturnType<AuthService["authenticate"]>>,
54
+ ): boolean {
55
+ if (!user) return false;
56
+ if (user.type === "service") return true;
57
+ const rules = user.accessRules ?? [];
58
+ return (
59
+ rules.includes("*") || rules.includes(scriptPackagesAccess.read.id)
60
+ );
61
+ }
62
+
63
+ function jsonResponse(body: unknown, status: number): Response {
64
+ return Response.json(body, { status });
65
+ }
66
+
67
+ export function createTypeClosureHttpHandler({
68
+ auth,
69
+ getLockfileHash,
70
+ storeRoot,
71
+ logger,
72
+ }: CreateTypeClosureHttpHandlerDeps): (req: Request) => Promise<Response> {
73
+ return async (req: Request): Promise<Response> => {
74
+ if (req.method !== "GET") {
75
+ return jsonResponse({ error: "Method not allowed" }, 405);
76
+ }
77
+
78
+ // ─── Auth ─────────────────────────────────────────────────────────────
79
+ let user: Awaited<ReturnType<AuthService["authenticate"]>>;
80
+ try {
81
+ user = await auth.authenticate(req);
82
+ } catch (error) {
83
+ logger.error(
84
+ `Type-closure auth failed: ${extractErrorMessage(error)}`,
85
+ );
86
+ return jsonResponse({ error: "Unauthorized" }, 401);
87
+ }
88
+ if (!user) return jsonResponse({ error: "Unauthorized" }, 401);
89
+ if (!hasReadAccess(user)) {
90
+ return jsonResponse({ error: "Forbidden" }, 403);
91
+ }
92
+
93
+ // ─── Parse path ───────────────────────────────────────────────────────
94
+ const pathname = new URL(req.url).pathname;
95
+ const prefixIndex = pathname.indexOf(TYPE_ACQUISITION_PATH_PREFIX);
96
+ if (prefixIndex === -1) {
97
+ return jsonResponse({ error: "Not found" }, 404);
98
+ }
99
+ const afterPrefix = pathname.slice(
100
+ prefixIndex + TYPE_ACQUISITION_PATH_PREFIX.length,
101
+ );
102
+ const parsed = parseTypeAcquisitionPath(afterPrefix);
103
+ if (!parsed) {
104
+ return jsonResponse({ error: "Bad request" }, 400);
105
+ }
106
+ const { lockfileHash, specifier } = parsed;
107
+
108
+ // ─── Hash must match the current install ──────────────────────────────
109
+ // We only retain the current materialized tree; an old hash's tree may be
110
+ // gone. A mismatch means the client's install state is stale — tell it so
111
+ // it refetches install state and retries against the new hash.
112
+ let currentHash: string | null;
113
+ try {
114
+ currentHash = await getLockfileHash();
115
+ } catch (error) {
116
+ logger.error(
117
+ `Type-closure install-state load failed: ${extractErrorMessage(error)}`,
118
+ );
119
+ return jsonResponse({ error: "Internal error" }, 500);
120
+ }
121
+ if (currentHash === null) {
122
+ // No packages configured: nothing to acquire. Cacheable empty result.
123
+ return cacheableClosure({
124
+ specifier,
125
+ files: [],
126
+ hasOwnTypes: false,
127
+ hasAtTypes: false,
128
+ notFound: true,
129
+ truncated: false,
130
+ });
131
+ }
132
+ if (currentHash !== lockfileHash) {
133
+ // Don't cache a stale-hash miss (no immutable guarantee).
134
+ return jsonResponse(
135
+ { error: "Stale lockfile hash; refetch install state." },
136
+ 409,
137
+ );
138
+ }
139
+
140
+ // ─── Resolve the closure off the materialized tree ────────────────────
141
+ const nodeModulesDir = path.join(
142
+ storePaths(storeRoot).trees,
143
+ lockfileHash,
144
+ "node_modules",
145
+ );
146
+ let closure: PackageTypeClosure;
147
+ try {
148
+ closure = await resolvePackageTypeClosure({
149
+ nodeModulesDir,
150
+ specifier,
151
+ });
152
+ } catch (error) {
153
+ logger.error(
154
+ `Type-closure resolution failed for "${specifier}": ${extractErrorMessage(
155
+ error,
156
+ )}`,
157
+ );
158
+ return jsonResponse({ error: "Internal error" }, 500);
159
+ }
160
+ if (closure.truncated) {
161
+ logger.warn(
162
+ `Type closure for "${specifier}" (${lockfileHash}) hit the size ceiling; some declarations were dropped.`,
163
+ );
164
+ }
165
+ return cacheableClosure(closure);
166
+ };
167
+ }
168
+
169
+ function cacheableClosure(closure: PackageTypeClosure): Response {
170
+ return Response.json(closure, {
171
+ status: 200,
172
+ headers: {
173
+ // Per-install immutable: the (hash, specifier) closure never changes
174
+ // for the life of the install. `private` because types may be private.
175
+ "Cache-Control": `private, max-age=${CACHE_MAX_AGE_SECONDS}, immutable`,
176
+ },
177
+ });
178
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ],
6
+ "references": [
7
+ {
8
+ "path": "../backend-api"
9
+ },
10
+ {
11
+ "path": "../common"
12
+ },
13
+ {
14
+ "path": "../script-packages-common"
15
+ },
16
+ {
17
+ "path": "../secrets-backend"
18
+ },
19
+ {
20
+ "path": "../secrets-common"
21
+ }
22
+ ]
23
+ }