@theappagency/valinor 10.9.0 → 10.11.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 (3) hide show
  1. package/README.md +13 -3
  2. package/lib/launcher.js +105 -13
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -18,15 +18,24 @@ The launcher is **trivial by design** and contains no Valinor functionality itse
18
18
  version runs **fully offline** with no network I/O;
19
19
  4. executes the cached CLI, passing through all arguments and the exit code.
20
20
 
21
- No npm registry auth, no `.npmrc`, no tokens, no secrets — `npx` it anywhere, including CI. It runs on
22
- Node 22 or later, so a GitHub runner's default Node works without a `setup-node` step.
21
+ No npm registry auth and no `.npmrc` — `npx` it anywhere, including CI. It runs on Node 22 or later, so a
22
+ GitHub runner's default Node works without a `setup-node` step.
23
+
24
+ **Authentication.** The downloads endpoint can require a credential. In GitHub Actions the launcher mints one
25
+ itself: give the job `permissions: id-token: write` and it requests a GitHub OIDC token for the audience
26
+ `https://downloads.theappagency.com`, so the repository stores no secret. Elsewhere, set
27
+ `VALINOR_DOWNLOADS_TOKEN`. With neither, the launcher sends no credential, which works for as long as the
28
+ endpoint does not require one. An HTTP 401 or 403 is never retried; the error names the fix. A
29
+ `pull_request` run from a fork cannot mint an OIDC token, so it needs `VALINOR_DOWNLOADS_TOKEN` once the
30
+ endpoint requires authentication.
23
31
 
24
32
  A download that fails for a transient reason (a dropped connection, a stalled transfer, a timeout, or an
25
33
  HTTP 408, 425, 429 or 5xx) is retried with jittered backoff: 3 attempts per file within a 2-minute budget by default. A 404
26
34
  and a checksum mismatch are never retried. When `review-trigger-run` still cannot be fetched after the
27
35
  retries, the launcher prints a warning and exits 0: the verb posts nothing when it cannot run, and the next
28
36
  wake-up or the hourly sweep evaluates the PR again. Every other command exits 1.
29
- **The launcher sends no telemetry.** Its only network activity is fetching the artifact above.
37
+ **The launcher sends no telemetry.** Its only network activity is fetching the artifact above (and, in
38
+ GitHub Actions with `id-token: write`, minting the OIDC token it fetches with).
30
39
 
31
40
  ## Environment variables
32
41
 
@@ -39,6 +48,7 @@ wake-up or the hourly sweep evaluates the PR again. Every other command exits 1.
39
48
  | `VALINOR_FETCH_ATTEMPTS` | Attempts per file for a transient failure: a whole number, at least `1` (default `3`). |
40
49
  | `VALINOR_FETCH_BACKOFF_MS` | Retry backoff base in milliseconds (default `1000`); attempt *n* waits 50–100% of base × 2^(n−1). |
41
50
  | `VALINOR_FETCH_TOTAL_TIMEOUT_MS` | The whole download's budget in milliseconds, across every attempt (default `120000`). |
51
+ | `VALINOR_DOWNLOADS_TOKEN` | The downloads credential outside GitHub Actions (sent as a bearer token; a minted Actions OIDC token takes precedence). Sent only over https, or to this machine. |
42
52
  | `VALINOR_LAUNCHER_DEBUG` | Print full error causes (default output is the actionable message only). |
43
53
 
44
54
  ## Licensing
package/lib/launcher.js CHANGED
@@ -7,6 +7,8 @@
7
7
  // Design constraints (deliberate):
8
8
  // • ZERO runtime dependencies — nothing to audit, nothing to resolve from any registry.
9
9
  // • NO TELEMETRY — the launcher reports nothing anywhere; only the artifact fetch itself.
10
+ // • One credential per run, only when the endpoint asks for one: a GitHub Actions OIDC token minted
11
+ // for the downloads audience, else VALINOR_DOWNLOADS_TOKEN, else none (see downloadsAuthorization).
10
12
  // • Fail closed on integrity: a checksum mismatch DELETES the download and refuses to run.
11
13
  // • Offline-friendly: an exactly-pinned, already-cached version runs with NO network at all;
12
14
  // only a dist-tag (`latest`) needs the manifest.
