Skip to content

Extract GitHub API client into @exodus/stasis-api package - #149

Open
exo-nikita wants to merge 7 commits into
mainfrom
claude/stasis-api-directory-sbsuvz
Open

Extract GitHub API client into @exodus/stasis-api package#149
exo-nikita wants to merge 7 commits into
mainfrom
claude/stasis-api-directory-sbsuvz

Conversation

@exo-nikita

@exo-nikita exo-nikita commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

This PR creates a standalone @exodus/stasis-api package: zero-dependency, transport-only registry API clients (npm, GitHub) reusable across projects. The npm advisories client moves over from @exodus/stasis; the GitHub client (releases + repo subtrees) is new.

Summary

@exodus/stasis-api has no disk access, no credential discovery, and no caching. Three export paths:

  • @exodus/stasis-api/npm — npm bulk security advisories client (moved from @exodus/stasis)
  • @exodus/stasis-api/npm/semver — lazily-bound semver from the npm CLI bundled with the running Node
  • @exodus/stasis-api/github — GitHub releases, release assets, and repo subtrees

GitHub client

Split into shared plumbing and endpoint families:

  • src/github/core.js — host, headers (versioned media type, User-Agent), optional Bearer token, identifier validation (repo slugs as strict path segments; refs percent-encoded after rejecting what git itself forbids), and Link: rel="next" pagination that only follows URLs on the GitHub API host, so a token can never be carried elsewhere
  • src/github/releases.jsreleases() (paginated listing with limit, Infinity to walk every page), release() (by tag), latestRelease(), and asset() (attachment download with optional SHA256/384/512 digest verification before the bytes are handed back)
  • src/github/subtree.jssubtree(): a repo tree at an exact commit/tag/branch, fetched as one tarball request and read into a Map of repo-relative path → bytes, optionally narrowed to a path prefix while reading (entries outside it are vetted but never copied)
  • src/archive.js — in-memory tar.gz reader (Node zlib + ustar/pax/GNU-longname parsing, no external tar). Only regular files are kept; unsafe paths (absolute, .., backslash, NUL), duplicate paths, and multi-root archives are refused rather than sanitized. maxBytes (256 MiB default) is enforced on the downloaded bytes and again on the decompressed size, so a compression bomb fails instead of exhausting the heap
  • src/request.js — one shared transport shell used by both the npm and GitHub clients, producing the single uniform "… request failed: …" error shape and owning the package-wide timeout defaults

Responses are normalized to a fixed, curated shape (volatile fields like node_id/uploader dropped; only tarball_url kept since GitHub serves a tarball for every repo), so consumers cannot come to depend on the raw API payloads.

Security posture

  • No credentials are ever read from the environment — a token is only ever passed explicitly
  • Pagination refuses next links off the API host; asset/tarball redirects rely on fetch dropping Authorization cross-origin
  • Repo slugs, refs, and asset ids are validated before any request is assembled
  • Every request carries a default AbortSignal.timeout (30 s metadata, 300 s transfers), overridable via signal

Tests

  • tests/github-api.test.js — releases/release/latestRelease/asset: normalization, pagination (Link following, limits, off-host link refusal), input validation before any network call, digest verification including mismatches and malformed digests, error shapes
  • tests/github-subtree.test.js — tar fixtures built byte-by-byte: path filtering, binary round-trips, pax/ustar-prefix long names, symlink/dir dropping, escape/duplicate/multi-root refusal, download and decompression caps (including a compression-bomb case), corrupt archives
  • tests/fetch.helper.js — shared fetch stub, also adopted by tests/audit.test.js
  • tests/public-exports.test.js — the new export paths are importable and expose the documented functions

@exodus/stasis updates

  • audit.js / audit-corrections.js import from @exodus/stasis-api/npm, sbom.js from @exodus/stasis-api/npm/semver; the embedded src/apis/npm/ files are removed
  • stasis now depends on @exodus/stasis-api@1.0.0-beta.2 (exact pin, matching stasis-core/stasis-plugins)

