vana-cli 0.24.5 → 0.26.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 (103) hide show
  1. package/README.md +1 -0
  2. package/dist/cli/auth.d.ts +2 -0
  3. package/dist/cli/auth.d.ts.map +1 -1
  4. package/dist/cli/auth.js +2 -1
  5. package/dist/cli/auth.js.map +1 -1
  6. package/dist/cli/index.d.ts +2 -0
  7. package/dist/cli/index.d.ts.map +1 -1
  8. package/dist/cli/index.js +128 -13
  9. package/dist/cli/index.js.map +1 -1
  10. package/dist/cli/server-start.d.ts +42 -0
  11. package/dist/cli/server-start.d.ts.map +1 -0
  12. package/dist/cli/server-start.js +189 -0
  13. package/dist/cli/server-start.js.map +1 -0
  14. package/dist/connectors/registry.d.ts +2 -0
  15. package/dist/connectors/registry.d.ts.map +1 -1
  16. package/dist/connectors/registry.js +18 -2
  17. package/dist/connectors/registry.js.map +1 -1
  18. package/dist/core/cli-types.d.ts +44 -0
  19. package/dist/core/cli-types.d.ts.map +1 -1
  20. package/dist/core/cli-types.js +14 -0
  21. package/dist/core/cli-types.js.map +1 -1
  22. package/dist/core/state-store.d.ts +6 -0
  23. package/dist/core/state-store.d.ts.map +1 -1
  24. package/dist/core/state-store.js.map +1 -1
  25. package/dist/pdpp/host.d.ts +66 -0
  26. package/dist/pdpp/host.d.ts.map +1 -0
  27. package/dist/pdpp/host.js +283 -0
  28. package/dist/pdpp/host.js.map +1 -0
  29. package/dist/pdpp/installer.d.ts +17 -0
  30. package/dist/pdpp/installer.d.ts.map +1 -0
  31. package/dist/pdpp/installer.js +117 -0
  32. package/dist/pdpp/installer.js.map +1 -0
  33. package/dist/pdpp/local.d.ts +14 -0
  34. package/dist/pdpp/local.d.ts.map +1 -0
  35. package/dist/pdpp/local.js +76 -0
  36. package/dist/pdpp/local.js.map +1 -0
  37. package/dist/pdpp/pins.d.ts +23 -0
  38. package/dist/pdpp/pins.d.ts.map +1 -0
  39. package/dist/pdpp/pins.js +18 -0
  40. package/dist/pdpp/pins.js.map +1 -0
  41. package/dist/pdpp/profile.d.ts +49 -0
  42. package/dist/pdpp/profile.d.ts.map +1 -0
  43. package/dist/pdpp/profile.js +17 -0
  44. package/dist/pdpp/profile.js.map +1 -0
  45. package/dist/pdpp/protocol.d.ts +79 -0
  46. package/dist/pdpp/protocol.d.ts.map +1 -0
  47. package/dist/pdpp/protocol.js +372 -0
  48. package/dist/pdpp/protocol.js.map +1 -0
  49. package/dist/pdpp/runtime.d.ts +43 -0
  50. package/dist/pdpp/runtime.d.ts.map +1 -0
  51. package/dist/pdpp/runtime.js +387 -0
  52. package/dist/pdpp/runtime.js.map +1 -0
  53. package/dist/pdpp/store.d.ts +43 -0
  54. package/dist/pdpp/store.d.ts.map +1 -0
  55. package/dist/pdpp/store.js +100 -0
  56. package/dist/pdpp/store.js.map +1 -0
  57. package/dist/personal-server/index.d.ts +1 -0
  58. package/dist/personal-server/index.d.ts.map +1 -1
  59. package/dist/personal-server/index.js +17 -2
  60. package/dist/personal-server/index.js.map +1 -1
  61. package/dist/personal-server/local/config.d.ts +23 -0
  62. package/dist/personal-server/local/config.d.ts.map +1 -0
  63. package/dist/personal-server/local/config.js +51 -0
  64. package/dist/personal-server/local/config.js.map +1 -0
  65. package/dist/personal-server/local/owner-binding.d.ts +33 -0
  66. package/dist/personal-server/local/owner-binding.d.ts.map +1 -0
  67. package/dist/personal-server/local/owner-binding.js +155 -0
  68. package/dist/personal-server/local/owner-binding.js.map +1 -0
  69. package/dist/personal-server/local/owner-secret.d.ts +17 -0
  70. package/dist/personal-server/local/owner-secret.d.ts.map +1 -0
  71. package/dist/personal-server/local/owner-secret.js +119 -0
  72. package/dist/personal-server/local/owner-secret.js.map +1 -0
  73. package/dist/personal-server/local/runtime-pkg/entry.mjs +153 -0
  74. package/dist/personal-server/local/runtime-pkg/package-lock.json +2342 -0
  75. package/dist/personal-server/local/runtime-pkg/package.json +13 -0
  76. package/dist/personal-server/local/runtime.d.ts +9 -0
  77. package/dist/personal-server/local/runtime.d.ts.map +1 -0
  78. package/dist/personal-server/local/runtime.js +66 -0
  79. package/dist/personal-server/local/runtime.js.map +1 -0
  80. package/dist/personal-server/local/server.d.ts +39 -0
  81. package/dist/personal-server/local/server.d.ts.map +1 -0
  82. package/dist/personal-server/local/server.js +203 -0
  83. package/dist/personal-server/local/server.js.map +1 -0
  84. package/dist/runtime/core/contracts.d.ts +2 -0
  85. package/dist/runtime/core/contracts.d.ts.map +1 -1
  86. package/dist/runtime/managed-playwright.d.ts +7 -0
  87. package/dist/runtime/managed-playwright.d.ts.map +1 -1
  88. package/dist/runtime/managed-playwright.js +7 -0
  89. package/dist/runtime/managed-playwright.js.map +1 -1
  90. package/dist/vendor/pdpp-connector-manager/LICENSE +202 -0
  91. package/dist/vendor/pdpp-connector-manager/NOTICE +2 -0
  92. package/dist/vendor/pdpp-connector-manager/VENDOR.md +27 -0
  93. package/dist/vendor/pdpp-connector-manager/catalog-schema-data.mjs +168 -0
  94. package/dist/vendor/pdpp-connector-manager/catalog-schema.mjs +62 -0
  95. package/dist/vendor/pdpp-connector-manager/index.d.mts +49 -0
  96. package/dist/vendor/pdpp-connector-manager/index.mjs +1646 -0
  97. package/dist/vendor/pdpp-connector-manager/oci-catalog.mjs +93 -0
  98. package/dist/vendor/pdpp-connector-manager/oci-registry.mjs +1002 -0
  99. package/dist/vendor/pdpp-connector-manager/oci-verify.mjs +515 -0
  100. package/dist/vendor/pdpp-connector-manager/package.json +51 -0
  101. package/dist/vendor/pdpp-connector-manager/retry.mjs +142 -0
  102. package/dist/vendor/pdpp-connector-manager/tar-stream.mjs +685 -0
  103. package/package.json +7 -2