@@ -48,6 +50,14 @@ export const DEFAULT_DOWNLOADS_HOST = LEGACY_DOWNLOADS_HOST;
48
50
  /** The production downloads endpoint (override with VALINOR_DOWNLOADS_URL — staging/fixtures/tests). */
49
51
  export const DEFAULT_DOWNLOADS_URL = `https://${DEFAULT_DOWNLOADS_HOST}`;
50
52
 
53
+ /**
54
+ * The OIDC AUDIENCE the launcher requests when it mints a GitHub Actions token for the downloads endpoint —
55
+ * MIRRORS src/launcherBundle/resolveSha.ts (asserted by src/launcherBundle/downloadsHostMirror.test.ts). It is ONE
56
+ * fixed string whichever hostname the run fetches from: an audience is signed into the token before the endpoint
57
+ * sees it, so it cannot follow a host flip, and the endpoint accepts it on every hostname it serves.
58
+ */
59
+ export const DOWNLOADS_OIDC_AUDIENCE = "https://downloads.theappagency.com";
60
+
51
61
  const SEMVER_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
52
62
 
53
63
  /** The endpoint base URL, env-overridable, without a trailing slash. */
@@ -126,6 +136,78 @@ export function parseSha256Sums(text, filename) {
126
136
  throw new Error(`SHA256SUMS carries no entry for ${filename} — refusing to run an unverifiable artifact`);
127
137
  }
128
138
 
