Skip to content

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

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

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

Conversation

@exo-nikita

Copy link
Copy Markdown
Collaborator

This PR extracts the GitHub releases API client into a new standalone @exodus/stasis-api package, making it reusable across projects. The package also includes the existing npm advisories client that was previously embedded in @exodus/stasis.

Summary

A new @exodus/stasis-api package has been created as a zero-dependency registry API client library. It provides transport-only clients for npm and GitHub APIs with no disk access, credential discovery, or caching. The GitHub client is newly extracted from internal usage, while the npm client is moved from @exodus/stasis to the new package.

Key Changes

  • New @exodus/stasis-api package with three export paths:

    • @exodus/stasis-api/npm — npm security advisories client
    • @exodus/stasis-api/npm/semver — lazily-bound semver from bundled npm CLI
    • @exodus/stasis-api/github — GitHub releases and asset download client
  • GitHub API client (stasis-api/src/github/index.js):

    • releases() — list a repo's releases with pagination support and optional limit
    • release() — fetch a single release by tag
    • latestRelease() — fetch the latest non-draft, non-prerelease release
    • asset() — download release assets with optional digest verification
    • Comprehensive input validation (repo slugs, tags, asset IDs)
    • Secure pagination that only follows links on the GitHub API host
    • Automatic timeouts and optional Bearer token authentication
  • Comprehensive test suite (tests/github-api.test.js):

    • 309 lines of tests covering all four functions
    • Tests for pagination, limits, input validation, error handling
    • Digest verification with SHA256/384/512 support
    • Security tests (token not leaked to other hosts, malformed inputs rejected before network calls)
  • Updated @exodus/stasis to import from the new package:

    • stasis/src/audit.js and stasis/src/audit-corrections.js now import from @exodus/stasis-api/npm
    • stasis/src/sbom.js imports semver from @exodus/stasis-api/npm/semver
    • Removed embedded npm API files from stasis package
  • Workspace updates:

    • Added stasis-api to pnpm workspace
    • Updated root README to list the new package
    • Updated public exports test to verify the new APIs are accessible

Notable Implementation Details

  • Security-first design: No credentials are ever read from the environment; tokens must be explicitly passed. Pagination only follows links on the GitHub API host to prevent token leakage.
  • Strict input validation: Repo slugs, tags, and asset IDs are validated before making network requests using regex patterns and git's own tag restrictions.
  • Digest verification: Asset downloads can be verified against SHA256/384/512 digests before being returned to the caller.
  • Configurable timeouts: All functions accept an optional signal parameter, defaulting to 30 seconds for metadata and 300 seconds for asset downloads.
  • Narrow API surface: Responses are normalized to drop extra fields, preventing consumers from depending on raw GitHub API shape.

https://claude.ai/code/session_01DKbtN73UaD2stKyg6XSKKN

claude added 5 commits July 28, 2026 08:05
…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
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