@@ -0,0 +1,1002 @@
1
+ // Copyright The PDP-Connect Contributors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // The registry half of OCI connector consumption: turn a reference into bytes,
5
+ // and say honestly when it could not.
6
+ //
7
+ // The classification core — present / absent / unknown — is carried over from
8
+ // the publisher's `scripts/lookup-manifest.mjs`, and the reason it is carried
9
+ // over rather than re-derived is that the distinction it draws is the whole
10
+ // point of the module. A publisher asks "may I move this version tag onto new
11
+ // bytes?"; a consumer asks "should I install these bytes?". Both questions are
12
+ // answered wrongly, and in the same direction, by any client that reports "I
13
+ // could not find out" as "nothing is there". The publisher's version of this
14
+ // bug authorised re-signing a released tag; the consumer's would let a sick
15
+ // registry look like a withdrawn connector. One rule, two callers.
16
+ //
17
+ // What the publisher's copy classified from is kept exactly: THE RESPONSE TO
18
+ // THE REQUEST THAT WAS MADE. Absence is a claim about one endpoint, so it is
19
+ // only ever read off that endpoint's own reply, never off a token exchange's
20
+ // failure and never off registry prose. `parseDistributionErrorCodes` and
21
+ // `classifyManifestResponse` below are that rule, and their tests come with
22
+ // them (`oci-registry.test.mjs`).
23
+ //
24
+ // WHAT IS NEW HERE, AND WHY. The publisher only ever needed a digest. A
25
+ // consumer needs the bytes too, so this module adds blob fetch, and with it
26
+ // two obligations the publisher never had:
27
+ //
28
+ // - a blob is verified against the digest that NAMED it before any caller
29
+ // sees it (`fetchBlob`). A registry that serves the wrong bytes for a
30
+ // content-addressed name is not trusted to say so itself.
31
+ // - a fetch by digest never falls back to a tag. Re-resolution is the defect
32
+ // the publisher documents at its own push step, and the consumer's version
33
+ // of it is installing something other than what the lock pinned.
34
+
35
+ import { createHash } from "node:crypto";
36
+
37
+ import { fetchWithRetry } from "./retry.mjs";
38
+
39
+ const MANIFEST_ACCEPT = [
40
+ "application/vnd.oci.image.manifest.v1+json",
41
+ "application/vnd.oci.image.index.v1+json",
42
+ "application/vnd.docker.distribution.manifest.v2+json",
43
+ "application/vnd.docker.distribution.manifest.list.v2+json",
44
+ ].join(", ");
45
+
46
+ // The only two codes the distribution spec defines for "this reference does not
47
+ // exist". Anything else a registry returns with a 404 — including a bare 404
48
+ // from a proxy that never reached the registry — stays UNKNOWN.
49
+ const ABSENCE_CODES = new Set(["MANIFEST_UNKNOWN", "NAME_UNKNOWN"]);
50
+
51
+ // A manifest descriptor is small, and so are the distribution-spec error bodies
52
+ // classified from it. 1 MiB is far above anything either can legitimately be, so
53
+ // a response past it is not a reply this module can read. The ceiling exists
54
+ // because the body is accumulated in memory.
55
+ const MAX_MANIFEST_BYTES = 1024 * 1024;
56
+
57
+ // A connector artifact's layers are code and a brand icon, not container images.
58
+ // 64 MiB bounds a single blob well above anything the builder emits while still
59
+ // refusing to buffer an unbounded stream from a peer.
60
+ const MAX_BLOB_BYTES = 64 * 1024 * 1024;
61
+
62
+ export const DEFAULT_OCI_REGISTRY = "ghcr.io";
63
+
64
+ // The registry this consumer will talk to at all. C1.3: a lock entry naming any
65
+ // other host is refused before a socket is opened, which is the fail-closed
66
+ // analogue of the tarball path's URL→identity origin policy. Widening this is a
67
+ // deliberate edit, not something an artifact or a lock file can ask for.
68
+ const ALLOWED_REGISTRIES = new Set([DEFAULT_OCI_REGISTRY]);
69
+
70
+ // The same predicate the publisher enforces when it derives a repository name
71
+ // from a manifest's `connector_key`. Checked here so a key that could never
72
+ // have been published is refused before any network call (C1.1).
73
+ const CONNECTOR_KEY_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
74
+
75
+ const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
76
+
77
+ export function isValidConnectorKey(key) {
78
+ return typeof key === "string" && CONNECTOR_KEY_PATTERN.test(key);
79
+ }
80
+
81
+ export function isValidDigest(digest) {
82
+ return typeof digest === "string" && DIGEST_PATTERN.test(digest.trim());
83
+ }
84
+
85
+ export function sha256Hex(buffer) {
86
+ return createHash("sha256").update(buffer).digest("hex");
87
+ }
88
+
89
+ export function sha256Digest(buffer) {
90
+ return `sha256:${sha256Hex(buffer)}`;
91
+ }
92
+
93
+ /**
94
+ * The cosign tag that holds the signature for a manifest digest.
95
+ *
96
+ * `sha256:<hex>` is not a legal tag — `:` is a separator — so cosign rewrites
97
+ * the separator and suffixes `.sig`. Observed against cosign v2.4.3, which is
98
+ * the version the publish workflow pins.
99
+ */
100
+ export function cosignSignatureTag(digest) {
101
+ if (!isValidDigest(digest)) {
102
+ throw new Error(`Cannot derive a cosign signature tag from "${digest}"`);
103
+ }
104
+ return `${digest.trim().replace(":", "-")}.sig`;
105
+ }
106
+
107
+ /**
108
+ * A refusal that carries WHICH failure it was.
109
+ *
110
+ * C6.1 requires unknown / absent / denied / tampered / misidentified /
111
+ * unverifiable to be distinguishable, and a caller that has to regex an error
112
+ * message to tell them apart cannot act on the difference. The `reason` tag is
113
+ * the machine-readable half; the message stays human-readable.
114
+ */
115
+ export class OciRegistryError extends Error {
116
+ constructor(message, reason) {
117
+ super(message);
118
+ this.name = "OciRegistryError";
119
+ this.reason = reason;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Validate and split `ghcr.io/pdp-connect/connector/<key>` style coordinates.
125
+ *
126
+ * Every field a caller can influence is checked here, before anything is sent,
127
+ * so that a malformed lock entry fails as a refusal rather than as a request to
128
+ * somewhere unintended.
129
+ */
130
+ export function parseOciReference({
131
+ registry,
132
+ repository,
133
+ digest = null,
134
+ version = null,
135
+ // The behavioural tests' hook, and nothing else. It exists because the
136
+ // acceptance suite serves a real registry on a loopback port, and a test
137
+ // registry cannot be `ghcr.io`. It is passed explicitly by a caller that
138
+ // already decided to trust it — never read from the environment, never
139
+ // inferred, and never reachable from a lock file, so no artifact or lock can
140
+ // talk the installer into widening the policy.
141
+ allowedRegistries = ALLOWED_REGISTRIES,
142
+ } = {}) {
143
+ if (typeof registry !== "string" || registry.length === 0) {
144
+ throw new OciRegistryError("OCI entry is missing a registry", "invalid-reference");
145
+ }
146
+ if (!allowedRegistries.has(registry)) {
147
+ throw new OciRegistryError(
148
+ `Refusing connector artifact from unsupported registry "${registry}"; only ${[...allowedRegistries].join(", ")} is trusted`,
149
+ "untrusted-registry"
150
+ );
151
+ }
152
+ if (typeof repository !== "string" || !/^[a-z0-9]+([._-][a-z0-9]+)*(\/[a-z0-9]+([._-][a-z0-9]+)*)*$/.test(repository)) {
153
+ throw new OciRegistryError(
154
+ `Invalid OCI repository "${repository}"`,
155
+ "invalid-reference"
156
+ );
157
+ }
158
+ if (digest !== null && !isValidDigest(digest)) {
159
+ throw new OciRegistryError(`Invalid OCI manifest digest "${digest}"`, "invalid-reference");
160
+ }
161
+ if (digest === null && (typeof version !== "string" || version.length === 0)) {
162
+ throw new OciRegistryError(
163
+ `OCI entry for ${repository} carries neither a digest nor a version`,
164
+ "invalid-reference"
165
+ );
166
+ }
167
+ return {
168
+ registry,
169
+ repository,
170
+ digest: digest === null ? null : digest.trim(),
171
+ version,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Parse a user-typed reference — `ghcr.io/pdp-connect/connector/ynab@sha256:…`
177
+ * or `…/ynab:0.3.0` — into coordinates.
178
+ *
179
+ * The `@digest` form is parsed before the `:tag` form, because a digest
180
+ * contains a colon and reading the reference right-to-left for a tag would
181
+ * split `sha256:abc…` in the middle. The connector key is the last path
182
+ * segment, and it is validated here so a reference that could never name a
183
+ * published artifact is refused before it becomes a request (C1.1).
184
+ */
185
+ export function parseConnectorOciReference(reference) {
186
+ if (typeof reference !== "string" || reference.length === 0) {
187
+ throw new OciRegistryError("No OCI reference given", "invalid-reference");
188
+ }
189
+
190
+ const at = reference.indexOf("@");
191
+ let body = reference;
192
+ let digest = null;
193
+ let version = null;
194
+
195
+ if (at !== -1) {
196
+ body = reference.slice(0, at);
197
+ digest = reference.slice(at + 1);
198
+ if (!isValidDigest(digest)) {
199
+ throw new OciRegistryError(`Invalid digest in OCI reference "${reference}"`, "invalid-reference");
200
+ }
201
+ } else {
202
+ const lastColon = body.lastIndexOf(":");
203
+ const lastSlash = body.lastIndexOf("/");
204
+ if (lastColon > lastSlash) {
205
+ version = body.slice(lastColon + 1);
206
+ body = body.slice(0, lastColon);
207
+ }
208
+ }
209
+
210
+ const firstSlash = body.indexOf("/");
211
+ if (firstSlash === -1) {
212
+ throw new OciRegistryError(
213
+ `OCI reference "${reference}" names no repository`,
214
+ "invalid-reference"
215
+ );
216
+ }
217
+ const registry = body.slice(0, firstSlash);
218
+ const repository = body.slice(firstSlash + 1);
219
+ const connectorKey = repository.slice(repository.lastIndexOf("/") + 1);
220
+
221
+ if (!isValidConnectorKey(connectorKey)) {
222
+ throw new OciRegistryError(
223
+ `OCI reference "${reference}" names an invalid connector key "${connectorKey}"`,
224
+ "invalid-reference"
225
+ );
226
+ }
227
+
228
+ // Runs the same registry and repository checks a lock entry gets, so the
229
+ // command line is not a way around the origin policy.
230
+ parseOciReference({ registry, repository, digest, version });
231
+
232
+ return { registry, repository, connectorKey, digest, version };
233
+ }
234
+
235
+ /**
236
+ * Parse a `WWW-Authenticate: Bearer realm="...",service="...",scope="..."`
237
+ * challenge. Returns null for any other scheme, which keeps an unexpected
238
+ * challenge on the unknown path rather than guessing at a token exchange.
239
+ */
240
+ export function parseBearerChallenge(header) {
241
+ if (typeof header !== "string") return null;
242
+ if (!/^bearer\s/i.test(header)) return null;
243
+ const params = {};
244
+ for (const match of header.slice(7).matchAll(/([a-zA-Z0-9_-]+)="([^"]*)"/g)) {
245
+ params[match[1]] = match[2];
246
+ }
247
+ return params.realm ? params : null;
248
+ }
249
+
250
+ /**
251
+ * Read a distribution-spec error code out of a response body.
252
+ *
253
+ * Strict on purpose, and carried over verbatim in intent from the publisher's
254
+ * lookup. The body must be JSON, must carry an `errors` array, and codes are
255
+ * taken ONLY from that structure — never from a free-text `message`, which is
256
+ * where the publisher's original defect lived.
257
+ *
258
+ * An entry WITHOUT a string `code` is not skipped, it poisons the whole array:
259
+ * `[{"code":"MANIFEST_UNKNOWN"},{}]` must not reduce to a clean absence, because
260
+ * the unreadable second error may be the one saying the request was not allowed
261
+ * to ask. `null` is that verdict — distinct from `[]`, which says the body
262
+ * carried no error structure — and both land on `unknown`.
263
+ */
264
+ export function parseDistributionErrorCodes(body) {
265
+ let parsed;
266
+ try {
267
+ parsed = JSON.parse(body);
268
+ } catch {
269
+ return [];
270
+ }
271
+ if (!parsed || !Array.isArray(parsed.errors)) return [];
272
+ const codes = [];
273
+ for (const entry of parsed.errors) {
274
+ if (!entry || typeof entry.code !== "string") return null;
275
+ codes.push(entry.code.toUpperCase());
276
+ }
277
+ return codes;
278
+ }
279
+
280
+ /**
281
+ * The registry's own words for a failure, for the diagnostic line only.
282
+ *
283
+ * Deliberately NEVER consulted by the classifier: this text is precisely the
284
+ * prose whose promotion to a decision was the defect being avoided. A 403 whose
285
+ * message reads "not found" is still a 403.
286
+ */
287
+ function registryMessage(body) {
288
+ let parsed;
289
+ try {
290
+ parsed = JSON.parse(body);
291
+ } catch {
292
+ return "";
293
+ }
294
+ const message = parsed?.errors?.[0]?.message;
295
+ return typeof message === "string" ? message : "";
296
+ }
297
+
298
+ /**
299
+ * Classify the response to THE manifest request.
300
+ *
301
+ * Split out from the I/O so the decision table is testable without a socket. A
302
+ * token failure never reaches this function; it is handled where the token is
303
+ * requested, which is what makes "a token 404 cannot become an absence" a
304
+ * structural property rather than a filtered one.
305
+ */
306
+ export function classifyManifestResponse(response) {
307
+ const { status, headers = {}, body = "" } = response;
308
+
309
+ if (status === 200) {
310
+ const digest = headers["docker-content-digest"];
311
+ const computed = sha256Digest(Buffer.from(body, "utf8"));
312
+ if (typeof digest === "string" && isValidDigest(digest)) {
313
+ // C2.1: the header and the bytes must agree. They are two independent
314
+ // claims about the same object, and a registry that contradicts itself
315
+ // about a content-addressed name has not answered the question.
316
+ if (digest.trim() !== computed) {
317
+ return {
318
+ outcome: "unknown",
319
+ reason:
320
+ `the manifest endpoint's Docker-Content-Digest (${digest.trim()}) does not match ` +
321
+ `the digest of the bytes it returned (${computed})`,
322
+ };
323
+ }
324
+ return { outcome: "present", digest: digest.trim(), body };
325
+ }
326
+ if (digest === undefined) {
327
+ // A registry that omits the header entirely is still answerable: the
328
+ // digest is a property of the bytes, and they are in hand.
329
+ return { outcome: "present", digest: computed, body };
330
+ }
331
+ return {
332
+ outcome: "unknown",
333
+ reason: "the manifest endpoint returned 200 with a malformed Docker-Content-Digest header",
334
+ };
335
+ }
336
+
337
+ if (status === 404) {
338
+ const codes = parseDistributionErrorCodes(body);
339
+ const said = registryMessage(body);
340
+
341
+ if (codes === null) {
342
+ return {
343
+ outcome: "unknown",
344
+ reason:
345
+ `the manifest endpoint returned 404 with an errors array carrying an entry that has no ` +
346
+ `string code, so the reply cannot be read as absence and only absence` +
347
+ `${said ? ` (registry said: ${said})` : ""}`,
348
+ };
349
+ }
350
+
351
+ // EVERY code must be an absence code, and there must be at least one. A
352
+ // body mixing MANIFEST_UNKNOWN with DENIED asserts two different things,
353
+ // one of which says the request was not allowed to ask; a reply that
354
+ // contradicts itself has not established that this manifest is missing.
355
+ if (codes.length > 0 && codes.every((code) => ABSENCE_CODES.has(code))) {
356
+ return { outcome: "absent" };
357
+ }
358
+ const contradictory = codes.some((code) => ABSENCE_CODES.has(code));
359
+ return {
360
+ outcome: "unknown",
361
+ reason:
362
+ `the manifest endpoint returned 404 but its body does not state absence and only absence: ` +
363
+ (contradictory
364
+ ? `it mixes an absence code with ${codes.filter((code) => !ABSENCE_CODES.has(code)).join(", ")}, ` +
365
+ `so the reply contradicts itself`
366
+ : `it carries no MANIFEST_UNKNOWN or NAME_UNKNOWN distribution error`) +
367
+ ` (codes: ${codes.length ? codes.join(", ") : "none"}${said ? `; registry said: ${said}` : ""})`,
368
+ };
369
+ }
370
+
371
+ // 401/403 say the request was not allowed to ask, not that the answer is no.
372
+ // 5xx says the registry failed. Both are unknown, and the STATUS takes
373
+ // precedence over any message text.
374
+ const said = registryMessage(body);
375
+ return {
376
+ outcome: "unknown",
377
+ reason: `the manifest endpoint returned HTTP ${status}${said ? ` (registry said: ${said})` : ""}`,
378
+ };
379
+ }
380
+
381
+ /**
382
+ * One HTTP round trip with the body collected, bounded by ONE deadline for the
383
+ * whole exchange.
384
+ *
385
+ * `AbortSignal.timeout` measures elapsed time, not socket inactivity, which is
386
+ * the property needed: a peer dribbling a byte every few seconds must not be
387
+ * able to hold an install open indefinitely.
388
+ */
389
+ async function fetchOnce(
390
+ url,
391
+ {
392
+ method = "GET",
393
+ headers = {},
394
+ timeoutMs = 30000,
395
+ maxBytes = MAX_MANIFEST_BYTES,
396
+ fetchImpl = fetch,
397
+ redirect = "follow",
398
+ retryOptions = {},
399
+ } = {}
400
+ ) {
401
+ const response = await fetchWithRetry(url, {
402
+ ...retryOptions,
403
+ fetchImpl,
404
+ fetchOptions: () => ({
405
+ method,
406
+ headers: { "user-agent": "pdpp-connector-installer/1", ...headers },
407
+ signal: AbortSignal.timeout(timeoutMs),
408
+ redirect,
409
+ }),
410
+ });
411
+
412
+ const buffer = Buffer.from(await response.arrayBuffer());
413
+ if (buffer.length > maxBytes) {
414
+ // Refused rather than truncated: a half-read JSON body parses to nothing,
415
+ // which is indistinguishable from a registry that sent no error code.
416
+ //
417
+ // Note what this does and does not bound. The body is already buffered by
418
+ // the time it is measured, so this caps what a CALLER can be handed, not
419
+ // peak memory during the read. What bounds an endlessly streaming peer is
420
+ // the elapsed-time deadline above, not this. Stating it because a reader
421
+ // who assumed otherwise would be relying on a guarantee that is not here.
422
+ throw new Error(`response body exceeded ${maxBytes} bytes`);
423
+ }
424
+
425
+ const headerObject = {};
426
+ response.headers?.forEach?.((value, key) => {
427
+ headerObject[key.toLowerCase()] = value;
428
+ });
429
+
430
+ return { status: response.status, headers: headerObject, buffer, body: buffer.toString("utf8") };
431
+ }
432
+
433
+ // A 3xx this consumer will follow by hand. 304 is not a redirect to a location.
434
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
435
+
436
+ // Enough hops for a realm that redirects once or twice; a chain longer than this
437
+ // is a loop or a service this consumer should not be chasing.
438
+ const MAX_TOKEN_REDIRECTS = 3;
439
+
440
+ /**
441
+ * The token request, following redirects MANUALLY so every hop is checked.
442
+ *
443
+ * `redirect: "follow"` let the origin policy be satisfied once and then left
444
+ * behind: an allowed realm could answer 302 to any origin and the runtime would
445
+ * fetch it without the challenge check ever seeing that second origin. Since the
446
+ * realm is supplied by the peer in its own 401, that made the check advisory —
447
+ * the registry chose the final destination, including a port on the machine
448
+ * running the install.
449
+ *
450
+ * So the redirect is not followed by the runtime; each `Location` is resolved
451
+ * and put through the SAME `checkTokenRealm` the first realm passed, and a hop
452
+ * that fails is a refusal rather than a request. This is the token exchange
453
+ * only. Blob and manifest reads keep `redirect: "follow"`, because a registry
454
+ * redirecting a blob to its CDN is the documented way that transport works and
455
+ * those responses are pinned by digest rather than trusted by origin.
456
+ */
457
+ async function fetchTokenFollowingRedirects(
458
+ startUrl,
459
+ { registry, timeoutMs, fetchImpl, allowInsecureLoopback, retryOptions }
460
+ ) {
461
+ let url = startUrl;
462
+
463
+ for (let hop = 0; hop <= MAX_TOKEN_REDIRECTS; hop += 1) {
464
+ const response = await fetchOnce(url.toString(), {
465
+ timeoutMs,
466
+ fetchImpl,
467
+ redirect: "manual",
468
+ retryOptions,
469
+ });
470
+ if (!REDIRECT_STATUSES.has(response.status)) {
471
+ return { response };
472
+ }
473
+
474
+ const location = response.headers.location;
475
+ if (!location) {
476
+ return { error: `the token endpoint returned HTTP ${response.status} without a location` };
477
+ }
478
+
479
+ let next;
480
+ try {
481
+ // Resolved against the current URL, so a relative location is read the
482
+ // same way the runtime would have read it.
483
+ next = new URL(location, url);
484
+ } catch {
485
+ return { error: "the token endpoint redirected to a location that cannot be read as a URL" };
486
+ }
487
+
488
+ const refusal = checkTokenRealm(next, registry, { allowInsecureLoopback });
489
+ if (refusal) {
490
+ return { error: `the token endpoint redirected to a refused origin: ${refusal}` };
491
+ }
492
+ url = next;
493
+ }
494
+
495
+ return { error: `the token endpoint redirected more than ${MAX_TOKEN_REDIRECTS} times` };
496
+ }
497
+
498
+ // The token realms this consumer will talk to, keyed by registry origin. Moved
499
+ // from the publisher's `lookup-manifest.mjs` (#97) rather than rewritten, so
500
+ // both sides of the same handshake apply the same policy.
501
+ //
502
+ // The unit is the ORIGIN — scheme, host AND port — because a hostname is not a
503
+ // service. `https://ghcr.io:9443/token` is a different listener from the
504
+ // registry at `ghcr.io`, and a registry on port 5000 challenging to a realm on
505
+ // 6000 is naming whatever else is bound on that machine. Origins are compared
506
+ // as WHATWG `URL` renders them, which is what makes the comparison total: it
507
+ // defaults the port per scheme (`https://ghcr.io:443` IS `https://ghcr.io`),
508
+ // lowercases the host, and brackets and compresses IPv6 literals identically on
509
+ // both sides (`[0:0:0:0:0:0:0:1]` IS `[::1]`), so no spelling of an authority
510
+ // slips past by differing from the registry's.
511
+ //
512
+ // GHCR is the split this repository pulls from: ghcr.io challenges with a realm
513
+ // on ghcr.io itself. Docker Hub is kept because the same helper resolves any
514
+ // `<registry>/<name>:<tag>`. Both sides are https origins, because a documented
515
+ // auth service is a public one reached over TLS on the default port —
516
+ // `ghcr.io:9443` is not the GHCR this table describes and does not inherit its
517
+ // realms. Widening this is a deliberate edit, not an accident of a challenge.
518
+ const TOKEN_ORIGINS = new Map([
519
+ ["https://ghcr.io", ["https://ghcr.io"]],
520
+ ["https://registry-1.docker.io", ["https://auth.docker.io"]],
521
+ ["https://docker.io", ["https://auth.docker.io"]],
522
+ ["https://index.docker.io", ["https://auth.docker.io"]],
523
+ ]);
524
+
525
+ /**
526
+ * The registry authority read as an origin under a given scheme.
527
+ *
528
+ * Both sides of the destination check go through `URL` so they are normalised
529
+ * the same way; returns null when the authority is not one `URL` can read,
530
+ * which the caller turns into a refusal rather than a comparison against a
531
+ * string it had to build by hand.
532
+ */
533
+ function originOf(scheme, authority) {
534
+ try {
535
+ const url = new URL(`${scheme}//${authority}`);
536
+ return url.origin === "null" ? null : url.origin;
537
+ } catch {
538
+ return null;
539
+ }
540
+ }
541
+
542
+ /**
543
+ * May this consumer make the token request to this realm?
544
+ *
545
+ * Returns null when it may, or a diagnostic sentence when it may not. Callers
546
+ * turn a refusal into `unknown`: the manifest question is unanswered, and an
547
+ * unanswered question must never read as absence.
548
+ *
549
+ * The publisher checks this to protect a credential. This consumer sends none —
550
+ * the pull is anonymous — so what the check is worth here is smaller and worth
551
+ * stating exactly: the realm comes from the PEER, in its own 401, so without
552
+ * the check a registry chooses an arbitrary origin this process will then issue
553
+ * a GET to, including a port on the machine running the install. That is a
554
+ * request forgery with the registry as the author, not a credential leak. It is
555
+ * a narrow exposure, and it costs one comparison to close.
556
+ *
557
+ * `allowInsecureLoopback` is the tests' hook and nothing else — off unless the
558
+ * caller passes it, so the fixture registry's plaintext realm is reachable in a
559
+ * test and no loopback carve-out exists in production. What it waives is the
560
+ * TRANSPORT requirement, for a loopback host only; the destination check still
561
+ * runs, so a test registry cannot be talked into a different loopback port than
562
+ * the one it is published on.
563
+ */
564
+ export function checkTokenRealm(realmUrl, registry, { allowInsecureLoopback = false } = {}) {
565
+ const registryAuthority = registry.split("/")[0].toLowerCase();
566
+ // `URL` keeps the brackets on an IPv6 literal, so the loopback test is
567
+ // written against the bracketed spelling rather than the bare address.
568
+ const realmHost = realmUrl.hostname.toLowerCase();
569
+ const loopback = realmHost === "127.0.0.1" || realmHost === "[::1]" || realmHost === "localhost";
570
+
571
+ if (realmUrl.protocol !== "https:") {
572
+ // The hook waives the TRANSPORT requirement for loopback and nothing more.
573
+ // Deliberately not an early `return null`: the origin check below still
574
+ // runs.
575
+ const waived = allowInsecureLoopback && realmUrl.protocol === "http:" && loopback;
576
+ if (!waived) {
577
+ return (
578
+ `the 401 challenge points at a non-HTTPS token realm ` +
579
+ `(${realmUrl.protocol}//${realmUrl.host})`
580
+ );
581
+ }
582
+ }
583
+
584
+ // The registry is read under the REALM'S scheme, so the comparison is between
585
+ // two origins of the same kind. Under the hook that scheme is http, which is
586
+ // the only way a plaintext loopback realm can match at all; on the ordinary
587
+ // path it is https, so a registry written without a port compares equal to a
588
+ // realm written with `:443` and to nothing else.
589
+ const registryOrigin = originOf(realmUrl.protocol, registryAuthority);
590
+ const refusal =
591
+ `the 401 challenge points at ${realmUrl.origin}, which is neither the registry ` +
592
+ `origin (${registryOrigin ?? registryAuthority}) nor a token origin documented for it`;
593
+
594
+ if (registryOrigin === null) return refusal;
595
+ if (realmUrl.origin === registryOrigin) return null;
596
+
597
+ // The documented split-auth table is keyed by the registry's https origin, so
598
+ // it is consulted with that origin whatever scheme the realm proposed.
599
+ const documented = TOKEN_ORIGINS.get(originOf("https:", registryAuthority)) ?? [];
600
+ if (documented.includes(realmUrl.origin)) return null;
601
+
602
+ return refusal;
603
+ }
604
+
605
+ /**
606
+ * Anonymous pull of a public GHCR repository still requires a token exchange.
607
+ *
608
+ * C2.4: a failure ANYWHERE in this handshake is the caller's `unknown`, and it
609
+ * is reported as a token failure so it can never be mistaken for the manifest
610
+ * endpoint's answer.
611
+ */
612
+ async function requestToken(
613
+ challenge,
614
+ {
615
+ registry,
616
+ repository,
617
+ timeoutMs,
618
+ fetchImpl,
619
+ allowInsecureLoopback = false,
620
+ retryOptions,
621
+ }
622
+ ) {
623
+ let tokenUrl;
624
+ try {
625
+ tokenUrl = new URL(challenge.realm);
626
+ } catch (error) {
627
+ return { error: `the 401 challenge names an unparseable token realm (${error.message})` };
628
+ }
629
+ const refusal = checkTokenRealm(tokenUrl, registry, { allowInsecureLoopback });
630
+ if (refusal) {
631
+ return { error: refusal };
632
+ }
633
+
634
+ if (challenge.service) tokenUrl.searchParams.set("service", challenge.service);
635
+ tokenUrl.searchParams.set("scope", challenge.scope ?? `repository:${repository}:pull`);
636
+
637
+ let response;
638
+ try {
639
+ const followed = await fetchTokenFollowingRedirects(tokenUrl, {
640
+ registry,
641
+ timeoutMs,
642
+ fetchImpl,
643
+ allowInsecureLoopback,
644
+ retryOptions,
645
+ });
646
+ if (followed.error) {
647
+ return { error: followed.error };
648
+ }
649
+ response = followed.response;
650
+ } catch (error) {
651
+ return { error: `token request failed: ${error.message}` };
652
+ }
653
+ if (response.status !== 200) {
654
+ return {
655
+ error:
656
+ `the token endpoint returned HTTP ${response.status}; ` +
657
+ `this says nothing about whether the manifest exists`,
658
+ };
659
+ }
660
+ let token;
661
+ try {
662
+ const parsed = JSON.parse(response.body);
663
+ token = parsed?.token ?? parsed?.access_token;
664
+ } catch {
665
+ return { error: "the token endpoint returned a body that is not JSON" };
666
+ }
667
+ if (typeof token !== "string" || token.length === 0) {
668
+ return { error: "the token endpoint returned no token" };
669
+ }
670
+ return { token };
671
+ }
672
+
673
+ /**
674
+ * GET one registry path, completing a Bearer handshake if challenged.
675
+ *
676
+ * Returns the raw response so the caller can classify it. Token failures are
677
+ * returned as a `tokenFailure` rather than thrown, so the caller can report
678
+ * them as what they are.
679
+ */
680
+ async function registryGet(
681
+ {
682
+ registry,
683
+ repository,
684
+ path,
685
+ accept,
686
+ scheme = "https",
687
+ timeoutMs = 30000,
688
+ maxBytes,
689
+ fetchImpl = fetch,
690
+ allowInsecureLoopback = false,
691
+ retryOptions = {},
692
+ }
693
+ ) {
694
+ const url = `${scheme}://${registry}/v2/${repository}/${path}`;
695
+ const headers = accept ? { accept } : {};
696
+
697
+ let response = await fetchOnce(url, {
698
+ headers,
699
+ timeoutMs,
700
+ maxBytes,
701
+ fetchImpl,
702
+ retryOptions,
703
+ });
704
+ if (response.status !== 401) return { response };
705
+
706
+ const challenge = parseBearerChallenge(response.headers["www-authenticate"]);
707
+ if (!challenge) {
708
+ return { tokenFailure: "the registry returned 401 without a usable Bearer challenge" };
709
+ }
710
+ const { token, error } = await requestToken(challenge, {
711
+ registry,
712
+ repository,
713
+ timeoutMs,
714
+ fetchImpl,
715
+ retryOptions,
716
+ // `scheme` is ALREADY the explicit, caller-supplied hook this module uses to
717
+ // reach a test registry: production never sets it, so it is `https` on every
718
+ // real path and a plaintext realm is refused there whatever this resolves
719
+ // to. Deriving the waiver from it keeps one hook instead of two that must be
720
+ // set together, and no artifact, lock entry or registry challenge can reach
721
+ // it. The destination check still runs either way, so even under `http` the
722
+ // realm must be the test registry's own origin, port included.
723
+ allowInsecureLoopback: allowInsecureLoopback || scheme === "http",
724
+ });
725
+ if (error) return { tokenFailure: error };
726
+
727
+ response = await fetchOnce(url, {
728
+ headers: { ...headers, authorization: `Bearer ${token}` },
729
+ timeoutMs,
730
+ maxBytes,
731
+ fetchImpl,
732
+ retryOptions,
733
+ });
734
+ return { response };
735
+ }
736
+
737
+ /**
738
+ * Resolve `<repository>:<tag>` to one of present / absent / unknown.
739
+ *
740
+ * This is the consumer's copy of the publisher's guard, and it exists for the
741
+ * same reason: a first publish and an unreachable registry look identical from
742
+ * the outside, and only the registry's own answer about this exact reference
743
+ * tells them apart. Returns the classification; refusing is the caller's
744
+ * decision (C2.2), which is why nothing here throws on `unknown`.
745
+ */
746
+ export async function lookupManifest({
747
+ registry,
748
+ repository,
749
+ reference,
750
+ scheme = "https",
751
+ timeoutMs = 30000,
752
+ fetchImpl = fetch,
753
+ retryOptions = {},
754
+ }) {
755
+ let result;
756
+ try {
757
+ result = await registryGet({
758
+ registry,
759
+ repository,
760
+ path: `manifests/${encodeURIComponent(reference)}`,
761
+ accept: MANIFEST_ACCEPT,
762
+ scheme,
763
+ timeoutMs,
764
+ maxBytes: MAX_MANIFEST_BYTES,
765
+ fetchImpl,
766
+ retryOptions,
767
+ });
768
+ } catch (error) {
769
+ return { outcome: "unknown", reason: `manifest request failed: ${error.message}` };
770
+ }
771
+ if (result.tokenFailure) {
772
+ return { outcome: "unknown", reason: result.tokenFailure };
773
+ }
774
+ return classifyManifestResponse(result.response);
775
+ }
776
+
777
+ /**
778
+ * Resolve a version tag to the digest it currently names.
779
+ *
780
+ * C2.1/C2.2: `present` yields a digest, `absent` and `unknown` both refuse, and
781
+ * they refuse with different reasons because the operator's next question
782
+ * differs. This is a FIRST-PIN operation only; once a digest is in the lock,
783
+ * installs go through `fetchManifestByDigest` and never come back here (C2.3).
784
+ */
785
+ export async function resolveVersionToDigest({
786
+ registry,
787
+ repository,
788
+ version,
789
+ scheme = "https",
790
+ timeoutMs = 30000,
791
+ fetchImpl = fetch,
792
+ retryOptions = {},
793
+ }) {
794
+ const result = await lookupManifest({
795
+ registry,
796
+ repository,
797
+ reference: version,
798
+ scheme,
799
+ timeoutMs,
800
+ fetchImpl,
801
+ retryOptions,
802
+ });
803
+
804
+ if (result.outcome === "present") return result.digest;
805
+ if (result.outcome === "absent") {
806
+ throw new OciRegistryError(
807
+ `${registry}/${repository}:${version} is not published`,
808
+ "absent"
809
+ );
810
+ }
811
+ throw new OciRegistryError(
812
+ `Refusing to resolve ${registry}/${repository}:${version}: ${result.reason}`,
813
+ "unverifiable"
814
+ );
815
+ }
816
+
817
+ /**
818
+ * Fetch a manifest BY DIGEST and prove the bytes hash to the digest asked for.
819
+ *
820
+ * The self-check is not ceremony. A digest is a content address, so bytes that
821
+ * do not hash to it are not the object requested however the registry labelled
822
+ * them, and this is the one check that makes every later layer-digest check
823
+ * meaningful — the layer descriptors live inside these bytes.
824
+ */
825
+ export async function fetchManifestByDigest({
826
+ registry,
827
+ repository,
828
+ digest,
829
+ scheme = "https",
830
+ timeoutMs = 30000,
831
+ fetchImpl = fetch,
832
+ retryOptions = {},
833
+ }) {
834
+ if (!isValidDigest(digest)) {
835
+ throw new OciRegistryError(`Invalid OCI manifest digest "${digest}"`, "invalid-reference");
836
+ }
837
+
838
+ let result;
839
+ try {
840
+ result = await registryGet({
841
+ registry,
842
+ repository,
843
+ path: `manifests/${digest}`,
844
+ accept: MANIFEST_ACCEPT,
845
+ scheme,
846
+ timeoutMs,
847
+ maxBytes: MAX_MANIFEST_BYTES,
848
+ fetchImpl,
849
+ retryOptions,
850
+ });
851
+ } catch (error) {
852
+ throw new OciRegistryError(
853
+ `Failed to fetch ${registry}/${repository}@${digest}: ${error.message}`,
854
+ "unverifiable"
855
+ );
856
+ }
857
+ if (result.tokenFailure) {
858
+ throw new OciRegistryError(
859
+ `Failed to fetch ${registry}/${repository}@${digest}: ${result.tokenFailure}`,
860
+ "unverifiable"
861
+ );
862
+ }
863
+
864
+ const { response } = result;
865
+ if (response.status !== 200) {
866
+ const classification = classifyManifestResponse(response);
867
+ throw new OciRegistryError(
868
+ `Failed to fetch ${registry}/${repository}@${digest}: ${classification.reason ?? `HTTP ${response.status}`}`,
869
+ classification.outcome === "absent" ? "absent" : "unverifiable"
870
+ );
871
+ }
872
+
873
+ const actual = sha256Digest(response.buffer);
874
+ if (actual !== digest.trim()) {
875
+ throw new OciRegistryError(
876
+ `${registry}/${repository}@${digest} returned bytes whose digest is ${actual}`,
877
+ "tampered"
878
+ );
879
+ }
880
+
881
+ let manifest;
882
+ try {
883
+ manifest = JSON.parse(response.buffer.toString("utf8"));
884
+ } catch (error) {
885
+ throw new OciRegistryError(
886
+ `${registry}/${repository}@${digest} is not a JSON manifest: ${error.message}`,
887
+ "tampered"
888
+ );
889
+ }
890
+
891
+ return { manifest, bytes: response.buffer, digest: digest.trim() };
892
+ }
893
+
894
+ /**
895
+ * Fetch a blob and verify it against the digest that named it (C4.1).
896
+ *
897
+ * Verification happens HERE rather than in the caller so that no code path can
898
+ * obtain unverified blob bytes: the only way to get a blob out of this module
899
+ * is to have proved it already.
900
+ */
901
+ export async function fetchBlob({
902
+ registry,
903
+ repository,
904
+ digest,
905
+ scheme = "https",
906
+ timeoutMs = 30000,
907
+ maxBytes = MAX_BLOB_BYTES,
908
+ fetchImpl = fetch,
909
+ retryOptions = {},
910
+ }) {
911
+ if (!isValidDigest(digest)) {
912
+ throw new OciRegistryError(`Invalid OCI blob digest "${digest}"`, "invalid-reference");
913
+ }
914
+
915
+ let result;
916
+ try {
917
+ result = await registryGet({
918
+ registry,
919
+ repository,
920
+ path: `blobs/${digest}`,
921
+ scheme,
922
+ timeoutMs,
923
+ maxBytes,
924
+ fetchImpl,
925
+ retryOptions,
926
+ });
927
+ } catch (error) {
928
+ throw new OciRegistryError(
929
+ `Failed to fetch blob ${digest} from ${registry}/${repository}: ${error.message}`,
930
+ "unverifiable"
931
+ );
932
+ }
933
+ if (result.tokenFailure) {
934
+ throw new OciRegistryError(
935
+ `Failed to fetch blob ${digest} from ${registry}/${repository}: ${result.tokenFailure}`,
936
+ "unverifiable"
937
+ );
938
+ }
939
+
940
+ const { response } = result;
941
+ if (response.status !== 200) {
942
+ throw new OciRegistryError(
943
+ `Failed to fetch blob ${digest} from ${registry}/${repository}: HTTP ${response.status}`,
944
+ response.status === 401 || response.status === 403 ? "denied" : "unverifiable"
945
+ );
946
+ }
947
+
948
+ const actual = sha256Digest(response.buffer);
949
+ if (actual !== digest.trim()) {
950
+ throw new OciRegistryError(
951
+ `Blob ${digest} from ${registry}/${repository} hashes to ${actual}`,
952
+ "tampered"
953
+ );
954
+ }
955
+
956
+ return response.buffer;
957
+ }
958
+
959
+ /**
960
+ * Fetch the cosign signature manifest for a digest, if one is published.
961
+ *
962
+ * Returns null when the signature tag is genuinely absent, and throws on
963
+ * `unknown` — the same three-outcome rule, applied to the object whose absence
964
+ * would otherwise read as "this artifact is unsigned" (C2.2, C6.4).
965
+ */
966
+ export async function fetchSignatureManifest({
967
+ registry,
968
+ repository,
969
+ digest,
970
+ scheme = "https",
971
+ timeoutMs = 30000,
972
+ fetchImpl = fetch,
973
+ retryOptions = {},
974
+ }) {
975
+ const tag = cosignSignatureTag(digest);
976
+ const result = await lookupManifest({
977
+ registry,
978
+ repository,
979
+ reference: tag,
980
+ scheme,
981
+ timeoutMs,
982
+ fetchImpl,
983
+ retryOptions,
984
+ });
985
+
986
+ if (result.outcome === "absent") return null;
987
+ if (result.outcome === "unknown") {
988
+ throw new OciRegistryError(
989
+ `Could not determine whether ${registry}/${repository}:${tag} exists: ${result.reason}`,
990
+ "unverifiable"
991
+ );
992
+ }
993
+
994
+ try {
995
+ return { manifest: JSON.parse(result.body), digest: result.digest };
996
+ } catch (error) {
997
+ throw new OciRegistryError(
998
+ `The cosign signature manifest at ${registry}/${repository}:${tag} is not JSON: ${error.message}`,
999
+ "tampered"
1000
+ );
1001
+ }
1002
+ }