139
+ /** Whether a credential may be sent to `base`: https, or plain http to this machine only (a local fixture). */
140
+ function mayCarryCredential(base) {
141
+ try {
142
+ const url = new URL(base);
143
+ return url.protocol === "https:" || (url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname));
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * The `Authorization` value for downloads requests, or null to send none. ONE credential, in this order:
151
+ * 1. a GitHub Actions OIDC token minted for {@link DOWNLOADS_OIDC_AUDIENCE} — available only when the job grants
152
+ * `permissions: id-token: write`, which is what puts ACTIONS_ID_TOKEN_REQUEST_URL/TOKEN in the environment;
153
+ * 2. VALINOR_DOWNLOADS_TOKEN — the non-Actions credential (a laptop, another CI);
154
+ * 3. nothing — anonymous, which the endpoint serves until it requires authentication.
155
+ * A failed mint is not fatal: it warns (never printing a token) and falls through, so a run the endpoint would
156
+ * serve anonymously still runs; when the endpoint does require auth, its 401 names the fix. A pull_request run
157
+ * from a fork cannot mint (GitHub withholds the request variables), so it lands on 2 or 3.
158
+ * `base` is the endpoint the credential is for: a plain-http endpoint that is not this machine gets none.
159
+ */
160
+ export async function downloadsAuthorization(env = process.env, fetchImpl, policy = fetchPolicy(env), base = downloadsUrl(env)) {
161
+ if (!mayCarryCredential(base)) return null;
162
+ const fallback = env.VALINOR_DOWNLOADS_TOKEN ? `Bearer ${env.VALINOR_DOWNLOADS_TOKEN}` : null;
163
+ const requestUrl = env.ACTIONS_ID_TOKEN_REQUEST_URL;
164
+ const requestToken = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
165
+ if (!requestUrl || !requestToken) return fallback;
166
+ try {
167
+ const url = new URL(requestUrl);
168
+ url.searchParams.set("audience", DOWNLOADS_OIDC_AUDIENCE);
169
+ const res = await (fetchImpl ?? fetch)(url, {
170
+ headers: { Authorization: `bearer ${requestToken}`, Accept: "application/json" },
171
+ signal: AbortSignal.timeout(policy.timeoutMs),
172
+ });
173
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
174
+ const value = (await res.json())?.value;
175
+ if (typeof value !== "string" || value === "") throw new Error("the response carried no token");
176
+ return `Bearer ${value}`;
177
+ } catch (e) {
178
+ const why = e?.name === "TimeoutError" ? `timed out after ${policy.timeoutMs}ms` : e instanceof Error ? e.message : String(e);
179
+ console.error(
180
+ `valinor launcher: warning — could not mint a GitHub Actions OIDC token for the downloads endpoint (${why}); ` +
181
+ (fallback ? "sending VALINOR_DOWNLOADS_TOKEN instead" : "continuing without a credential"),
182
+ );
183
+ return fallback;
184
+ }
185
+ }
186
+
187
+ /** Resolve the run's credential at most once, and only if a request is actually made (a cache hit mints nothing). */
188
+ function onceAuthorization(resolveAuthorization) {
189
+ let pending = null;
190
+ return () => (pending ??= resolveAuthorization());
191
+ }
192
+
193
+ /** The refusal for a 401/403 — an auth answer is never an outage, so it names the fix, not the network. */
194
+ function authRefusal(url, status, sentCredential) {
195
+ const fix =
196
+ "In GitHub Actions, add `permissions: id-token: write` to this job so the launcher can mint an OIDC token " +
197
+ "(`valinor refresh-workflows --apply` adds it to Valinor's own workflows); elsewhere, set VALINOR_DOWNLOADS_TOKEN.";
198
+ const e =
199
+ status === 403
200
+ ? fetchError(`${url} returned HTTP 403: the endpoint verified the credential, but this repository's owner is not authorized to download Valinor.`, false)
201
+ : fetchError(
202
+ sentCredential
203
+ ? `${url} returned HTTP 401: the downloads endpoint refused the credential the launcher sent (expired or not valid for it). ${fix}`
204
+ : `${url} returned HTTP 401: the downloads endpoint requires authentication and this run sent no credential. ${fix}`,
205
+ false,
206
+ );
207
+ e.auth = true;
208
+ return e;
209
+ }
210
+
129
211
  /**
130
212
  * The per-ATTEMPT timeout (ms): a firewalled/blackholed endpoint that ACCEPTS a connection but never
131
213
  * replies must fail LOUDLY in seconds, not hang forever. The signal covers the response BODY as well as
@@ -198,7 +280,10 @@ function describeFailure(e, timeoutMs, phase) {
198
280
  * backoff wait — once a sibling download has already failed terminally, so the process never idles out
199
281
  * the rest of the budget on a run that must fail anyway.
200
282
  */
201
- async function fetchBody(url, read, fetchImpl, policy, cancel) {
283
+ async function fetchBody(url, read, fetchImpl, policy, cancel, authorization) {
284
+ // The credential is resolved BEFORE the budget starts: minting has its own timeout, and the budget is the download's.
285
+ const credential = authorization ? await authorization() : null;
286
+ const headers = credential ? { Authorization: credential } : undefined;
202
287
  const deadline = Date.now() + policy.totalTimeoutMs;
203
288
  let last;
204
289
  let made = 0;
@@ -212,11 +297,16 @@ async function fetchBody(url, read, fetchImpl, policy, cancel) {
212
297
  const signal = cancel ? AbortSignal.any([timeout, cancel]) : timeout;
213
298
  let res;
214
299
  try {
215
- res = await (fetchImpl ?? fetch)(url, { redirect: "follow", signal });
300
+ // A redirect to another origin drops the Authorization header (the fetch standard), so a credential never
301
+ // follows the legacy store redirect off this endpoint.
302
+ res = await (fetchImpl ?? fetch)(url, { redirect: "follow", signal, headers });
216
303
  } catch (e) {
217
304
  last = fetchError(`could not reach ${url} (${describeFailure(e, timeoutMs, "waiting for a response")})`, true, e);
218
305
  }
219
- if (res && !res.ok) {
306
+ if (res && (res.status === 401 || res.status === 403)) {
307
+ last = authRefusal(url, res.status, credential !== null);
308
+ await res.body?.cancel().catch(() => {});
309
+ } else if (res && !res.ok) {
220
310
  last = fetchError(`${url} returned HTTP ${res.status}`, isTransientStatus(res.status));
221
311
  await res.body?.cancel().catch(() => {});
222
312
  } else if (res) {
@@ -243,8 +333,8 @@ async function fetchBody(url, read, fetchImpl, policy, cancel) {
243
333
  }
244
334
 
245
335
  /** Fetch + validate the endpoint manifest, retrying transient failures. `policy` is threaded from the caller's env. */
246
- export async function fetchManifest(base, fetchImpl, policy = fetchPolicy()) {
247
- return fetchBody(`${base}/manifest.json`, (res) => res.json(), fetchImpl, policy);
336
+ export async function fetchManifest(base, fetchImpl, policy = fetchPolicy(), authorization) {
337
+ return fetchBody(`${base}/manifest.json`, (res) => res.json(), fetchImpl, policy, undefined, authorization);
248
338
  }
249
339
 
250
340
  /**
@@ -255,7 +345,7 @@ export async function fetchManifest(base, fetchImpl, policy = fetchPolicy()) {
255
345
  * a temp dir and atomically renamed into place, so a crashed install can never half-populate a
256
346
  * version dir that a later run would trust.
257
347
  */
258
- export async function ensureVersion(base, version, root, fetchImpl, policy = fetchPolicy()) {
348
+ export async function ensureVersion(base, version, root, fetchImpl, policy = fetchPolicy(), authorization) {
259
349
  const versionDir = join(root, version);
260
350
  if (existsSync(join(versionDir, ".complete"))) return versionDir; // cache hit — zero network.
261
351
 
@@ -268,8 +358,8 @@ export async function ensureVersion(base, version, root, fetchImpl, policy = fet
268
358
  throw e;
269
359
  });
270
360
  const [tarball, sums] = await Promise.all([
271
- settle(fetchBody(tarballUrl, async (res) => Buffer.from(await res.arrayBuffer()), fetchImpl, policy, siblings.signal)),
272
- settle(fetchBody(`${base}/v/${version}/SHA256SUMS`, (res) => res.text(), fetchImpl, policy, siblings.signal)),
361
+ settle(fetchBody(tarballUrl, async (res) => Buffer.from(await res.arrayBuffer()), fetchImpl, policy, siblings.signal, authorization)),
362
+ settle(fetchBody(`${base}/v/${version}/SHA256SUMS`, (res) => res.text(), fetchImpl, policy, siblings.signal, authorization)),
273
363
  ]);
274
364
  const expected = parseSha256Sums(sums, "valinor.tgz");
275
365
  const actual = createHash("sha256").update(tarball).digest("hex");
@@ -334,6 +424,8 @@ export async function run(args, env = process.env) {
334
424
  // Resolve the fetch policy ONCE from THIS call's env (an earlier bug read the ambient process.env, so a
335
425
  // `run(args, env)` override never engaged) and thread it into every fetch.
336
426
  const policy = fetchPolicy(env);
427
+ // One credential for the whole run, resolved lazily: a cache hit (or an offline pinned run) mints nothing.
428
+ const authorization = onceAuthorization(() => downloadsAuthorization(env, undefined, policy, base));
337
429
 
338
430
  let version;
339
431
  if (SEMVER_RE.test(requested)) {
@@ -341,20 +433,20 @@ export async function run(args, env = process.env) {
341
433
  } else {
342
434
  let manifest;
343
435
  try {
344
- manifest = await fetchManifest(base, undefined, policy);
436
+ manifest = await fetchManifest(base, undefined, policy, authorization);
345
437
  } catch (e) {
346
- throw withHint(e, `${e instanceof Error ? e.message : String(e)}\n${offlineHint(base, root)}`);
438
+ throw withHint(e, e?.auth === true ? e.message : `${e instanceof Error ? e.message : String(e)}\n${offlineHint(base, root)}`);
347
439
  }
348
440
  version = resolveVersion(manifest, requested);
349
441
  }
350
442
 
351
443
  let versionDir;
352
444
  try {
353
- versionDir = await ensureVersion(base, version, root, undefined, policy);
445
+ versionDir = await ensureVersion(base, version, root, undefined, policy, authorization);
354
446
  } catch (e) {
355
447
  const msg = e instanceof Error ? e.message : String(e);
356
- // Integrity failures are terminal as-is; reachability failures get the offline hint.
357
- throw withHint(e, msg.includes("SHA-256 MISMATCH") ? msg : `${msg}\n${offlineHint(base, root)}`);
448
+ // Integrity and authentication failures are terminal as-is; reachability failures get the offline hint.
449
+ throw withHint(e, msg.includes("SHA-256 MISMATCH") || e?.auth === true ? msg : `${msg}\n${offlineHint(base, root)}`);
358
450
  }
359
451
  return spawnCli(cachedBinPath(versionDir), args);
360
452
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theappagency/valinor",
3
- "version": "10.9.0",
3
+ "version": "10.11.0",
4
4
  "description": "Public launcher for the Valinor CLI — fetches, SHA-256-verifies, caches, and runs the proprietary Valinor bundle from downloads.valinorci.com. Zero setup, zero secrets, zero npm auth.",
5
5
  "repository": {
6
6
  "type": "git",