From 219775982e71f9fb8b263c71abd89194e8795e48 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 08:05:38 +0000 Subject: [PATCH 1/7] Add @exodus/stasis-api with the npm client and a new GitHub releases client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registry access lived inside `stasis` as `src/apis/npm/`, which meant the npm advisories client and the lazily-bound `semver` shim were only reachable by importing the whole CLI package. Move them into a separate zero-dependency workspace package so API clients are versioned and consumed on their own. - `@exodus/stasis-api/npm` — `advisories()`, moved verbatim - `@exodus/stasis-api/npm/semver` — the npm-CLI-backed semver shim, moved verbatim - `@exodus/stasis-api/github` — new: `releases()`, `release()`, `latestRelease()` and `asset()` for listing releases and fetching their attachments `stasis` now depends on `@exodus/stasis-api`; `audit.js`, `audit-corrections.js` and `sbom.js` import through the package specifier instead of relative paths. The GitHub client is transport-only, matching the npm one: no disk, no caching, and no credential discovery — a `token` is always passed explicitly, so one is never picked up from the environment. Owner/repo slugs are validated (not just escaped) and tags are percent-encoded so a caller-supplied value can't reshape the endpoint it lands in; `Link: rel="next"` pagination refuses to follow a link off the API host, which would carry the token elsewhere. `asset()` verifies the listing's `digest` before returning bytes, so a truncated or swapped download fails at the boundary rather than downstream. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DKbtN73UaD2stKyg6XSKKN --- README.md | 1 + package.json | 3 +- pnpm-lock.yaml | 8 + pnpm-workspace.yaml | 1 + stasis-api/LICENSE | 21 ++ stasis-api/README.md | 31 ++ stasis-api/package.json | 30 ++ stasis-api/src/github/index.js | 200 ++++++++++++ .../src/apis => stasis-api/src}/npm/index.js | 0 .../apis => stasis-api/src}/npm/semver.cjs | 0 stasis/README.md | 2 +- stasis/package.json | 3 +- stasis/src/audit-corrections.js | 2 +- stasis/src/audit.js | 4 +- stasis/src/sbom.js | 2 +- tests/github-api.test.js | 309 ++++++++++++++++++ tests/public-exports.test.js | 14 + 17 files changed, 623 insertions(+), 8 deletions(-) create mode 100644 stasis-api/LICENSE create mode 100644 stasis-api/README.md create mode 100644 stasis-api/package.json create mode 100644 stasis-api/src/github/index.js rename {stasis/src/apis => stasis-api/src}/npm/index.js (100%) rename {stasis/src/apis => stasis-api/src}/npm/semver.cjs (100%) create mode 100644 tests/github-api.test.js diff --git a/README.md b/README.md index 8bb59eaf..5e170df1 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ | Package | Location | | - | - | | `@exodus/stasis` | [stasis](stasis/) | +| `@exodus/stasis-api` | [stasis-api](stasis-api/) | | `@exodus/stasis-core` | [stasis-core](stasis-core/) | | `@exodus/stasis-plugins` | [stasis-plugins](stasis-plugins/) | diff --git a/package.json b/package.json index fc95d6c5..c32ff429 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@exodus/stasis-workspace", "version": "0.0.0", - "description": "Workspace root for @exodus/stasis, @exodus/stasis-core, and @exodus/stasis-plugins", + "description": "Workspace root for @exodus/stasis, @exodus/stasis-api, @exodus/stasis-core, and @exodus/stasis-plugins", "private": true, "type": "module", "engines": { @@ -24,6 +24,7 @@ "@babel/plugin-transform-classes": "^7.29.7", "@exodus/bytes": "^1.15.0", "@exodus/stasis": "workspace:*", + "@exodus/stasis-api": "workspace:*", "@exodus/stasis-core": "workspace:*", "@exodus/stasis-plugins": "workspace:*", "babel-plugin-module-resolver": "^3.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6cd8b85..89d71e6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@exodus/stasis': specifier: workspace:* version: link:stasis + '@exodus/stasis-api': + specifier: workspace:* + version: link:stasis-api '@exodus/stasis-core': specifier: workspace:* version: link:stasis-core @@ -59,6 +62,9 @@ importers: stasis: dependencies: + '@exodus/stasis-api': + specifier: 1.0.0-beta.2 + version: link:../stasis-api '@exodus/stasis-core': specifier: 1.0.0-beta.2 version: link:../stasis-core @@ -75,6 +81,8 @@ importers: specifier: ^0.94.0 version: 0.94.0(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0) + stasis-api: {} + stasis-core: {} stasis-plugins: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e751cb64..0dc1d9b7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - stasis + - stasis-api - stasis-core - stasis-plugins diff --git a/stasis-api/LICENSE b/stasis-api/LICENSE new file mode 100644 index 00000000..7fbaf3ab --- /dev/null +++ b/stasis-api/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Exodus Movement + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/stasis-api/README.md b/stasis-api/README.md new file mode 100644 index 00000000..d5fe244d --- /dev/null +++ b/stasis-api/README.md @@ -0,0 +1,31 @@ +# `@exodus/stasis-api` + +Zero-dependency registry API clients used by `@exodus/stasis`. + +Transport only: no disk access, no credential discovery, no caching. + +| Export | What it provides | +| - | - | +| `@exodus/stasis-api/npm` | `advisories(list)` — npm bulk security advisories for `{ name, version }` pairs | +| `@exodus/stasis-api/npm/semver` | lazily-bound `semver` from the bundled npm CLI, so nothing is installed for it | +| `@exodus/stasis-api/github` | `releases()`, `release()`, `latestRelease()`, `asset()` — GitHub releases and their attachments | + +```js +import { asset, releases } from '@exodus/stasis-api/github' + +const [latest] = await releases('ExodusOSS/stasis', { limit: 1 }) +for (const a of latest.assets) { + // `digest` (when GitHub reports one) is verified before the bytes are returned + const bytes = await asset('ExodusOSS/stasis', a.id, { digest: a.digest }) + console.log(a.name, bytes.byteLength) +} +``` + +Every function takes an optional `signal` (defaulting to a timeout) and, for GitHub, an +optional `token` — no token is ever read from the environment. + +See main package [GitHub](https://github.com/ExodusOSS/stasis/tree/main/stasis) or [npm](https://npmjs.com/package/@exodus/stasis) for full README. + +## License + +[MIT](./LICENSE) diff --git a/stasis-api/package.json b/stasis-api/package.json new file mode 100644 index 00000000..1d4423b1 --- /dev/null +++ b/stasis-api/package.json @@ -0,0 +1,30 @@ +{ + "name": "@exodus/stasis-api", + "version": "1.0.0-beta.2", + "description": "Zero-dependency registry API clients (npm, GitHub) for @exodus/stasis", + "type": "module", + "exports": { + "./npm": "./src/npm/index.js", + "./npm/semver": "./src/npm/semver.cjs", + "./github": "./src/github/index.js" + }, + "files": [ + "src/github/index.js", + "src/npm/index.js", + "src/npm/semver.cjs" + ], + "engines": { + "node": ">=24.14.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ExodusOSS/stasis.git", + "directory": "stasis-api" + }, + "author": "Exodus Movement, Inc.", + "license": "MIT", + "bugs": { + "url": "https://github.com/ExodusOSS/stasis/issues" + }, + "homepage": "https://github.com/ExodusOSS/stasis#readme" +} diff --git a/stasis-api/src/github/index.js b/stasis-api/src/github/index.js new file mode 100644 index 00000000..f5999f58 --- /dev/null +++ b/stasis-api/src/github/index.js @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict' +import { hash } from 'node:crypto' + +// GitHub REST client, scoped to releases and their attachments ("assets" in API terms). +// Transport only: no disk, and no credential discovery -- a caller that needs a private +// repo or a higher rate limit (60 requests/hour per IP unauthenticated, 5000 +// authenticated) passes `token` explicitly, so a token is never picked up from the +// environment behind the caller's back. + +const API = 'https://api.github.com' +const API_VERSION = '2022-11-28' +const JSON_MEDIA_TYPE = 'application/vnd.github+json' +// GitHub rejects requests that carry no User-Agent, so one is always sent. +const USER_AGENT = '@exodus/stasis-api' + +// Owners are alphanumeric with single inner hyphens (at most 39 alphanumerics); repo names +// add `.`, `_` and `-` (at most 100 chars). Validated rather than just escaped so a +// hand-assembled slug can't reshape the endpoint it is interpolated into -- a `..` segment +// would climb out of /repos/ once fetch normalizes the path. +const ownerRegex = /^[\da-z](?:-?[\da-z]){0,38}$/iu +const repoRegex = /^\w[\w.-]{0,99}$/u +// Refs hold nearly anything, so a tag is percent-encoded rather than matched; these are the +// characters and sequences git itself forbids, rejected up front for a local error message. +const badTagRegex = /[\s~^:?*[\]\\]|\.\./u +// GitHub reports an asset's content digest as `:`. The hex length is pinned +// per algorithm so a truncated digest fails as a bad argument, not as a content mismatch. +const digestRegex = /^(?:sha256:[\da-f]{64}|sha384:[\da-f]{96}|sha512:[\da-f]{128})$/u + +function repoSlug(repo) { + assert(typeof repo === 'string', `Unexpected repo: ${repo}`) + const parts = repo.split('/') + assert.equal(parts.length, 2, `Expected an \`owner/repo\` slug: ${repo}`) + const [owner, name] = parts + assert(ownerRegex.test(owner), `Unexpected repo owner: ${owner}`) + assert(repoRegex.test(name), `Unexpected repo name: ${name}`) + return `${owner}/${name}` +} + +function encodeTag(tag) { + assert(typeof tag === 'string' && tag !== '', `Unexpected tag: ${tag}`) + assert(!badTagRegex.test(tag), `Unexpected tag: ${tag}`) + return encodeURIComponent(tag) +} + +async function request(what, url, { accept = JSON_MEDIA_TYPE, token = null, signal }) { + const headers = { Accept: accept, 'User-Agent': USER_AGENT, 'X-GitHub-Api-Version': API_VERSION } + if (token !== null) { + assert(typeof token === 'string' && token !== '', 'Expected a non-empty token') + headers.Authorization = `Bearer ${token}` + } + + let res + try { + // An asset download answers with a cross-origin 302 to a storage host. fetch drops + // Authorization when following a redirect to another origin, so the token stays with + // GitHub -- which is why redirects are followed here rather than handled by hand. + res = await fetch(url, { headers, signal }) + } catch (cause) { + throw new Error(`github ${what} request failed: ${cause.message}`, { cause }) + } + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`github ${what} request failed: ${res.status} ${res.statusText}${text ? ` — ${text.slice(0, 200)}` : ''}`) + } + return res +} + +async function parseJson(what, res) { + try { + return await res.json() + } catch (cause) { + throw new Error(`github ${what} response was not JSON: ${cause.message}`, { cause }) + } +} + +// Follow the `next` URL of a GitHub `Link` header, ignoring the other rels. Only URLs on +// the API host are accepted: pagination must not walk a response into following a link to +// somewhere else (which would carry the token there). +function nextLink(header) { + if (!header) return null + for (const part of header.split(',')) { + const m = /^\s*<([^>]+)>\s*;\s*rel="?next"?/u.exec(part) + if (m && m[1].startsWith(`${API}/`)) return m[1] + } + return null +} + +// A deliberately narrow view of GitHub's payloads: what identifies a release plus what is +// needed to fetch and verify its attachments. Extra fields are dropped rather than passed +// through, so consumers can't come to depend on the raw API shape. +function normalizeAsset(raw) { + assert(raw !== null && typeof raw === 'object' && !Array.isArray(raw), 'Expected a GitHub asset object') + return { + id: raw.id, + name: raw.name, + label: raw.label ?? null, + size: raw.size ?? null, + contentType: raw.content_type ?? null, + // `:` when GitHub has computed one -- assets uploaded before it + // started reporting digests have none. Pass it to `asset()` to verify the download. + digest: raw.digest ?? null, + // 'uploaded' once the upload completed; anything else is not fetchable yet. + state: raw.state ?? null, + downloadUrl: raw.browser_download_url ?? null, + apiUrl: raw.url ?? null, + } +} + +function normalizeRelease(raw) { + assert(raw !== null && typeof raw === 'object' && !Array.isArray(raw), 'Expected a GitHub release object') + return { + id: raw.id, + tag: raw.tag_name, + name: raw.name ?? null, + body: raw.body ?? null, + draft: Boolean(raw.draft), + prerelease: Boolean(raw.prerelease), + commitish: raw.target_commitish ?? null, + createdAt: raw.created_at ?? null, + publishedAt: raw.published_at ?? null, + url: raw.html_url ?? null, + tarballUrl: raw.tarball_url ?? null, + zipballUrl: raw.zipball_url ?? null, + assets: (Array.isArray(raw.assets) ? raw.assets : []).map(normalizeAsset), + } +} + +// One page of the releases listing, plus where the next one lives (null when this is the last). +async function releasePage(url, options) { + const res = await request('releases', url, options) + const page = await parseJson('releases', res) + assert(Array.isArray(page), 'Expected an array of GitHub releases') + return { page, next: nextLink(res.headers.get('link')) } +} + +// List a repo's releases, newest first, including drafts and prereleases (for those the +// token must be able to see them). `limit` caps how many are returned; pass `Infinity` to +// walk every page. +export async function releases(repo, { limit = 100, signal = AbortSignal.timeout(30_000), token = null } = {}) { + const slug = repoSlug(repo) + assert((Number.isInteger(limit) || limit === Infinity) && limit > 0, `Unexpected limit: ${limit}`) + + const out = [] + // GitHub caps a page at 100 entries. Walking `Link: rel="next"` instead of incrementing + // `page=` keeps the walk on URLs GitHub itself handed back. One `signal` covers the + // whole walk, so `limit` also bounds how long a repo with many releases can take. + let url = `${API}/repos/${slug}/releases?per_page=${Math.min(limit, 100)}` + while (url !== null) { + // eslint-disable-next-line no-await-in-loop -- pagination is sequential by nature: the next URL comes from this page's Link header + const { page, next } = await releasePage(url, { token, signal }) + for (const entry of page) { + out.push(normalizeRelease(entry)) + if (out.length === limit) return out + } + url = next + } + return out +} + +// Fetch a single release by its tag. +export async function release(repo, tag, { signal = AbortSignal.timeout(30_000), token = null } = {}) { + const url = `${API}/repos/${repoSlug(repo)}/releases/tags/${encodeTag(tag)}` + const res = await request('release', url, { token, signal }) + return normalizeRelease(await parseJson('release', res)) +} + +// Fetch the latest release. GitHub's notion of "latest" skips drafts and prereleases -- +// use `releases()` when those matter. +export async function latestRelease(repo, { signal = AbortSignal.timeout(30_000), token = null } = {}) { + const url = `${API}/repos/${repoSlug(repo)}/releases/latest` + const res = await request('release', url, { token, signal }) + return normalizeRelease(await parseJson('release', res)) +} + +// Download one attachment's bytes by asset id (from a listing's `assets[].id`). When +// `digest` is given -- the listing's `digest`, `:` -- it is verified before +// the bytes are handed back, so a truncated or swapped download fails here and not +// somewhere downstream. Default timeout is generous: release assets can be large. +export async function asset(repo, id, { digest = null, signal = AbortSignal.timeout(300_000), token = null } = {}) { + const slug = repoSlug(repo) + assert(Number.isInteger(id) && id > 0, `Unexpected asset id: ${id}`) + const expected = digest === null ? null : String(digest).toLowerCase() + assert(expected === null || digestRegex.test(expected), `Unexpected digest: ${digest}`) + + const url = `${API}/repos/${slug}/releases/assets/${id}` + const res = await request('asset', url, { accept: 'application/octet-stream', token, signal }) + let bytes + try { + bytes = new Uint8Array(await res.arrayBuffer()) + } catch (cause) { + throw new Error(`github asset request failed: ${cause.message}`, { cause }) + } + + if (expected !== null) { + const algorithm = expected.slice(0, expected.indexOf(':')) + const actual = `${algorithm}:${hash(algorithm, bytes, 'hex')}` + assert.equal(actual, expected, `Asset ${id} digest mismatch: expected ${expected}, got ${actual}`) + } + return bytes +} diff --git a/stasis/src/apis/npm/index.js b/stasis-api/src/npm/index.js similarity index 100% rename from stasis/src/apis/npm/index.js rename to stasis-api/src/npm/index.js diff --git a/stasis/src/apis/npm/semver.cjs b/stasis-api/src/npm/semver.cjs similarity index 100% rename from stasis/src/apis/npm/semver.cjs rename to stasis-api/src/npm/semver.cjs diff --git a/stasis/README.md b/stasis/README.md index bfb06cec..c82bf12d 100644 --- a/stasis/README.md +++ b/stasis/README.md @@ -82,7 +82,7 @@ _Lockfiles (npm/pnpm/etc) not mentioned: they are like the "tarball" column, but ## Runtime -The zero-dependency [`@exodus/stasis-core`](../stasis-core) CLI provides `run`, `prune`, and `add` commands only; the bundler plugins live in [`@exodus/stasis-plugins`](../stasis-plugins). +The zero-dependency [`@exodus/stasis-core`](../stasis-core) CLI provides `run`, `prune`, and `add` commands only; the bundler plugins live in [`@exodus/stasis-plugins`](../stasis-plugins), and the npm / GitHub API clients that `audit` builds on live in [`@exodus/stasis-api`](../stasis-api). ## License diff --git a/stasis/package.json b/stasis/package.json index dc5a1bca..2eec7832 100644 --- a/stasis/package.json +++ b/stasis/package.json @@ -22,8 +22,6 @@ "files": [ "bin/stasis.js", "logo.svg", - "src/apis/npm/index.js", - "src/apis/npm/semver.cjs", "src/audit.js", "src/babel.js", "src/babel-worker.cjs", @@ -70,6 +68,7 @@ }, "homepage": "https://github.com/ExodusOSS/stasis#readme", "dependencies": { + "@exodus/stasis-api": "1.0.0-beta.2", "@exodus/stasis-core": "1.0.0-beta.2", "@exodus/stasis-plugins": "1.0.0-beta.2", "oxc-parser": "^0.94.0" diff --git a/stasis/src/audit-corrections.js b/stasis/src/audit-corrections.js index 5ec2c631..48b29742 100644 --- a/stasis/src/audit-corrections.js +++ b/stasis/src/audit-corrections.js @@ -1,4 +1,4 @@ -import semver from './apis/npm/semver.cjs' +import semver from '@exodus/stasis-api/npm/semver' // Manual corrections to audit findings: files that must NOT count as evidence // that a (potentially vulnerable) package's code is present. An import edge whose diff --git a/stasis/src/audit.js b/stasis/src/audit.js index 73d31177..05d56ac7 100644 --- a/stasis/src/audit.js +++ b/stasis/src/audit.js @@ -1,7 +1,7 @@ +import { advisories } from '@exodus/stasis-api/npm' +import semver from '@exodus/stasis-api/npm/semver' import { moduleFileKey } from '@exodus/stasis-core/util' -import { advisories } from './apis/npm/index.js' import { isEvidenceFile } from './audit-corrections.js' -import semver from './apis/npm/semver.cjs' import { parseFile } from './parse.js' import { collectWhy } from './why.js' diff --git a/stasis/src/sbom.js b/stasis/src/sbom.js index 0ad2969f..b09baa19 100644 --- a/stasis/src/sbom.js +++ b/stasis/src/sbom.js @@ -1,4 +1,4 @@ -import semver from './apis/npm/semver.cjs' +import semver from '@exodus/stasis-api/npm/semver' import pkg from '../package.json' with { type: 'json' } // Derive an SPDX 2.3 or CycloneDX 1.5 SBOM from already-parsed stasis artifacts (`Bundle`/`Lockfile`). diff --git a/tests/github-api.test.js b/tests/github-api.test.js new file mode 100644 index 00000000..ca958514 --- /dev/null +++ b/tests/github-api.test.js @@ -0,0 +1,309 @@ +import { test } from 'node:test' +import { hash } from 'node:crypto' + +import { asset, latestRelease, release, releases } from '@exodus/stasis-api/github' + +const API = 'https://api.github.com' + +// Swap in a fetch stub for the duration of one test, recording every call so the +// request (URL, method-less GET, headers) can be asserted alongside the result. +const withFetch = (impl, fn) => async (t) => { + const original = globalThis.fetch + const calls = [] + globalThis.fetch = async (url, opts) => { + calls.push({ url, opts }) + return impl({ url, opts, calls }) + } + try { + return await fn(t, calls) + } finally { + globalThis.fetch = original + } +} + +const json = (body, headers = {}) => + new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json', ...headers } }) + +const RELEASE = { + id: 42, + tag_name: 'v1.2.3', + name: 'Release 1.2.3', + body: 'notes', + draft: false, + prerelease: false, + target_commitish: 'main', + created_at: '2026-01-01T00:00:00Z', + published_at: '2026-01-02T00:00:00Z', + html_url: 'https://github.com/ExodusOSS/stasis/releases/tag/v1.2.3', + tarball_url: `${API}/repos/ExodusOSS/stasis/tarball/v1.2.3`, + zipball_url: `${API}/repos/ExodusOSS/stasis/zipball/v1.2.3`, + // fields the normalized view deliberately drops + node_id: 'RE_kwDO', + upload_url: `${API}/repos/ExodusOSS/stasis/releases/42/assets{?name,label}`, + author: { login: 'someone' }, + assets: [ + { + id: 7, + name: 'app.stasis.code.br', + label: 'bundle', + size: 1234, + content_type: 'application/octet-stream', + digest: 'sha256:abc', + state: 'uploaded', + browser_download_url: 'https://github.com/ExodusOSS/stasis/releases/download/v1.2.3/app.stasis.code.br', + url: `${API}/repos/ExodusOSS/stasis/releases/assets/7`, + download_count: 9, + uploader: { login: 'someone' }, + }, + ], +} + +test('releases() lists a repo and normalizes releases and their attachments', withFetch( + () => json([RELEASE]), + async (t, calls) => { + const list = await releases('ExodusOSS/stasis') + t.assert.equal(calls.length, 1) + t.assert.equal(calls[0].url, `${API}/repos/ExodusOSS/stasis/releases?per_page=100`) + // Metadata reads ask for the versioned JSON media type and identify themselves; + // GitHub rejects requests without a User-Agent. + t.assert.equal(calls[0].opts.headers.Accept, 'application/vnd.github+json') + t.assert.equal(calls[0].opts.headers['X-GitHub-Api-Version'], '2022-11-28') + t.assert.equal(calls[0].opts.headers['User-Agent'], '@exodus/stasis-api') + t.assert.equal(calls[0].opts.headers.Authorization, undefined, 'no token means no Authorization header') + t.assert.ok(calls[0].opts.signal, 'a default timeout signal is always passed') + + t.assert.equal(list.length, 1) + t.assert.deepEqual(list[0], { + id: 42, + tag: 'v1.2.3', + name: 'Release 1.2.3', + body: 'notes', + draft: false, + prerelease: false, + commitish: 'main', + createdAt: '2026-01-01T00:00:00Z', + publishedAt: '2026-01-02T00:00:00Z', + url: 'https://github.com/ExodusOSS/stasis/releases/tag/v1.2.3', + tarballUrl: `${API}/repos/ExodusOSS/stasis/tarball/v1.2.3`, + zipballUrl: `${API}/repos/ExodusOSS/stasis/zipball/v1.2.3`, + assets: [{ + id: 7, + name: 'app.stasis.code.br', + label: 'bundle', + size: 1234, + contentType: 'application/octet-stream', + digest: 'sha256:abc', + state: 'uploaded', + downloadUrl: 'https://github.com/ExodusOSS/stasis/releases/download/v1.2.3/app.stasis.code.br', + apiUrl: `${API}/repos/ExodusOSS/stasis/releases/assets/7`, + }], + }, 'the raw payload is narrowed, so extra API fields (node_id, uploader, ...) never leak through') + } +)) + +test('releases() fills defaults for a bare release with no assets', withFetch( + () => json([{ id: 1, tag_name: 'v0.0.1' }]), + async (t) => { + const [only] = await releases('ExodusOSS/stasis') + t.assert.equal(only.tag, 'v0.0.1') + t.assert.deepEqual(only.assets, []) + for (const key of ['name', 'body', 'commitish', 'createdAt', 'publishedAt', 'url', 'tarballUrl', 'zipballUrl']) { + t.assert.equal(only[key], null, `${key} defaults to null`) + } + // draft/prerelease are booleans even when the payload omits them + t.assert.equal(only.draft, false) + t.assert.equal(only.prerelease, false) + } +)) + +test('releases() follows the Link header until the last page', withFetch( + ({ calls }) => calls.length === 1 + ? json([{ id: 1, tag_name: 'v3' }], { link: `<${API}/repos/o/r/releases?per_page=100&page=2>; rel="next", <${API}/repos/o/r/releases?per_page=100&page=9>; rel="last"` }) + : json([{ id: 2, tag_name: 'v2' }]), + async (t, calls) => { + const list = await releases('o/r') + t.assert.equal(calls.length, 2) + t.assert.equal(calls[1].url, `${API}/repos/o/r/releases?per_page=100&page=2`) + t.assert.deepEqual(list.map((r) => r.tag), ['v3', 'v2']) + } +)) + +test('releases() stops at `limit` instead of walking every page', withFetch( + ({ calls }) => json( + [{ id: calls.length * 2 - 1, tag_name: `a${calls.length}` }, { id: calls.length * 2, tag_name: `b${calls.length}` }], + { link: `<${API}/repos/o/r/releases?per_page=100&page=${calls.length + 1}>; rel="next"` } + ), + async (t, calls) => { + const list = await releases('o/r', { limit: 3 }) + t.assert.deepEqual(list.map((r) => r.tag), ['a1', 'b1', 'a2']) + t.assert.equal(calls.length, 2, 'the walk stops as soon as the limit is reached') + // A limit under a full page is asked for verbatim rather than over-fetching. + await releases('o/r', { limit: 1 }) + t.assert.equal(calls[2].url, `${API}/repos/o/r/releases?per_page=1`) + } +)) + +test('releases() with limit=Infinity walks until the pages run out', withFetch( + ({ calls }) => json([{ id: calls.length, tag_name: `v${calls.length}` }], calls.length < 3 + ? { link: `<${API}/repos/o/r/releases?per_page=100&page=${calls.length + 1}>; rel="next"` } + : {}), + async (t, calls) => { + const list = await releases('o/r', { limit: Infinity }) + t.assert.equal(calls[0].url, `${API}/repos/o/r/releases?per_page=100`, 'a page is still capped at GitHub\'s max') + t.assert.deepEqual(list.map((r) => r.tag), ['v1', 'v2', 'v3']) + } +)) + +test('releases() ignores a `next` link pointing off the API host', withFetch( + () => json([{ id: 1, tag_name: 'v1' }], { link: '; rel="next"' }), + async (t, calls) => { + // Following it would carry the caller's token to another host. + const list = await releases('o/r', { token: 'ghp_secret' }) + t.assert.equal(calls.length, 1) + t.assert.equal(list.length, 1) + } +)) + +test('releases() rejects a malformed repo slug before making a request', withFetch( + () => json([]), + async (t, calls) => { + await t.assert.rejects(() => releases('stasis'), /Expected an `owner\/repo` slug: stasis/) + await t.assert.rejects(() => releases('a/b/c'), /Expected an `owner\/repo` slug: a\/b\/c/) + await t.assert.rejects(() => releases(42), /Unexpected repo: 42/) + // `..` would climb out of /repos/ once fetch normalizes the path + await t.assert.rejects(() => releases('ExodusOSS/..'), /Unexpected repo name: \.\./) + await t.assert.rejects(() => releases('../x'), /Unexpected repo owner: \.\./) + await t.assert.rejects(() => releases('o/r?a=b'), /Unexpected repo name: r\?a=b/) + await t.assert.rejects(() => releases('o r/x'), /Unexpected repo owner: o r/) + await t.assert.rejects(() => releases('o/r', { limit: 0 }), /Unexpected limit: 0/) + await t.assert.rejects(() => releases('o/r', { limit: 1.5 }), /Unexpected limit: 1.5/) + t.assert.equal(calls.length, 0, 'nothing reaches the network') + } +)) + +test('release() fetches one release by tag, percent-encoding the tag', withFetch( + () => json(RELEASE), + async (t, calls) => { + const one = await release('ExodusOSS/stasis', 'v1.2.3', { token: 'ghp_secret' }) + t.assert.equal(calls[0].url, `${API}/repos/ExodusOSS/stasis/releases/tags/v1.2.3`) + t.assert.equal(calls[0].opts.headers.Authorization, 'Bearer ghp_secret') + t.assert.equal(one.tag, 'v1.2.3') + t.assert.equal(one.assets[0].id, 7) + + await release('ExodusOSS/stasis', 'weird/tag#1') + t.assert.equal(calls[1].url, `${API}/repos/ExodusOSS/stasis/releases/tags/weird%2Ftag%231`, + 'a tag can never introduce extra path segments or a fragment') + } +)) + +test('release() rejects tags git itself forbids, and an empty token', withFetch( + () => json(RELEASE), + async (t, calls) => { + await t.assert.rejects(() => release('o/r', ''), /Unexpected tag: /) + await t.assert.rejects(() => release('o/r', 'v1..v2'), /Unexpected tag: v1\.\.v2/) + await t.assert.rejects(() => release('o/r', 'v1 v2'), /Unexpected tag: v1 v2/) + await t.assert.rejects(() => release('o/r', 'v1^'), /Unexpected tag: v1\^/) + await t.assert.rejects(() => release('o/r', null), /Unexpected tag: null/) + await t.assert.rejects(() => release('o/r', 'v1', { token: '' }), /Expected a non-empty token/) + t.assert.equal(calls.length, 0) + } +)) + +test('latestRelease() asks GitHub for the latest release', withFetch( + () => json(RELEASE), + async (t, calls) => { + const latest = await latestRelease('ExodusOSS/stasis') + t.assert.equal(calls[0].url, `${API}/repos/ExodusOSS/stasis/releases/latest`) + t.assert.equal(latest.tag, 'v1.2.3') + } +)) + +test('asset() downloads an attachment as bytes', withFetch( + () => new Response(Buffer.from([1, 2, 3, 4]), { status: 200 }), + async (t, calls) => { + const bytes = await asset('ExodusOSS/stasis', 7) + t.assert.equal(calls[0].url, `${API}/repos/ExodusOSS/stasis/releases/assets/7`) + t.assert.equal(calls[0].opts.headers.Accept, 'application/octet-stream', + 'the JSON media type would return the asset metadata instead of its content') + t.assert.ok(bytes instanceof Uint8Array) + t.assert.deepEqual([...bytes], [1, 2, 3, 4]) + } +)) + +test('asset() verifies a supplied digest and rejects a mismatch', withFetch( + () => new Response(Buffer.from('payload'), { status: 200 }), + async (t) => { + const digest = `sha256:${hash('sha256', Buffer.from('payload'), 'hex')}` + const bytes = await asset('o/r', 7, { digest }) + t.assert.equal(Buffer.from(bytes).toString(), 'payload') + // Uppercase hex (as some tools render it) still matches. + t.assert.ok(await asset('o/r', 7, { digest: digest.toUpperCase().replace('SHA256', 'sha256') })) + // A truncated or swapped download must fail here, not downstream. + await t.assert.rejects( + () => asset('o/r', 7, { digest: `sha256:${'0'.repeat(64)}` }), + /Asset 7 digest mismatch: expected sha256:0{64}, got sha256:/ + ) + await t.assert.rejects(() => asset('o/r', 7, { digest: 'md5:abc' }), /Unexpected digest: md5:abc/) + await t.assert.rejects(() => asset('o/r', 7, { digest: 'sha256:nothex' }), /Unexpected digest: sha256:nothex/) + // A truncated digest STRING is a caller bug, and must not read as a content mismatch. + await t.assert.rejects(() => asset('o/r', 7, { digest: digest.slice(0, -1) }), /Unexpected digest: sha256:/) + } +)) + +test('asset() rejects a non-numeric asset id before making a request', withFetch( + () => new Response(Buffer.alloc(0), { status: 200 }), + async (t, calls) => { + await t.assert.rejects(() => asset('o/r', '7'), /Unexpected asset id: 7/) + await t.assert.rejects(() => asset('o/r', 0), /Unexpected asset id: 0/) + await t.assert.rejects(() => asset('o/r', 1.5), /Unexpected asset id: 1.5/) + t.assert.equal(calls.length, 0) + } +)) + +test('a non-ok response reports the status and a snippet of the body', withFetch( + () => new Response('{"message":"Not Found"}', { status: 404, statusText: 'Not Found' }), + async (t) => { + await t.assert.rejects(() => releases('o/r'), /github releases request failed: 404 Not Found — {"message":"Not Found"}/) + await t.assert.rejects(() => release('o/r', 'v1'), /github release request failed: 404 Not Found/) + await t.assert.rejects(() => latestRelease('o/r'), /github release request failed: 404 Not Found/) + await t.assert.rejects(() => asset('o/r', 7), /github asset request failed: 404 Not Found/) + } +)) + +test('a rate-limited response keeps only the first 200 characters of the body', withFetch( + () => new Response('x'.repeat(500), { status: 403, statusText: 'Forbidden' }), + async (t) => { + await t.assert.rejects(() => releases('o/r'), (err) => { + t.assert.match(err.message, /github releases request failed: 403 Forbidden — x{200}$/) + return true + }) + } +)) + +test('a transport failure is wrapped with its cause', withFetch( + () => { throw new Error('connection refused') }, + async (t) => { + await t.assert.rejects(() => releases('o/r'), (err) => { + t.assert.match(err.message, /github releases request failed: connection refused/) + t.assert.equal(err.cause.message, 'connection refused') + return true + }) + await t.assert.rejects(() => asset('o/r', 7), /github asset request failed: connection refused/) + } +)) + +test('a non-JSON body fails as a bad response, not a crash', withFetch( + () => new Response('maintenance', { status: 200, headers: { 'content-type': 'text/html' } }), + async (t) => { + await t.assert.rejects(() => releases('o/r'), /github releases response was not JSON/) + await t.assert.rejects(() => release('o/r', 'v1'), /github release response was not JSON/) + } +)) + +test('a JSON body of the wrong shape is rejected', withFetch( + ({ calls }) => json(calls.length === 1 ? { not: 'an array' } : 'a string'), + async (t) => { + await t.assert.rejects(() => releases('o/r'), /Expected an array of GitHub releases/) + await t.assert.rejects(() => release('o/r', 'v1'), /Expected a GitHub release object/) + } +)) diff --git a/tests/public-exports.test.js b/tests/public-exports.test.js index 07dd0580..71b9cc1d 100644 --- a/tests/public-exports.test.js +++ b/tests/public-exports.test.js @@ -10,6 +10,9 @@ import { StasisEsbuild } from '@exodus/stasis/esbuild' import { StasisWebpack } from '@exodus/stasis/webpack' import { StasisMetro } from '@exodus/stasis/metro' import * as metroTransformer from '@exodus/stasis/metro-transformer' +import * as githubApi from '@exodus/stasis-api/github' +import { advisories } from '@exodus/stasis-api/npm' +import apiSemver from '@exodus/stasis-api/npm/semver' import { StasisEsbuild as PluginsEsbuild } from '@exodus/stasis-plugins/esbuild' import { StasisWebpack as PluginsWebpack } from '@exodus/stasis-plugins/webpack' import { StasisMetro as PluginsMetro } from '@exodus/stasis-plugins/metro' @@ -36,6 +39,17 @@ test('@exodus/stasis/metro-transformer re-exports the stasis-plugins worker tran t.assert.equal(metroTransformer.getCacheKey, pluginsMetroTransformer.getCacheKey) }) +test('@exodus/stasis-api exposes the npm and GitHub clients', (t) => { + t.assert.equal(typeof advisories, 'function') + // A CJS shim behind an export subpath: the default import is its module.exports. + for (const fn of ['compare', 'satisfies', 'valid', 'validRange']) { + t.assert.equal(typeof apiSemver[fn], 'function', `semver.${fn}`) + } + for (const fn of ['asset', 'latestRelease', 'release', 'releases']) { + t.assert.equal(typeof githubApi[fn], 'function', `github.${fn}`) + } +}) + test('@exodus/stasis/cmd/bundle exports the bundle command and its in-memory API', (t) => { t.assert.equal(typeof buildBundle, 'function') t.assert.equal(typeof bundleCommand, 'function') From 19b04229b55c4655eea6a0bc0a860320f1d67733 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 08:21:57 +0000 Subject: [PATCH 2/7] Simplify stasis-api: share the transport shell, loosen slug validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup pass over the new package; no intended behavior change beyond the slug fix below. - Lift the fetch + error-wrap shell into `src/request.js` (`requestOk`). The `—` separator, the 200-char body cap, the swallowed body-read failure and the cause chain were encoded once per client, so a third client would have copied them again and anything GitHub needs later (retry, Retry-After) would land in N places. Both clients' error strings are unchanged. - Validate repo slugs as path segments instead of re-encoding GitHub's account naming policy. The old rule required a leading word character, which refused real repos — `ExodusOSS/.github` among them — while `.`/`..` and non-segment characters are what actually need rejecting. - `repoSlug()` rebuilt a string identical to its input; it is now `assertRepo()` and reads as the validator it always was. - Name the two request timeouts rather than repeating `30_000` in three signatures, and drop `request()`'s unreachable `token = null` default. - Trim comments that restated the code or repeated a nearby comment, and correct two that overclaimed: the normalizers narrow to a curated view rather than to "what is needed to fetch and verify", and the package is not disk-free — the semver shim reads npm's bundled copy. - README: fix the same disk claim, and stop showing a serialized download loop as the canonical example. - Tests: rejection-path tests now install a fetch that throws, so "no request was made" holds by construction instead of by a trailing assertion; add coverage for the repo names GitHub actually allows. `stasis/README.md` said `audit` builds on the npm and GitHub clients; it builds on the npm one only. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DKbtN73UaD2stKyg6XSKKN --- stasis-api/README.md | 22 ++++---- stasis-api/package.json | 3 +- stasis-api/src/github/index.js | 94 ++++++++++++++++------------------ stasis-api/src/npm/index.js | 24 ++++----- stasis-api/src/request.js | 22 ++++++++ stasis/README.md | 2 +- tests/github-api.test.js | 25 +++++++-- 7 files changed, 114 insertions(+), 78 deletions(-) create mode 100644 stasis-api/src/request.js diff --git a/stasis-api/README.md b/stasis-api/README.md index d5fe244d..5d03de3c 100644 --- a/stasis-api/README.md +++ b/stasis-api/README.md @@ -2,7 +2,9 @@ Zero-dependency registry API clients used by `@exodus/stasis`. -Transport only: no disk access, no credential discovery, no caching. +Transport only: no credential discovery, no caching, and no disk access — except the +`npm/semver` shim, which binds to the semver already bundled with the running Node's npm CLI +rather than installing one. | Export | What it provides | | - | - | @@ -11,16 +13,18 @@ Transport only: no disk access, no credential discovery, no caching. | `@exodus/stasis-api/github` | `releases()`, `release()`, `latestRelease()`, `asset()` — GitHub releases and their attachments | ```js -import { asset, releases } from '@exodus/stasis-api/github' - -const [latest] = await releases('ExodusOSS/stasis', { limit: 1 }) -for (const a of latest.assets) { - // `digest` (when GitHub reports one) is verified before the bytes are returned - const bytes = await asset('ExodusOSS/stasis', a.id, { digest: a.digest }) - console.log(a.name, bytes.byteLength) -} +import { asset, latestRelease } from '@exodus/stasis-api/github' + +const { tag, assets } = await latestRelease('ExodusOSS/stasis') +const bundle = assets.find((a) => a.name.endsWith('.stasis.code.br')) +// `digest` (when GitHub reports one) is verified before the bytes are returned +const bytes = await asset('ExodusOSS/stasis', bundle.id, { digest: bundle.digest }) +console.log(tag, bundle.name, bytes.byteLength) ``` +`asset()` buffers the attachment in memory, so a caller fetching several large assets should +bound its own concurrency rather than firing them all at once. + Every function takes an optional `signal` (defaulting to a timeout) and, for GitHub, an optional `token` — no token is ever read from the environment. diff --git a/stasis-api/package.json b/stasis-api/package.json index 1d4423b1..ef8cab73 100644 --- a/stasis-api/package.json +++ b/stasis-api/package.json @@ -11,7 +11,8 @@ "files": [ "src/github/index.js", "src/npm/index.js", - "src/npm/semver.cjs" + "src/npm/semver.cjs", + "src/request.js" ], "engines": { "node": ">=24.14.0" diff --git a/stasis-api/src/github/index.js b/stasis-api/src/github/index.js index f5999f58..4464bc69 100644 --- a/stasis-api/src/github/index.js +++ b/stasis-api/src/github/index.js @@ -1,6 +1,8 @@ import assert from 'node:assert/strict' import { hash } from 'node:crypto' +import { requestOk } from '../request.js' + // GitHub REST client, scoped to releases and their attachments ("assets" in API terms). // Transport only: no disk, and no credential discovery -- a caller that needs a private // repo or a higher rate limit (60 requests/hour per IP unauthenticated, 5000 @@ -13,27 +15,32 @@ const JSON_MEDIA_TYPE = 'application/vnd.github+json' // GitHub rejects requests that carry no User-Agent, so one is always sent. const USER_AGENT = '@exodus/stasis-api' -// Owners are alphanumeric with single inner hyphens (at most 39 alphanumerics); repo names -// add `.`, `_` and `-` (at most 100 chars). Validated rather than just escaped so a -// hand-assembled slug can't reshape the endpoint it is interpolated into -- a `..` segment -// would climb out of /repos/ once fetch normalizes the path. -const ownerRegex = /^[\da-z](?:-?[\da-z]){0,38}$/iu -const repoRegex = /^\w[\w.-]{0,99}$/u +// Metadata answers in one round trip; an asset download is bounded by its size instead. +const METADATA_TIMEOUT = 30_000 +const ASSET_TIMEOUT = 300_000 + +// Each half of a slug must be exactly one path segment, so a hand-assembled value can't +// reshape the endpoint it is interpolated into -- `.` and `..` would climb out of /repos/ +// once fetch normalizes the path. Deliberately a segment rule and not GitHub's account +// naming policy: that policy drifts, and encoding it here only turns a loosened rule into a +// local false rejection (it is how a leading-dot repo like `ExodusOSS/.github` gets refused). +const segmentRegex = /^[\w.-]{1,100}$/u +const isSegment = (s) => segmentRegex.test(s) && s !== '.' && s !== '..' // Refs hold nearly anything, so a tag is percent-encoded rather than matched; these are the // characters and sequences git itself forbids, rejected up front for a local error message. const badTagRegex = /[\s~^:?*[\]\\]|\.\./u // GitHub reports an asset's content digest as `:`. The hex length is pinned // per algorithm so a truncated digest fails as a bad argument, not as a content mismatch. const digestRegex = /^(?:sha256:[\da-f]{64}|sha384:[\da-f]{96}|sha512:[\da-f]{128})$/u +const nextLinkRegex = /^\s*<([^>]+)>\s*;\s*rel="?next"?/u -function repoSlug(repo) { +function assertRepo(repo) { assert(typeof repo === 'string', `Unexpected repo: ${repo}`) const parts = repo.split('/') assert.equal(parts.length, 2, `Expected an \`owner/repo\` slug: ${repo}`) const [owner, name] = parts - assert(ownerRegex.test(owner), `Unexpected repo owner: ${owner}`) - assert(repoRegex.test(name), `Unexpected repo name: ${name}`) - return `${owner}/${name}` + assert(isSegment(owner), `Unexpected repo owner: ${owner}`) + assert(isSegment(name), `Unexpected repo name: ${name}`) } function encodeTag(tag) { @@ -42,27 +49,16 @@ function encodeTag(tag) { return encodeURIComponent(tag) } -async function request(what, url, { accept = JSON_MEDIA_TYPE, token = null, signal }) { +function request(what, url, { accept = JSON_MEDIA_TYPE, token, signal }) { const headers = { Accept: accept, 'User-Agent': USER_AGENT, 'X-GitHub-Api-Version': API_VERSION } if (token !== null) { assert(typeof token === 'string' && token !== '', 'Expected a non-empty token') headers.Authorization = `Bearer ${token}` } - - let res - try { - // An asset download answers with a cross-origin 302 to a storage host. fetch drops - // Authorization when following a redirect to another origin, so the token stays with - // GitHub -- which is why redirects are followed here rather than handled by hand. - res = await fetch(url, { headers, signal }) - } catch (cause) { - throw new Error(`github ${what} request failed: ${cause.message}`, { cause }) - } - if (!res.ok) { - const text = await res.text().catch(() => '') - throw new Error(`github ${what} request failed: ${res.status} ${res.statusText}${text ? ` — ${text.slice(0, 200)}` : ''}`) - } - return res + // An asset download answers with a cross-origin 302 to a storage host. fetch drops + // Authorization when following a redirect to another origin, so the token stays with + // GitHub -- which is why redirects are followed rather than handled by hand. + return requestOk(`github ${what}`, url, { headers, signal }) } async function parseJson(what, res) { @@ -79,15 +75,17 @@ async function parseJson(what, res) { function nextLink(header) { if (!header) return null for (const part of header.split(',')) { - const m = /^\s*<([^>]+)>\s*;\s*rel="?next"?/u.exec(part) + const m = nextLinkRegex.exec(part) if (m && m[1].startsWith(`${API}/`)) return m[1] } return null } -// A deliberately narrow view of GitHub's payloads: what identifies a release plus what is -// needed to fetch and verify its attachments. Extra fields are dropped rather than passed -// through, so consumers can't come to depend on the raw API shape. +// A fixed, curated view of GitHub's payloads rather than a passthrough: volatile or +// caller-irrelevant fields (node_id, author/uploader, download_count, upload_url) are +// dropped and the rest renamed to a stable shape, so consumers can't come to depend on the +// raw API shape. Everything kept here is public API of this package, so a field is added +// when a caller needs one rather than pre-emptively -- adding is cheap, removing breaks. function normalizeAsset(raw) { assert(raw !== null && typeof raw === 'object' && !Array.isArray(raw), 'Expected a GitHub asset object') return { @@ -96,8 +94,7 @@ function normalizeAsset(raw) { label: raw.label ?? null, size: raw.size ?? null, contentType: raw.content_type ?? null, - // `:` when GitHub has computed one -- assets uploaded before it - // started reporting digests have none. Pass it to `asset()` to verify the download. + // Absent on assets uploaded before GitHub began reporting digests. digest: raw.digest ?? null, // 'uploaded' once the upload completed; anything else is not fetchable yet. state: raw.state ?? null, @@ -125,7 +122,6 @@ function normalizeRelease(raw) { } } -// One page of the releases listing, plus where the next one lives (null when this is the last). async function releasePage(url, options) { const res = await request('releases', url, options) const page = await parseJson('releases', res) @@ -136,15 +132,15 @@ async function releasePage(url, options) { // List a repo's releases, newest first, including drafts and prereleases (for those the // token must be able to see them). `limit` caps how many are returned; pass `Infinity` to // walk every page. -export async function releases(repo, { limit = 100, signal = AbortSignal.timeout(30_000), token = null } = {}) { - const slug = repoSlug(repo) +export async function releases(repo, { limit = 100, signal = AbortSignal.timeout(METADATA_TIMEOUT), token = null } = {}) { + assertRepo(repo) assert((Number.isInteger(limit) || limit === Infinity) && limit > 0, `Unexpected limit: ${limit}`) const out = [] // GitHub caps a page at 100 entries. Walking `Link: rel="next"` instead of incrementing // `page=` keeps the walk on URLs GitHub itself handed back. One `signal` covers the // whole walk, so `limit` also bounds how long a repo with many releases can take. - let url = `${API}/repos/${slug}/releases?per_page=${Math.min(limit, 100)}` + let url = `${API}/repos/${repo}/releases?per_page=${Math.min(limit, 100)}` while (url !== null) { // eslint-disable-next-line no-await-in-loop -- pagination is sequential by nature: the next URL comes from this page's Link header const { page, next } = await releasePage(url, { token, signal }) @@ -158,31 +154,31 @@ export async function releases(repo, { limit = 100, signal = AbortSignal.timeout } // Fetch a single release by its tag. -export async function release(repo, tag, { signal = AbortSignal.timeout(30_000), token = null } = {}) { - const url = `${API}/repos/${repoSlug(repo)}/releases/tags/${encodeTag(tag)}` - const res = await request('release', url, { token, signal }) +export async function release(repo, tag, { signal = AbortSignal.timeout(METADATA_TIMEOUT), token = null } = {}) { + assertRepo(repo) + const res = await request('release', `${API}/repos/${repo}/releases/tags/${encodeTag(tag)}`, { token, signal }) return normalizeRelease(await parseJson('release', res)) } // Fetch the latest release. GitHub's notion of "latest" skips drafts and prereleases -- // use `releases()` when those matter. -export async function latestRelease(repo, { signal = AbortSignal.timeout(30_000), token = null } = {}) { - const url = `${API}/repos/${repoSlug(repo)}/releases/latest` - const res = await request('release', url, { token, signal }) +export async function latestRelease(repo, { signal = AbortSignal.timeout(METADATA_TIMEOUT), token = null } = {}) { + assertRepo(repo) + const res = await request('release', `${API}/repos/${repo}/releases/latest`, { token, signal }) return normalizeRelease(await parseJson('release', res)) } -// Download one attachment's bytes by asset id (from a listing's `assets[].id`). When -// `digest` is given -- the listing's `digest`, `:` -- it is verified before -// the bytes are handed back, so a truncated or swapped download fails here and not -// somewhere downstream. Default timeout is generous: release assets can be large. -export async function asset(repo, id, { digest = null, signal = AbortSignal.timeout(300_000), token = null } = {}) { - const slug = repoSlug(repo) +// Download one attachment's bytes by asset id (from a listing's `assets[].id`). Pass the +// listing's `digest` to have it verified before the bytes are handed back, so a truncated or +// swapped download fails here rather than downstream. The default timeout is far longer than +// the metadata calls': release assets can be very large. +export async function asset(repo, id, { digest = null, signal = AbortSignal.timeout(ASSET_TIMEOUT), token = null } = {}) { + assertRepo(repo) assert(Number.isInteger(id) && id > 0, `Unexpected asset id: ${id}`) const expected = digest === null ? null : String(digest).toLowerCase() assert(expected === null || digestRegex.test(expected), `Unexpected digest: ${digest}`) - const url = `${API}/repos/${slug}/releases/assets/${id}` + const url = `${API}/repos/${repo}/releases/assets/${id}` const res = await request('asset', url, { accept: 'application/octet-stream', token, signal }) let bytes try { diff --git a/stasis-api/src/npm/index.js b/stasis-api/src/npm/index.js index 60b8f15f..51a1a5cd 100644 --- a/stasis-api/src/npm/index.js +++ b/stasis-api/src/npm/index.js @@ -1,7 +1,10 @@ import assert from 'node:assert/strict' +import { requestOk } from '../request.js' import semver from './semver.cjs' +const BULK_ADVISORIES_URL = 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk' + const packageNameRegex = /^(@[\da-z-]+\/)?[\w-]+(\.[\w-]+)*$/u export async function advisories(list, { signal = AbortSignal.timeout(30_000) } = {}) { @@ -17,20 +20,11 @@ export async function advisories(list, { signal = AbortSignal.timeout(30_000) } const entries = [...groups].map(([k, v]) => [k, [...v].toSorted((a, b) => semver.compare(a, b))]) const body = Object.fromEntries(entries.toSorted((a, b) => a[0] < b[0] ? -1 : 1)) - let res - try { - res = await fetch('https://registry.npmjs.org/-/npm/v1/security/advisories/bulk', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - signal, - }) - } catch (cause) { - throw new Error(`npm advisories request failed: ${cause.message}`, { cause }) - } - if (!res.ok) { - const text = await res.text().catch(() => '') - throw new Error(`npm advisories request failed: ${res.status} ${res.statusText}${text ? ` — ${text.slice(0, 200)}` : ''}`) - } + const res = await requestOk('npm advisories', BULK_ADVISORIES_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + }) return res.json() } diff --git a/stasis-api/src/request.js b/stasis-api/src/request.js new file mode 100644 index 00000000..06ce7e00 --- /dev/null +++ b/stasis-api/src/request.js @@ -0,0 +1,22 @@ +// Shared transport for this package's registry clients: make the request and turn both +// failure modes -- fetch itself rejecting, and a non-2xx answer -- into one Error shape, +// `