Workspace

  • stasis-api added to the pnpm workspace and lockfile; root README and package description updated

🤖 Generated with Claude Code

https://claude.ai/code/session_01DKbtN73UaD2stKyg6XSKKN

claude added 7 commits August 10, 2026 07:20
…client

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DKbtN73UaD2stKyg6XSKKN
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DKbtN73UaD2stKyg6XSKKN
`subtree(repo, ref, { path, format })` returns a repo subtree at an exact
commit, tag or branch, as a Map of repo-relative posix path -> bytes. No git
client is involved and nothing is written to disk: one request to GitHub's
archive endpoint, decompressed and parsed in memory.

- `src/archive.js` — in-memory readers for both containers GitHub serves a tree
  as: gzipped tar (`/tarball`, default) and zip (`/zipball`). Node's zlib does
  the decompression; the container framing is parsed here, so the package stays
  dependency-free.
  - tar: ustar with `prefix` long names, GNU 'L' long names, pax 'x' path
    overrides, and base-256 sizes.
  - zip: central-directory driven, so sizes are read from the authoritative
    record rather than a local header that may defer them to a data descriptor.
    Stored and deflated entries; zip64 is rejected rather than misread.
- Only regular files are returned. Directories and symlinks carry no usable
  content, and a symlink target is precisely what could point out of the tree.
- Entries that escape the tree (absolute, `..`, backslash, NUL) are rejected
  rather than sanitized, as are archives with more than one top-level directory.
- The archive's top-level directory is returned as `root` — it records the
  commit the ref resolved to — and is taken from the entries rather than
  reconstructed from a name pattern and assumed to match.
- `maxBytes` (256 MiB default) bounds the download from both `content-length`
  and the streamed byte count, since the archive is held in memory to parse.
- A `path` matching nothing throws: that is a typo far more often than an
  intentionally empty subtree, and an empty Map would look like success.

Verified against real GitHub archives, not only the hand-built fixtures: the tar
and zip readers are independent code paths and decode the same commit to
identical bytes across all 476 files of a real repo. That check is also what
caught the archive endpoints answering 415 to `Accept: application/octet-stream`
— the header the asset endpoint requires — so the archive request keeps the
default JSON Accept and lets the codeload redirect serve the bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DKbtN73UaD2stKyg6XSKKN
- Drop the zip container. GitHub serves /tarball for every repo, so supporting
  both was a second way to do one job: `subtree()` loses its `format` option,
  `readZip` and its central-directory/EOCD/zip64 handling are gone, and
  `zipball_url` is no longer carried on a normalized release beside the tarball.
- Split github/index.js by altitude. core.js holds the plumbing that knows how
  to talk to GitHub but not what to ask it — host, headers, token policy,
  identifier validation, Link pagination, capped body reads — and each endpoint
  family is its own module on top: releases.js (releases + assets) and
  subtree.js. index.js re-exports just the five public functions, so core.js
  stays internal and can change without breaking a consumer.
- Assert an archive never repeats a path. tar can legally carry the same path
  twice, and reading into a Map would keep whichever came last, so a second
  entry could shadow the first and the shadowed bytes would never be seen by any
  caller. Refuse the archive instead of silently picking a winner.

Re-verified against real GitHub archives after the restructure: the whole tree
of a real repo still reads (476 files, no duplicate-path trip) and a subtree
filter still resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DKbtN73UaD2stKyg6XSKKN
Cleanups from a review pass over the new package. No public API change --
`asset, latestRelease, release, releases, subtree` and their option shapes are
untouched.

Structure:
- Add `repoUrl(repo, ...segments)` as the single route by which a slug becomes a
  URL. `assertRepo` was a separate step every endpoint had to remember before
  hand-interpolating `${API}/repos/${repo}/...` five times over; now the check
  cannot be forgotten, and `API`/`assertRepo` stop being exported for string
  concatenation at the leaves.
