@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,314 @@
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ searchPackages,
4
+ getPackageVersions,
5
+ registryUrlForName,
6
+ RegistryClientError,
7
+ type PackageSearchResult,
8
+ } from "./registry-client";
9
+ import type { RegistryRequestConfig } from "./registry-request-config";
10
+
11
+ const npmRegistry: RegistryRequestConfig = {
12
+ registryUrl: "https://registry.npmjs.org/",
13
+ scopedRegistries: [],
14
+ };
15
+
16
+ const realFetch = globalThis.fetch;
17
+
18
+ afterEach(() => {
19
+ globalThis.fetch = realFetch;
20
+ });
21
+
22
+ /**
23
+ * Read a header from the recorded `RequestInit` without a type cast: the
24
+ * client always passes a plain object of string headers, so narrow `unknown`
25
+ * with zod rather than asserting the type.
26
+ */
27
+ function headerValue(init: RequestInit | undefined, key: string): string | undefined {
28
+ const headers = init?.headers;
29
+ if (!headers) return undefined;
30
+ if (headers instanceof Headers) return headers.get(key) ?? undefined;
31
+ if (Array.isArray(headers)) {
32
+ return headers.find(([k]) => k === key)?.[1];
33
+ }
34
+ const value: unknown = headers[key];
35
+ return typeof value === "string" ? value : undefined;
36
+ }
37
+
38
+ /** Install a fetch mock that records calls and returns a JSON body. */
39
+ function mockFetch(
40
+ handler: (url: string, init?: RequestInit) => {
41
+ ok?: boolean;
42
+ status?: number;
43
+ json: unknown;
44
+ } | Promise<never>,
45
+ ) {
46
+ const calls: { url: string; init?: RequestInit }[] = [];
47
+ globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
48
+ const url = typeof input === "string" ? input : input.toString();
49
+ calls.push({ url, init });
50
+ const result = await handler(url, init);
51
+ return {
52
+ ok: result.ok ?? true,
53
+ status: result.status ?? 200,
54
+ json: async () => result.json,
55
+ } as Response;
56
+ }) as unknown as typeof fetch;
57
+ return calls;
58
+ }
59
+
60
+ describe("searchPackages", () => {
61
+ test("maps objects[].package to name/version/description", async () => {
62
+ mockFetch(() => ({
63
+ json: {
64
+ objects: [
65
+ { package: { name: "lodash", version: "4.17.21", description: "utils" } },
66
+ { package: { name: "lodash-es", version: "4.17.21" } },
67
+ ],
68
+ },
69
+ }));
70
+ const items = await searchPackages({
71
+ registry: npmRegistry,
72
+ text: "lodash",
73
+ now: 1,
74
+ });
75
+ expect(items).toEqual([
76
+ { name: "lodash", version: "4.17.21", description: "utils" },
77
+ { name: "lodash-es", version: "4.17.21", description: undefined },
78
+ ]);
79
+ });
80
+
81
+ test("hits the -/v1/search endpoint and caps size at 25", async () => {
82
+ const calls = mockFetch(() => ({ json: { objects: [] } }));
83
+ await searchPackages({
84
+ registry: npmRegistry,
85
+ text: "react",
86
+ size: 1000,
87
+ now: 1,
88
+ });
89
+ expect(calls[0].url).toBe(
90
+ "https://registry.npmjs.org/-/v1/search?text=react&size=25",
91
+ );
92
+ });
93
+
94
+ test("does NOT send Authorization when no token", async () => {
95
+ const calls = mockFetch(() => ({ json: { objects: [] } }));
96
+ await searchPackages({ registry: npmRegistry, text: "no-auth", now: 1 });
97
+ expect(headerValue(calls[0].init, "authorization")).toBeUndefined();
98
+ });
99
+
100
+ test("sends Authorization: Bearer when a token is set", async () => {
101
+ const calls = mockFetch(() => ({ json: { objects: [] } }));
102
+ await searchPackages({
103
+ registry: { ...npmRegistry, authToken: "s3cret" },
104
+ text: "with-auth",
105
+ now: 1,
106
+ });
107
+ expect(headerValue(calls[0].init, "authorization")).toBe("Bearer s3cret");
108
+ });
109
+
110
+ test("non-200 → RegistryClientError without the token", async () => {
111
+ mockFetch(() => ({ ok: false, status: 503, json: {} }));
112
+ const promise = searchPackages({
113
+ registry: { ...npmRegistry, authToken: "TOPSECRET" },
114
+ text: "non-200-case",
115
+ now: 1,
116
+ });
117
+ await expect(promise).rejects.toBeInstanceOf(RegistryClientError);
118
+ await promise.catch((error: unknown) => {
119
+ const message = error instanceof Error ? error.message : String(error);
120
+ expect(message).not.toContain("TOPSECRET");
121
+ });
122
+ });
123
+
124
+ test("timeout (abort) → RegistryClientError without the token", async () => {
125
+ // Honor the AbortController: reject only once the signal fires, exactly
126
+ // as a real timed-out fetch would, so the client sees `signal.aborted`.
127
+ globalThis.fetch = mock(
128
+ (_input: RequestInfo | URL, init?: RequestInit) =>
129
+ new Promise<never>((_resolve, reject) => {
130
+ init?.signal?.addEventListener("abort", () =>
131
+ reject(new Error("aborted")),
132
+ );
133
+ }),
134
+ ) as unknown as typeof fetch;
135
+ const promise = searchPackages({
136
+ registry: { ...npmRegistry, authToken: "TOPSECRET" },
137
+ text: "abort-case",
138
+ now: 1,
139
+ timeoutMs: 5,
140
+ });
141
+ await expect(promise).rejects.toBeInstanceOf(RegistryClientError);
142
+ await promise.catch((error: unknown) => {
143
+ const message = error instanceof Error ? error.message : String(error);
144
+ expect(message).toContain("timed out");
145
+ expect(message).not.toContain("TOPSECRET");
146
+ });
147
+ });
148
+
149
+ test("cache returns within TTL, refetches after it expires", async () => {
150
+ let hits = 0;
151
+ mockFetch(() => {
152
+ hits += 1;
153
+ return {
154
+ json: {
155
+ objects: [{ package: { name: `pkg-${hits}`, version: "1.0.0" } }],
156
+ },
157
+ };
158
+ });
159
+ const a = await searchPackages({
160
+ registry: npmRegistry,
161
+ text: "cache-me",
162
+ now: 1000,
163
+ });
164
+ const b = await searchPackages({
165
+ registry: npmRegistry,
166
+ text: "cache-me",
167
+ now: 2000,
168
+ });
169
+ expect(hits).toBe(1);
170
+ expect(b).toEqual(a);
171
+
172
+ // Past the 45s TTL → refetch.
173
+ const c = await searchPackages({
174
+ registry: npmRegistry,
175
+ text: "cache-me",
176
+ now: 1000 + 46_000,
177
+ });
178
+ expect(hits).toBe(2);
179
+ expect(c).not.toEqual(a);
180
+ });
181
+ });
182
+
183
+ describe("getPackageVersions", () => {
184
+ test("returns versions newest-first and parses dist-tags", async () => {
185
+ mockFetch(() => ({
186
+ json: {
187
+ versions: {
188
+ "1.0.0": {},
189
+ "1.2.0": {},
190
+ "1.10.0": {},
191
+ "2.0.0-rc.1": {},
192
+ },
193
+ "dist-tags": { latest: "1.10.0", next: "2.0.0-rc.1" },
194
+ },
195
+ }));
196
+ const result = await getPackageVersions({
197
+ registry: npmRegistry,
198
+ name: "lodash",
199
+ now: 1,
200
+ });
201
+ expect(result.versions).toEqual([
202
+ "2.0.0-rc.1",
203
+ "1.10.0",
204
+ "1.2.0",
205
+ "1.0.0",
206
+ ]);
207
+ expect(result.distTags).toEqual({ latest: "1.10.0", next: "2.0.0-rc.1" });
208
+ });
209
+
210
+ test("a release outranks its prerelease of the same core version", async () => {
211
+ mockFetch(() => ({
212
+ json: {
213
+ versions: { "2.0.0": {}, "2.0.0-rc.1": {} },
214
+ "dist-tags": {},
215
+ },
216
+ }));
217
+ const result = await getPackageVersions({
218
+ registry: npmRegistry,
219
+ name: "release-vs-pre",
220
+ now: 1,
221
+ });
222
+ expect(result.versions).toEqual(["2.0.0", "2.0.0-rc.1"]);
223
+ expect(result.distTags).toBeUndefined();
224
+ });
225
+
226
+ test("tolerates an odd version string instead of discarding the response", async () => {
227
+ mockFetch(() => ({
228
+ json: {
229
+ versions: { "1.0.0": {}, latest: {}, "2.0.0": {} },
230
+ "dist-tags": {},
231
+ },
232
+ }));
233
+ const result = await getPackageVersions({
234
+ registry: npmRegistry,
235
+ name: "odd-version",
236
+ now: 1,
237
+ });
238
+ // Parseable versions first (desc), the odd one ("latest") sorted last.
239
+ expect(result.versions).toEqual(["2.0.0", "1.0.0", "latest"]);
240
+ });
241
+
242
+ test("encodes a scoped name and selects its scoped registry", async () => {
243
+ const calls = mockFetch(() => ({
244
+ json: { versions: { "1.0.0": {} }, "dist-tags": {} },
245
+ }));
246
+ await getPackageVersions({
247
+ registry: {
248
+ registryUrl: "https://registry.npmjs.org/",
249
+ scopedRegistries: [
250
+ { scope: "@acme", registryUrl: "https://npm.acme.com/" },
251
+ ],
252
+ },
253
+ name: "@acme/utils",
254
+ now: 1,
255
+ });
256
+ expect(calls[0].url).toBe("https://npm.acme.com/@acme%2Futils");
257
+ });
258
+
259
+ test("falls back to the base registry for an unmatched scope", async () => {
260
+ const calls = mockFetch(() => ({
261
+ json: { versions: { "1.0.0": {} }, "dist-tags": {} },
262
+ }));
263
+ await getPackageVersions({
264
+ registry: npmRegistry,
265
+ name: "@other/thing",
266
+ now: 1,
267
+ });
268
+ expect(calls[0].url).toBe(
269
+ "https://registry.npmjs.org/@other%2Fthing",
270
+ );
271
+ });
272
+
273
+ test("caches versions within the TTL keyed by registry+name", async () => {
274
+ let hits = 0;
275
+ mockFetch(() => {
276
+ hits += 1;
277
+ return { json: { versions: { "1.0.0": {} }, "dist-tags": {} } };
278
+ });
279
+ await getPackageVersions({ registry: npmRegistry, name: "cached-pkg", now: 5000 });
280
+ await getPackageVersions({ registry: npmRegistry, name: "cached-pkg", now: 6000 });
281
+ expect(hits).toBe(1);
282
+ await getPackageVersions({
283
+ registry: npmRegistry,
284
+ name: "cached-pkg",
285
+ now: 5000 + 46_000,
286
+ });
287
+ expect(hits).toBe(2);
288
+ });
289
+ });
290
+
291
+ describe("registryUrlForName", () => {
292
+ test("unscoped name uses the base registry", () => {
293
+ expect(
294
+ registryUrlForName({ name: "lodash", registry: npmRegistry }),
295
+ ).toBe("https://registry.npmjs.org/");
296
+ });
297
+
298
+ test("scoped name uses a matching scoped registry", () => {
299
+ const url = registryUrlForName({
300
+ name: "@acme/x",
301
+ registry: {
302
+ ...npmRegistry,
303
+ scopedRegistries: [
304
+ { scope: "@acme", registryUrl: "https://npm.acme.com/" },
305
+ ],
306
+ },
307
+ });
308
+ expect(url).toBe("https://npm.acme.com/");
309
+ });
310
+ });
311
+
312
+ // Type-level guard: the exported result shape stays mappable.
313
+ const _typeCheck: PackageSearchResult = { name: "x" };
314
+ void _typeCheck;
Binary file
@@ -0,0 +1,63 @@
1
+ import { extractErrorMessage } from "@checkstack/common";
2
+ import type { Logger } from "@checkstack/backend-api";
3
+ import type { RegistryTokenStore } from "./registry-token";
4
+
5
+ /**
6
+ * The registry request shape shared by the install path (resolver / npmrc
7
+ * renderer) and the live registry-client (search / version lookup): the
8
+ * configured registry URL, scoped-registry overrides, and the resolved
9
+ * plaintext auth token (undefined for an anonymous registry).
10
+ *
11
+ * The token is NEVER returned to the client; it only ever flows into a
12
+ * server-side `.npmrc` (install) or an `Authorization` header (live lookup).
13
+ */
14
+ export interface RegistryRequestConfig {
15
+ registryUrl: string;
16
+ scopedRegistries: { scope: string; registryUrl: string }[];
17
+ /** Resolved plaintext token, or undefined for an anonymous registry. */
18
+ authToken?: string;
19
+ }
20
+
21
+ /**
22
+ * Resolve the registry + auth token exactly the way the install path does:
23
+ * read the registry config, look up the stored secret ref, then resolve the
24
+ * plaintext token (platform marker or legacy inline ciphertext) via the
25
+ * token store. A failure to resolve the token is logged and treated as
26
+ * anonymous (the registry may still allow public reads) rather than throwing.
27
+ *
28
+ * Factored out of the inline install wiring so the RPC handlers and the
29
+ * installer share one source of truth for "how do we talk to the registry".
30
+ */
31
+ export async function resolveRegistryRequestConfig({
32
+ registry,
33
+ registryToken,
34
+ logger,
35
+ }: {
36
+ registry: {
37
+ get(): Promise<{
38
+ registryUrl: string;
39
+ scopedRegistries: { scope: string; registryUrl: string }[];
40
+ }>;
41
+ authSecretRef(): Promise<string | null>;
42
+ };
43
+ registryToken: RegistryTokenStore;
44
+ logger: Logger;
45
+ }): Promise<RegistryRequestConfig> {
46
+ const reg = await registry.get();
47
+ const secretRef = await registry.authSecretRef();
48
+ let authToken: string | undefined;
49
+ if (secretRef) {
50
+ try {
51
+ authToken = await registryToken.resolve(secretRef);
52
+ } catch (error) {
53
+ logger.error(
54
+ `Failed to resolve registry auth token: ${extractErrorMessage(error)}`,
55
+ );
56
+ }
57
+ }
58
+ return {
59
+ registryUrl: reg.registryUrl,
60
+ scopedRegistries: reg.scopedRegistries,
61
+ authToken,
62
+ };
63
+ }
@@ -0,0 +1,124 @@
1
+ // AES-256 master key for the legacy-ciphertext path. Set before importing
2
+ // the encryption module so encrypt()/decrypt() succeed.
3
+ process.env.ENCRYPTION_MASTER_KEY = "22".repeat(32);
4
+
5
+ import { describe, it, expect } from "bun:test";
6
+ import { encrypt } from "@checkstack/backend-api";
7
+ import { internalSecretName } from "@checkstack/secrets-common";
8
+ import type { InternalSecretsService } from "@checkstack/secrets-backend";
9
+ import {
10
+ createRegistryTokenStore,
11
+ migrateRegistryTokenToPlatform,
12
+ REGISTRY_TOKEN_MARKER,
13
+ isRegistryTokenMarker,
14
+ } from "./registry-token";
15
+
16
+ function fakeInternalSecrets(): InternalSecretsService & {
17
+ store: Map<string, string>;
18
+ } {
19
+ const store = new Map<string, string>();
20
+ return {
21
+ store,
22
+ set: async ({ parts, value }) => {
23
+ store.set(internalSecretName(...parts), value);
24
+ },
25
+ get: async ({ parts }) => store.get(internalSecretName(...parts)),
26
+ delete: async ({ parts }) => {
27
+ store.delete(internalSecretName(...parts));
28
+ },
29
+ };
30
+ }
31
+
32
+ describe("REGISTRY_TOKEN_MARKER", () => {
33
+ it("is the reserved internal name for the token", () => {
34
+ expect(REGISTRY_TOKEN_MARKER).toBe(
35
+ internalSecretName("script-packages", "registry-auth-token"),
36
+ );
37
+ expect(isRegistryTokenMarker(REGISTRY_TOKEN_MARKER)).toBe(true);
38
+ expect(isRegistryTokenMarker("iv:tag:ct")).toBe(false);
39
+ });
40
+ });
41
+
42
+ describe("RegistryTokenStore", () => {
43
+ it("store() persists the token and returns the marker; resolve() reads it back", async () => {
44
+ const internal = fakeInternalSecrets();
45
+ const tokenStore = createRegistryTokenStore({ internalSecrets: internal });
46
+
47
+ const marker = await tokenStore.store("npm_realToken");
48
+ expect(marker).toBe(REGISTRY_TOKEN_MARKER);
49
+ expect(await tokenStore.resolve(marker)).toBe("npm_realToken");
50
+ });
51
+
52
+ it("resolve() decrypts a legacy inline ciphertext (backward-compat)", async () => {
53
+ const internal = fakeInternalSecrets();
54
+ const tokenStore = createRegistryTokenStore({ internalSecrets: internal });
55
+ const legacy = encrypt("legacy_token_value");
56
+ expect(await tokenStore.resolve(legacy)).toBe("legacy_token_value");
57
+ });
58
+
59
+ it("resolve() returns undefined for null / unrecognized refs", async () => {
60
+ const internal = fakeInternalSecrets();
61
+ const tokenStore = createRegistryTokenStore({ internalSecrets: internal });
62
+ expect(await tokenStore.resolve(null)).toBeUndefined();
63
+ expect(await tokenStore.resolve("not-encrypted-not-marker")).toBeUndefined();
64
+ });
65
+
66
+ it("clear() removes the stored token", async () => {
67
+ const internal = fakeInternalSecrets();
68
+ const tokenStore = createRegistryTokenStore({ internalSecrets: internal });
69
+ await tokenStore.store("x");
70
+ await tokenStore.clear();
71
+ expect(await tokenStore.resolve(REGISTRY_TOKEN_MARKER)).toBeUndefined();
72
+ });
73
+ });
74
+
75
+ describe("migrateRegistryTokenToPlatform", () => {
76
+ it("migrates legacy ciphertext: stores it on the platform + rewrites the column to the marker (parity-verified)", async () => {
77
+ const internal = fakeInternalSecrets();
78
+ const tokenStore = createRegistryTokenStore({ internalSecrets: internal });
79
+ const legacy = encrypt("token-to-migrate");
80
+
81
+ let rewritten: string | undefined;
82
+ const outcome = await migrateRegistryTokenToPlatform({
83
+ currentRef: legacy,
84
+ tokenStore,
85
+ rewrite: async (marker) => {
86
+ rewritten = marker;
87
+ },
88
+ });
89
+
90
+ expect(outcome).toBe("migrated");
91
+ expect(rewritten).toBe(REGISTRY_TOKEN_MARKER);
92
+ // The platform now resolves the same plaintext.
93
+ expect(await tokenStore.resolve(REGISTRY_TOKEN_MARKER)).toBe(
94
+ "token-to-migrate",
95
+ );
96
+ });
97
+
98
+ it("is idempotent: re-running with the marker is a no-op", async () => {
99
+ const internal = fakeInternalSecrets();
100
+ const tokenStore = createRegistryTokenStore({ internalSecrets: internal });
101
+ let rewrites = 0;
102
+ const outcome = await migrateRegistryTokenToPlatform({
103
+ currentRef: REGISTRY_TOKEN_MARKER,
104
+ tokenStore,
105
+ rewrite: async () => {
106
+ rewrites++;
107
+ },
108
+ });
109
+ expect(outcome).toBe("already");
110
+ expect(rewrites).toBe(0);
111
+ });
112
+
113
+ it("no-ops for an empty column", async () => {
114
+ const internal = fakeInternalSecrets();
115
+ const tokenStore = createRegistryTokenStore({ internalSecrets: internal });
116
+ expect(
117
+ await migrateRegistryTokenToPlatform({
118
+ currentRef: null,
119
+ tokenStore,
120
+ rewrite: async () => {},
121
+ }),
122
+ ).toBe("empty");
123
+ });
124
+ });
@@ -0,0 +1,104 @@
1
+ import { decrypt, isEncrypted } from "@checkstack/backend-api";
2
+ import { internalSecretName } from "@checkstack/secrets-common";
3
+ import type { InternalSecretsService } from "@checkstack/secrets-backend";
4
+
5
+ /**
6
+ * The script-package registry auth token, consolidated onto the secrets
7
+ * platform's internal secrets (always local-backed, AES-GCM, UI-hidden).
8
+ *
9
+ * The `script_package_registry_config.authSecretRef` column historically
10
+ * held the AES-GCM ciphertext of the token directly. It now holds a
11
+ * stable MARKER (the internal secret name) when the token lives in the
12
+ * secrets platform. A one-time, idempotent, parity-verified migration moves
13
+ * any legacy ciphertext into the internal store and rewrites the column to
14
+ * the marker. Resolution falls back to decrypting legacy ciphertext until
15
+ * the migration runs, so behavior is unchanged throughout.
16
+ */
17
+ const TOKEN_PARTS = ["script-packages", "registry-auth-token"] as const;
18
+
19
+ /** Stable marker stored in `authSecretRef` once the token is in the platform. */
20
+ export const REGISTRY_TOKEN_MARKER = internalSecretName(...TOKEN_PARTS);
21
+
22
+ /** Whether a stored `authSecretRef` value is the platform marker. */
23
+ export function isRegistryTokenMarker(ref: string): boolean {
24
+ return ref === REGISTRY_TOKEN_MARKER;
25
+ }
26
+
27
+ export interface RegistryTokenStore {
28
+ /** Store / rotate the token; returns the marker to persist in the column. */
29
+ store(token: string): Promise<string>;
30
+ /** Clear the stored token. */
31
+ clear(): Promise<void>;
32
+ /**
33
+ * Resolve the plaintext token from a stored `authSecretRef` value. Handles
34
+ * both the platform marker (resolve via internal secrets) and legacy
35
+ * inline ciphertext (decrypt directly). Returns undefined when absent.
36
+ */
37
+ resolve(ref: string | null): Promise<string | undefined>;
38
+ }
39
+
40
+ export function createRegistryTokenStore({
41
+ internalSecrets,
42
+ }: {
43
+ internalSecrets: InternalSecretsService;
44
+ }): RegistryTokenStore {
45
+ return {
46
+ async store(token) {
47
+ await internalSecrets.set({ parts: [...TOKEN_PARTS], value: token });
48
+ return REGISTRY_TOKEN_MARKER;
49
+ },
50
+
51
+ async clear() {
52
+ await internalSecrets.delete({ parts: [...TOKEN_PARTS] });
53
+ },
54
+
55
+ async resolve(ref) {
56
+ if (!ref) return;
57
+ if (isRegistryTokenMarker(ref)) {
58
+ return internalSecrets.get({ parts: [...TOKEN_PARTS] });
59
+ }
60
+ // Legacy inline ciphertext (pre-migration). Decrypt directly.
61
+ if (isEncrypted(ref)) {
62
+ return decrypt(ref);
63
+ }
64
+ return;
65
+ },
66
+ };
67
+ }
68
+
69
+ /**
70
+ * One-time, idempotent migration: if `authSecretRef` holds legacy
71
+ * ciphertext, decrypt it, store it as the internal platform secret, verify
72
+ * the platform read-back matches, then rewrite the column to the marker.
73
+ * Reversible: the original plaintext is recoverable from the internal
74
+ * secret (same value), and the rewrite only happens AFTER parity is proven.
75
+ * No-ops when already migrated (marker) or empty.
76
+ *
77
+ * @returns "migrated" | "already" | "empty" | "skipped" (parity failed)
78
+ */
79
+ export async function migrateRegistryTokenToPlatform({
80
+ currentRef,
81
+ tokenStore,
82
+ rewrite,
83
+ }: {
84
+ currentRef: string | null;
85
+ tokenStore: RegistryTokenStore;
86
+ /** Persist the new `authSecretRef` (the marker). */
87
+ rewrite: (marker: string) => Promise<void>;
88
+ }): Promise<"migrated" | "already" | "empty" | "skipped"> {
89
+ if (!currentRef) return "empty";
90
+ if (isRegistryTokenMarker(currentRef)) return "already";
91
+ if (!isEncrypted(currentRef)) return "skipped";
92
+
93
+ const plaintext = decrypt(currentRef);
94
+ await tokenStore.store(plaintext);
95
+
96
+ // Verify parity BEFORE rewriting the column (reversible guarantee: we
97
+ // never drop the legacy ref until the platform copy reads back correctly).
98
+ const readBack = await tokenStore.resolve(REGISTRY_TOKEN_MARKER);
99
+ if (readBack !== plaintext) {
100
+ return "skipped";
101
+ }
102
+ await rewrite(REGISTRY_TOKEN_MARKER);
103
+ return "migrated";
104
+ }
@@ -0,0 +1,82 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, rm, symlink } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { resolveResolutionRoot } from "./resolution-root";
6
+ import { storePaths } from "./data-dir";
7
+
8
+ describe("resolveResolutionRoot", () => {
9
+ let storeRoot: string;
10
+
11
+ beforeEach(async () => {
12
+ storeRoot = path.join(await mkdtemp(path.join(tmpdir(), "cs-resroot-")), "store");
13
+ await mkdir(storeRoot, { recursive: true });
14
+ });
15
+ afterEach(async () => {
16
+ await rm(path.dirname(storeRoot), { recursive: true, force: true });
17
+ });
18
+
19
+ async function materialize(hash: string) {
20
+ const paths = storePaths(storeRoot);
21
+ await mkdir(path.join(paths.trees, hash, "node_modules"), {
22
+ recursive: true,
23
+ });
24
+ await symlink(path.join("trees", hash), paths.current);
25
+ }
26
+
27
+ test("returns mode:none when no packages are configured", async () => {
28
+ const res = await resolveResolutionRoot({
29
+ desiredLockfileHash: null,
30
+ storeRoot,
31
+ });
32
+ expect(res.mode).toBe("none");
33
+ });
34
+
35
+ test("returns mode:ready with the current path when materialized", async () => {
36
+ await materialize("hash-1");
37
+ const res = await resolveResolutionRoot({
38
+ desiredLockfileHash: "hash-1",
39
+ storeRoot,
40
+ });
41
+ expect(res.mode).toBe("ready");
42
+ if (res.mode === "ready") {
43
+ expect(res.root).toBe(storePaths(storeRoot).current);
44
+ }
45
+ });
46
+
47
+ test("returns mode:notReady when packages desired but nothing materialized", async () => {
48
+ const res = await resolveResolutionRoot({
49
+ desiredLockfileHash: "hash-1",
50
+ storeRoot,
51
+ hostLabel: "satellite",
52
+ });
53
+ expect(res.mode).toBe("notReady");
54
+ if (res.mode === "notReady") {
55
+ expect(res.reason).toContain("not ready on this satellite");
56
+ expect(res.reason).toContain("none materialized");
57
+ }
58
+ });
59
+
60
+ test("returns mode:notReady when current points at a DIFFERENT hash", async () => {
61
+ await materialize("old-hash");
62
+ const res = await resolveResolutionRoot({
63
+ desiredLockfileHash: "new-hash",
64
+ storeRoot,
65
+ });
66
+ expect(res.mode).toBe("notReady");
67
+ if (res.mode === "notReady") {
68
+ expect(res.reason).toContain("have old-hash");
69
+ }
70
+ });
71
+
72
+ test("returns mode:notReady when current exists but the tree lacks node_modules", async () => {
73
+ const paths = storePaths(storeRoot);
74
+ await mkdir(path.join(paths.trees, "hash-1"), { recursive: true });
75
+ await symlink(path.join("trees", "hash-1"), paths.current);
76
+ const res = await resolveResolutionRoot({
77
+ desiredLockfileHash: "hash-1",
78
+ storeRoot,
79
+ });
80
+ expect(res.mode).toBe("notReady");
81
+ });
82
+ });