- Hold both halves of the timeout policy in core.js: `ASSET_TIMEOUT` and
  `ARCHIVE_TIMEOUT` were the same 300s under two names in two files, while the
  comment describing the policy lived in a third. Now one `TRANSFER_TIMEOUT`.
- Let `request()` own the token policy. The `token = null` sentinel was restated
  in all five signatures and interpreted with a strict `!== null`, so an
  endpoint that omitted the default would report "Expected a non-empty token"
  for a caller who supplied none.
- Move `readCapped` to subtree.js. Its remediation hint names `maxBytes`, one
  endpoint's option, so it was never core policy -- asset() reads its body
  whole, whose size the caller already knows from the release listing.
- Fold `releasePage` into the pagination loop, and `release`/`latestRelease`
  into one `oneRelease` -- they differed only in how the release is addressed.

Efficiency:
- Filter the archive while reading it. `subtree({ path })` copied every regular
  file out of the tarball and then discarded most of them; `readTarGz` now takes
  a `select` hook that re-keys and narrows during the walk, so a skipped entry's
  bytes are never copied. On a 2000-file 9.2 MB archive keeping 20 files: 9.4 ms
  and 0.08 MB copied, against 15.3 ms and 8.19 MB. The safety and duplicate-path
  checks still run on every entry, so an archive that is unsafe or
  self-shadowing outside the requested subtree is still refused -- covered by a
  new test.
- Collapse three full Maps into one. readTar built one, stripRoot rebuilt it
  re-keyed, subtree rebuilt it again to apply the prefix; the root is now taken
  during the same walk.
- Read tar text fields by range instead of slicing two Buffer views per call
  (three calls per entry), and test `..` with one regex instead of splitting
  every path into segments.
- Drop `view()`: correct, but unreachable -- both call sites already pass a
  Buffer, so it never converted anything.

Tests and packaging:
- One `tests/fetch.helper.js` replaces three copies of the same global-fetch
  swap (audit.test.js had already drifted from the other two).
- Ship `src` instead of enumerating every module in `files`, which is one added
  module away from publishing a tarball that omits it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DKbtN73UaD2stKyg6XSKKN
… maxBytes

subtree() capped only the downloaded bytes; gunzipSync then decompressed
without a bound, so an archive well under the cap could still attempt a
huge allocation (gzip of zeros expands ~1000:1). Enforce maxBytes on the
decompressed size via zlib's maxOutputLength, reported as the size refusal
it is rather than as a malformed archive. Also fix a stale comment in
github/index.js: capped body reads live in subtree.js, not core.js.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DKbtN73UaD2stKyg6XSKKN
Quality pass over the branch, no behavior changes intended:

- The timeout numbers move to request.js, the shared shell both clients
  import, so npm's inline 30s literal becomes the same METADATA_TIMEOUT
  the GitHub endpoints use instead of coinciding with it.
- The `<label> request failed` shape is now built only by request.js
  (requestFailed), and a new core.js readBody owns the `github <what>`
  labeling for body reads, so asset() stops hand-assembling both.
- readTarGz inflates via promisified zlib on the threadpool -- a
  hundreds-of-MB decompression no longer blocks the event loop -- and
  loses its unreachable uncapped mode: both callers-to-be must pass
  select and maxBytes explicitly.
- Comments right-sized: core.js and github/index.js claim slug/ref
  validation and next-link parsing (not all validation and pagination,
  parts of which are endpoint-level), archive.js describes the generic
  reader it is rather than defining itself by its GitHub caller.
- Dead `token !== undefined` check dropped: the destructuring default
  already turns undefined into null.
- Tests: audit.test.js adopts the json() helper its own extraction
  created (five inline expansions replaced, a shadowing local renamed),
  and the digest case test uppercases the whole string since asset()
  lowercases it anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DKbtN73UaD2stKyg6XSKKN
@exo-nikita
exo-nikita force-pushed the claude/stasis-api-directory-sbsuvz branch from c3a6fbc to 7dacc40 Compare August 10, 2026 07:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants