diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5277312 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,29 @@ +# Weekly updates for the npm dependencies (pnpm at the root, npm in examples/) and for GitHub +# Actions. Security updates are separate and not delayed by the cooldown. +version: 2 +updates: + - package-ecosystem: npm + directories: [/, /examples] + schedule: + interval: weekly + # Mirrors minimum-release-age in .npmrc: only versions published at least 7 days ago. + cooldown: + default-days: 7 + groups: + # Minor and patch updates in one PR; each major update gets its own. + npm: + update-types: [minor, patch] + ignore: + # @types/node tracks the Node.js floor in engines, not the latest Node.js (CONTRIBUTING.md, + # "Toolchain pins"): it moves only when the floor is raised. + - dependency-name: '@types/node' + update-types: ['version-update:semver-major'] + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 + groups: + actions: + patterns: ['*'] diff --git a/.gitignore b/.gitignore index f0dc79d..7503e27 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,13 @@ node_modules dist coverage -# Environment variables +# Local env and credentials .env +.env.* +!.env.example +*.local +*.pem +*.key .claude # Examples use npm (package-lock.json is tracked); ignore a stray pnpm lockfile diff --git a/.npmignore b/.npmignore index 7ad1586..fc4cb09 100644 --- a/.npmignore +++ b/.npmignore @@ -3,7 +3,7 @@ .husky .gitignore .nvmrc -CLAUDE.md +AGENTS.md eslint.config.mjs prettier.config.mjs tsconfig.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7d4bc7f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,20 @@ +# glassnode-api-ts-client: agent instructions + +Read [CONTRIBUTING.md](CONTRIBUTING.md) first: setup, commands, the version and changelog rules, +and the design notes all live there. Before opening a PR, run the checks in its "Run the full +local check list" step. + +## Don't + +- Edit `test/fixtures/contract/` by hand: it holds recorded real API responses. A contract test + failing there is API drift: fix the schema, never the fixture or the test. +- Run `scripts/record-fixtures.mjs`: it calls the live API with a real key and spends quota. Only + a maintainer re-records the fixtures. +- Bump `@types/node` past the Node.js floor (`^22`), TypeScript to 7.x, or TypeScript past what + `typedoc` lists: see "Toolchain pins" in CONTRIBUTING.md. +- Write extensionless or `.ts` relative imports in `src/`: they are `./foo.js`. +- Add a `browser` field or a `browser` condition in `exports`, even when `publint` suggests it. +- Add `.transform()` to `GlassnodeConfigSchema`, or set `tag` in `package.json` `publishConfig`. +- Expose the API key: logged URLs mask it, and hook payloads and error messages never carry it. +- Rename the `test` or `compat-node-floor` CI jobs (the rulesets require them by name), or + reference an action by tag instead of a full commit SHA. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 3cb4547..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,322 +0,0 @@ -# Claude Instructions - -This document provides context for Claude when working with this project. - -## Project Structure - -- `/src` - Source code - - `/src/index.ts` - main entry (`glassnode-api`): client, errors, types - - `/src/x402.ts` - separate subpath entry (`glassnode-api/x402`): `createX402Fetch` and helpers - - `/src/types` - TypeScript type definitions (Zod schemas + inferred types) -- `/test` - Test files (Vitest) - - `/test/contract.spec.ts` - contract tests: each real API response in `/test/fixtures/contract` - (recorded by `scripts/record-fixtures.mjs`, listed in its `manifest.json`) goes through the - client and its schemas. Never hand-edit the fixtures; re-record them. A schema failure there is - real API drift: fix the schema, never the fixture or the test -- `/examples` - Example usage patterns (own `package.json`; type-checked via `tsconfig.examples.json`) -- `/scripts` - `smoke-timeout.mjs`, the plain-Node runtime smoke run on the Node floor (22) in CI; - `record-fixtures.mjs`, which records the contract fixtures (needs an API key; run only to refresh); - the release tooling `release-state.sh` + `release-plan.mjs` (publish.yml) and - `check-changelog-heading.mjs` (ci.yml), tested by `test/release-scripts.spec.ts` -- `/typecheck/x402-node16` - consumer type-check fixture (node16 resolution, `skipLibCheck: false`) -- `/dist` - Compiled output (not checked into git) -- `/api-docs` - Generated TypeDoc API reference (`pnpm run docs`; not checked into git, never - published to npm) -- `CONTRIBUTING.md` - contributor guide (the PR flow, local check list, version/changelog rules, - security reporting); keep it in sync with this file -- `LICENSE` (Apache-2.0) and `NOTICE` (attribution; listed in `package.json` `files` so it ships in - the npm tarball, as Apache-2.0 §4(d) requires) - -## Development Workflow - -- Use pnpm as package manager -- Run tests with `pnpm test` (Vitest, single run); `pnpm run test:watch` and `pnpm run test:coverage` are also available -- Lint code with `pnpm run lint` -- Format code with `pnpm run format` (check only: `npx prettier --check .`) -- Build the project with `pnpm run build` -- Build browser bundles with `pnpm run build:browser` -- Build the API reference with `pnpm run docs` (TypeDoc; config in `typedoc.json`, output in - `api-docs/`). It fails on any warning (`treatWarningsAsErrors`): a broken `{@link}`, a doc - comment referencing a type the entry points do not export, or a bad README path. See - [API reference (TypeDoc)](#api-reference-typedoc) -- Type-check tests with `pnpm exec tsc -p tsconfig.test.json --noEmit` (Vitest does not type-check) - and examples with `pnpm exec tsc -p tsconfig.examples.json` -- The Husky pre-commit hook runs `pnpm test`, `lint-staged` (ESLint + Prettier + related tests on - staged files) and `pnpm run build` - -### Node.js versions — two distinct baselines - -Keep these separate; they answer different questions: - -- **Consumers of the published package** need **Node.js >= 22** (raised from 18 in 1.0; Node 18 - and 20 are end-of-life). This is the contract in `package.json` `engines`. The shipped code - uses only universal APIs plus global `fetch` — no `node:` builtins — and also runs in browsers, - so the floor is not a licence to use Node-only APIs. To enforce the floor at compile time, - `@types/node` is pinned to the **floor** (`^22`), not the latest, so the compiler rejects any - API newer than Node 22. Both approximate the floor: `engines` says `>=22.0.0`, but the CI floor - job runs the latest 22.x and `@types/node` is `^22.20`, so an API added after 22.0 is not - caught. Do **not** bump `@types/node` to track the dev runtime — bump it only - if the minimum supported Node is intentionally raised (a breaking change → major/`engines` - bump). -- **Developers of this repo** run **Node.js 24** (`.nvmrc`, the main CI and publish jobs). The dev toolchain - sets the floor here: `vitest` 5 requires Node `>= 22.12` — that constraint is dev-only and never - reaches consumers (vitest is a devDependency). The CI `compat-node-floor` job proves the - consumer floor instead: on Node 22 it builds, `require`s the CJS entry and runs - `scripts/smoke-timeout.mjs`. Its name is version-neutral on purpose: the rulesets require it by - name, so a later floor bump changes only its `node-version`. - -## Coding Standards - -- TypeScript for all source files -- Vitest for testing (config in `vitest.config.ts`; coverage thresholds live there) -- ESLint and Prettier for code quality -- Husky for git hooks -- Zod for runtime validation of API responses and config - -### TypeScript version - -- Pinned to TypeScript **6.x** (`^6.0.3`), **not** 7.x. `typescript-eslint` still declares its - `typescript` peer as `>=4.8.4 <6.1.0` (verified on the current latest, `8.70.0` — its canary - is also 8.x), so TypeScript 7.0 breaks `pnpm run lint` in CI and the Husky pre-commit hook. - `6.0.3` is already the newest stable 6.x, so TypeScript needs no bump. Only move to 7.x once - `typescript-eslint` ships a release whose peer range accepts it. -- `typedoc` (`^0.28.20`) also gates the TypeScript version: its `typescript` peer is an explicit - list of minors (`5.0.x || … || 5.9.x || 6.0.x` on 0.28.20, the current latest), so any - TypeScript bump — even to 6.1 — needs a `typedoc` release that lists it, or `pnpm run docs` - (and CI) break. - -### API reference (TypeDoc) - -- Entry points `src/index.ts` (module `glassnode-api`) and `src/x402.ts` (`glassnode-api/x402`), - named by their `@module` comments; README.md is the landing page. -- `typedoc-plugin-zod` expands `z.infer`/`z.input` aliases into their object types, so Zod-derived - types (e.g. `MetricMetadata`, `GlassnodeConfig`) render readably, with their field comments, - instead of as `z.infer`. -- Validation: `notExported`, `invalidLink`, `invalidPath` and `rewrittenLink` are on, and every - warning is an error. `notDocumented` is **off**: with it on, the only reports are nested members - of Zod objects and `z.enum` literals (e.g. `p50`, `ccdata`, `tier1`), and `z.enum` literals - cannot carry comments at all. Every top-level export and class member is documented — keep it so. - -## API Client - -The main class is `GlassnodeAPI` which takes a configuration object (validated with Zod in the -constructor; an invalid config throws `GlassnodeConfigError`): - -- `apiKey` (required unless `x402`) - Glassnode API key (non-empty string) -- `apiKeyLocation` (optional) - `'query'` (default, `api_key` query param) or `'header'` (`X-Api-Key` - header, keeps the key out of URLs). `'header'` is server-side only: the API's CORS preflight does - not allow `X-Api-Key`, so browsers block it. With `'header'`, a custom `fetch` gets - `(url, { headers, redirect: 'manual' })` (+ `signal` when a timeout or per-call signal is set) so - `X-Api-Key` is never resent to a redirect target (a 3xx surfaces as a non-retried - `GlassnodeApiError`); with no key, no header is sent. The default `'query'` path keeps calling - `fetch(url)` with one argument. `createX402Fetch` likewise forces `redirect: 'manual'` (keeps - `'error'`) so a signed payment header is never sent cross-origin. -- `apiUrl` (optional) - Base URL, defaults to `https://api.glassnode.com`, or - `https://x402.glassnode.com` when `x402` is set; an explicit value always wins -- `x402` (optional) - Route through the paid x402 endpoint (default `false`); requires `fetch` (an - x402-capable one, from `createX402Fetch` in `glassnode-api/x402`) -- `logger` (optional) - Callback for debug logging (e.g. `console.log`) -- `hooks` (optional) - Structured observability callbacks `{ onRequest, onResponse, onRetry, onError }` - (types in `src/types/hooks.ts`; strict, so an unknown hook name throws). Called synchronously, - never awaited; a throwing/rejecting hook is swallowed (reported to `logger`). Payloads carry a - per-call `callId`, the redacted URL and no headers — the API key must never reach a hook payload. - Events are read-only by contract, but `event.error` is the live error the caller receives. -- `fetch` (optional) - Custom fetch function for testing or custom HTTP behavior (default - `globalThis.fetch`) -- `maxRetries` (optional) - Number of retries for 429/5xx responses and transport failures (network - errors, per-attempt timeouts); non-negative integer, default 2 (`0` disables retries). With - `x402` the default is 0: the client cannot tell whether a custom payment fetch refuses to retry - after a signed payment was sent (the `createX402Fetch` fetch does, raising a never-retried - `GlassnodePaymentError`), so a retry could pay twice. An explicit value always wins. The - constructor applies the default (`DEFAULT_MAX_RETRIES` / `DEFAULT_X402_MAX_RETRIES` in - `src/types/config.ts`), not the schema: `GlassnodeConfigSchema` must stay a plain `z.object` - (no `.transform()`), so `.shape`/`.pick`/`.extend`/`.partial` keep working -- `retryDelay` (optional) - Base delay in ms between retries (default 1000, doubles each attempt, - capped at `maxRetryDelay`, then full jitter; a `Retry-After` on a retried response is used - instead, capped but not jittered) -- `maxRetryDelay` (optional) - Upper bound in ms for a single retry wait (default 30000) -- `timeout` (optional) - Per-attempt timeout in ms; each attempt aborts via `AbortSignal.timeout()` - (default none) - -`timeout`, `retryDelay` and `maxRetryDelay` must be positive integers up to 2147483647 ms (2^31 − 1, -the largest timer delay). Every method also takes optional per-call options as its last argument, -`{ signal?, timeout? }` (`callMetric` also accepts a Zod `schema`). The public API is what -`src/index.ts` and `src/x402.ts` export; README.md documents it for users and must stay in sync. - -## Versioning - -Follow [semver](https://semver.org/): - -- **Major** (1.0.0 → 2.0.0): Breaking changes (removed/renamed exports, changed method signatures, - changed defaults, tightened or widened response schemas, a higher minimum Node.js version) -- **Minor** (0.4.0 → 0.5.0): New features, new methods, new config options (backward-compatible) -- **Patch** (0.5.0 → 0.5.1): Bug fixes, docs, internal refactors (no API changes) - -Since 1.0.0, README "Stability and versioning" states what semver covers (public API, what a minor -or a major may change); keep it in sync with this policy. - -**Before every commit**, you MUST: - -1. Bump `version` in `package.json` (patch, minor, or major as appropriate) -2. Add a corresponding entry to `CHANGELOG.md` describing the changes - -Exception: PRs into `release/**` branches carry no version bump (they still add a CHANGELOG entry), -except the one that prepares the release; see [Release branches](#release-branches). - -The `version` in `package.json` is exactly what gets published: CI never bumps it. A change -merged without a bump publishes nothing (see [Publishing](#publishing)). - -## Build Targets - -- **Node.js (CJS)**: `tsc` → `dist/` (CommonJS; the `require` entry). Relative imports in `src/` - carry explicit `.js` extensions so the same source also emits valid ESM. -- **Node.js (ESM)**: `tsc -p tsconfig.esm.json` → `dist/esm/` (unbundled ES modules with `zod` - externalized; the `import` entry). A generated `dist/esm/package.json` (`{"type":"module"}`) marks - the folder as ESM. `exports` uses per-condition `types` (ESM `.d.ts` for `import`, CJS for - `require`) — verified with `publint` + `@arethetypeswrong/cli` in CI. -- **Browser**: Rollup (`tsconfig.browser.json`) produces minified UMD - (`dist/glassnode-api.umd.min.js`, global `GlassnodeAPI`) and minified ESM - (`dist/glassnode-api.esm.min.js`) bundles from `src/index.ts`, with `zod` bundled in and - `src/x402.ts` excluded. The `module` field points at the unbundled `dist/esm/index.js`, not a - Rollup bundle. Source maps are generated `hidden` and not published. - `tsconfig.browser.json` uses `module: ESNext` + `moduleResolution: bundler` (not the deprecated - `node`/`node10`, which TypeScript 7 drops); `@rollup/plugin-node-resolve` does the actual - resolution, so the setting only affects type-checking. -- **CDN entry**: the `unpkg` and `jsdelivr` fields point at the UMD bundle, so the bare package URL - on those CDNs serves it (unpkg never read `browser`: it served the CJS `main` before 1.0). There - is deliberately **no** top-level `browser` field and **no** `browser` condition in `exports`. A - `browser` condition inside `exports` would steer webpack 5, Vite and esbuild - (`platform: 'browser'`) to the pre-minified bundle with `zod` inlined instead of the - tree-shakeable ESM build. The top-level field is ignored by them whenever `exports` exists; it - only affected `exports`-unaware tools such as Browserify. Keep both out, even though `publint` - suggested the condition (#37). -- Config: `tsconfig.json` (CJS), `tsconfig.esm.json` (ESM), `tsconfig.browser.json` (browser), - `tsconfig.test.json` (tests/IDE), `tsconfig.examples.json` (type-checks `examples/` against - `src/` using root deps), `examples/tsconfig.json` (ts-node config for running the examples; - extends `tsconfig.examples.json`), `typecheck/x402-node16/tsconfig.json` (consumer fixture). The - package sets `"type": "commonjs"`. - -### Why `src/` imports use `.js` specifiers - -Relative imports in `src/` are written `./foo.js` even though the file is `foo.ts`. Keep them so: - -- `dist/esm/` is unbundled `tsc` output, and Node's ESM loader requires full file extensions, so - the emitted specifiers must end in `.js`. `tsc` never rewrites a `.js` specifier, so it has to be - written that way in the source. -- TypeScript maps a `.js` specifier to the matching `.ts` file when type-checking, so the editor, - `tsc` and Vitest all resolve it. -- Extensionless specifiers (`./foo`) only work behind a bundler (or CommonJS `require`); they break - `dist/esm/` under Node. -- `.ts` specifiers with `rewriteRelativeImportExtensions` were tried and rejected: `tsc` rewrites - them in the emitted `.js` but leaves `.ts` specifiers in the emitted `.d.ts`. -- Tools that run `src/` directly must map `.js` back to `.ts` themselves. ts-node's CommonJS - `require` does not by default, which is why `examples/tsconfig.json` sets ts-node's - `experimentalResolver`, and CI imports `../src` through ts-node from `examples/` to guard it. - -## CI - -`.github/workflows/ci.yml` runs on pull requests to `main` and to `release/**` branches, and -`publish.yml` calls it (`workflow_call`) as its `verify` job, so a release runs the same checks: - -- `test` (Node 24): the CHANGELOG heading check (below), lint, `test:coverage` (thresholds in - `vitest.config.ts`), `tsc` on `tsconfig.test.json` and `tsconfig.examples.json`, an offline - ts-node import of `../src` from `examples/` (module-resolution guard, no example runs), `build`, - `build:browser`, the `typecheck/x402-node16` consumer check, `docs` (TypeDoc), `publint` and - `@arethetypeswrong/cli --pack .`. -- `compat-node-floor` (Node 22, the `engines` floor): build, CJS `require` smoke, - `scripts/smoke-timeout.mjs`. - -The CHANGELOG heading check (`scripts/check-changelog-heading.mjs`, target = the PR's base branch, -or `main` when `publish.yml` calls `ci.yml`): the top `## ` heading of `CHANGELOG.md` must be -exactly `## `; into `release/**`, `## (unreleased)` is also accepted -(not compared with package.json, which PRs into a release branch do not bump, except the release -prep PR; see [Release branches](#release-branches)). So a heading still marked "(unreleased)" -fails the final release-branch PR into `main` until it is renamed. - -`.github/workflows/docs.yml` builds the API reference on every push to `main` and deploys it to -GitHub Pages (https://glassnode.github.io/glassnode-api-ts-client/). It requires the repo setting -Settings → Pages → Source: **GitHub Actions**. - -Its concurrency group cancels superseded runs only for pull requests; a run called by -`publish.yml` gets its own group and is never cancelled. - -Every action in the workflows is pinned by full commit SHA with a `# vX.Y.Z` comment (supply-chain -hardening), never by a movable tag. To bump one, resolve the tag with -`gh api repos///git/ref/tags/` (for an annotated tag, dereference it with -`gh api repos///git/tags/` to get the commit), update SHA and comment together, -and read the release notes for changed inputs or defaults. - -### Release branches - -A major release is prepared on a long-lived `release/**` branch (e.g. `release/1.0`, cut from -`main`); its breaking changes land there as separate PRs, and nothing publishes from it. - -- **Versioning:** PRs into `release/**` carry **no** version bump (except the last one, below). - Each one does add its `CHANGELOG.md` entry, with migration notes for breaking changes, under a - single `## 1.0.0 (unreleased)`-style heading at the top. Before the release branch goes to `main`, a - last PR (into the release branch, or the final PR itself) bumps `version` (e.g. to `1.0.0`) and - renames that heading to exactly `## ` (e.g. `## 1.0.0`): `publish.yml` takes the GitHub - Release notes from the line that equals `## `, so any suffix left on the heading leaves - the Release without notes. `ci.yml` enforces this (the CHANGELOG heading check, see [CI](#ci)): - into `release/**` the `(unreleased)` heading or `## ` passes, into `main` - only the latter. The merge into `main` publishes as usual. -- **CI:** `ci.yml` runs on PRs into `release/**`, but that is not a merge gate by itself: the checks - block a merge only if a ruleset for `release/**` requires them. Only PRs are CI-checked, not - direct pushes to the release branch; the combined state is checked by the final PR to `main`. -- **Keeping in sync:** merge `main` into the release branch during its lifetime, and right after - any CI change lands on `main`. A `pull_request` run uses the workflow from the PR's merge commit, - so until the release branch has a `ci.yml` whose trigger includes `release/**` (this change), - PRs into it get no CI at all. - -## Publishing - -- `.github/workflows/publish.yml` releases the `version` already in `package.json`. It never bumps - the version, commits or pushes to `main`. -- `main` is protected by a ruleset: no direct pushes, changes land through PRs with the - required CI checks. Every change reaches `main` through a merged PR. -- On every push to `main` (a merge): - 1. `verify` runs `ci.yml` (the full CI check list, `test` and `compat-node-floor`), read-only. - 2. `release` (read-only) runs `scripts/release-state.sh`: `npm view glassnode-api@`. - Only an E404 means "not published"; any other `npm view` failure (network, registry error) - fails the job, never publishes. If the version is already on npm (a merge without a bump, a - re-run), the run ends there with a notice in the job summary, and no approval is requested. - It also runs `npm view glassnode-api versions dist-tags` (same failure policy) and - `scripts/release-plan.mjs`, which picks the dist-tag and warns in the job summary about - skipped releases: `CHANGELOG.md` versions between the last published version (the highest - on npm below this one) and this one that are missing on npm. - 3. `publish` (`needs: [verify, release]`) runs in the `npm` **environment**, so it waits for a - required reviewer to approve. It re-checks npm (and the dist-tag), builds, runs - `npm publish --tag `, pushes the `v` tag on the published commit and creates - a GitHub Release from the version's `CHANGELOG.md` section. An existing tag on the same commit - or an existing Release is fine; a tag on another commit is left alone with a warning. If the - version is on npm from this very commit (npm records its `gitHead`), a re-run only (re)creates - the missing tag and Release. -- Only `publish` has write permissions: `id-token: write` (OIDC) and `contents: write` (the tag and - Release, via `GITHUB_TOKEN`). A `npm-publish` concurrency group serializes publishes and never - cancels one in progress, but GitHub keeps only **one pending** run per group: a newer merge's - publish replaces an older one still queued or awaiting approval, and that version is never - published (0.29.5 and 0.30.0, 2026-09-29, #33). Merge release PRs one at a time. To recover, - re-run the skipped version's run (CONTRIBUTING.md "Releases"). -- **Dist-tag guard** (`scripts/release-plan.mjs`, SemVer 2.0.0 precedence incl. prereleases, no - dependency): `npm publish` always gets an explicit `--tag`: `latest`, or - `backport-.` for a version lower than the current `latest` (such a re-run), or - `next` for a prerelease, so `latest` never moves backwards. Not `v.`: npm rejects a - tag name that is a valid semver range. That GitHub Release gets `--latest=false` (and - `--prerelease` for a prerelease version). A re-run uses its own commit's workflow, so a run from - before this guard (pre-1.0) publishes with npm's default tag: check `npm dist-tag ls` afterwards. - `release-state.sh` fails unless the tag matches `latest|next|backport-.`. `package.json` - `publishConfig` deliberately sets **no** `tag`: a `tag: "latest"` there would force `latest` on - a manual publish or an npm that does not let `--tag` override it (npm's default is `latest` - anyway). Keep it out. -- It uses **npm Trusted Publishing (OIDC)** with provenance — there is **no `NPM_TOKEN` - secret**. The Trusted Publisher for `glassnode-api` on npmjs.com must name repo - `glassnode/glassnode-api-ts-client`, workflow `publish.yml` and environment `npm`. -- The `npm` environment (Settings → Environments) must have the maintainer as required reviewer - and allow deployments from `main` only. -- npm CLI `>= 11.5.1` performs the OIDC exchange, so the workflow installs the latest npm - and publishes with `npm publish` (not `pnpm publish`, which uses the setup-node - placeholder token and 404s). The setup-node `.npmrc` is overwritten before publishing so - npm authenticates via OIDC instead of the placeholder token. -- The repo `.npmrc` sets `ignore-scripts=true`, so `npm publish` skips `prepublishOnly`: the - workflow runs `build` and `build:browser` itself. The `pnpm/action-setup` step sets - `npm_config_ignore_scripts: 'false'` because the standalone pnpm needs its own setup script. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 02d4c17..ab26b4e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,8 @@ Project layout: - `examples/` - runnable usage examples (their own `package.json`) - `scripts/` - the Node-floor smoke test, the contract fixture recorder and the release tooling - `typecheck/x402-node16/` - a consumer type-check fixture +- `NOTICE` - attribution; listed in `package.json` `files` so it ships in the npm tarball, as + Apache-2.0 section 4(d) requires Add or update tests for what you change. The public API is what `src/index.ts` and `src/x402.ts` export; if you change it, update `README.md` to match and give every new export a doc comment. @@ -50,13 +52,15 @@ newer than Node 22 in `src/`. ### 3. Bump the version and add a changelog entry -Every change bumps `version` in `package.json` and adds an entry at the top of `CHANGELOG.md` -describing it. Follow [semver](https://semver.org/): +Every change to the published package bumps `version` in `package.json` and adds an entry at the +top of `CHANGELOG.md` describing it. Docs-, CI- and tooling-only changes (README, CONTRIBUTING, +workflows, tests, lint and editor config) don't: they publish nothing, and the top `CHANGELOG.md` +heading must keep naming the current version. Follow [semver](https://semver.org/): - **Major**: breaking changes (removed or renamed exports, changed method signatures, changed defaults, tightened or widened response schemas, a higher minimum Node.js version). - **Minor**: new features, methods or config options that are backward compatible. -- **Patch**: bug fixes, docs and internal refactors with no API change. +- **Patch**: bug fixes and internal refactors with no API change. The README's [Stability and versioning](./README.md#stability-and-versioning) section spells out what counts as breaking. @@ -88,6 +92,8 @@ pnpm exec tsc -p tsconfig.test.json --noEmit pnpm exec tsc -p tsconfig.examples.json pnpm run build pnpm run build:browser +pnpm exec tsc -p typecheck/x402-node16/tsconfig.json +node scripts/check-changelog-heading.mjs main npx prettier --check . pnpm run docs pnpm dlx publint @@ -145,16 +151,142 @@ restores it with `npm dist-tag add glassnode-api@ latest`. `test/fixtures/contract/` holds real API responses recorded by `scripts/record-fixtures.mjs`. Never hand-edit them; re-record them. That needs an API key, so it is only done to refresh the -fixtures. See [Recording contract fixtures](./README.md#recording-contract-fixtures) in the README. +fixtures. A contract test that fails against a fixture is real API drift: fix the schema, not the +fixture or the test (after a re-recording, the README lists the few literal values in +`test/contract.spec.ts` that may legitimately need updating). See +[Recording contract fixtures](./README.md#recording-contract-fixtures) in the README. ## Security -- Report vulnerabilities privately, through GitHub's private vulnerability reporting on this - repository (the **Security** tab → **Report a vulnerability**). Never open a public issue for - them. +- Report vulnerabilities privately as described in [SECURITY.md](./SECURITY.md). Never open a + public issue for them. - Never commit API keys or other secrets, including in fixtures, examples, `.env` files or test output. +## Design notes + +Decisions that are easy to undo by accident. Read the relevant one before changing the toolchain, +the build or the workflows. + +### Node.js versions + +Two baselines, kept separate: + +- **Consumers** need Node.js >= 22 (`package.json` `engines`; raised from 18 in 1.0). `src/` uses + only universal APIs plus global `fetch` and also runs in browsers, so the floor is no licence to + use Node-only APIs. +- **Developers** run Node.js 24 (`.nvmrc`, the main CI and publish jobs). Vitest 5 needs + Node.js >= 22.12; that is dev-only and never reaches consumers. The CI `compat-node-floor` job + proves the consumer floor: on Node 22 it builds, `require`s the CJS entry and runs + `scripts/smoke-timeout.mjs`. + +### Toolchain pins + +- **`@types/node` is pinned to the floor** (`^22`), not the dev runtime, so the compiler rejects + APIs newer than Node 22. It is approximate (CI runs the latest 22.x and `@types/node` is + `^22.20`, so an API added after 22.0 is not caught). Bump its major only when the minimum + supported Node.js is intentionally raised, which is a breaking change (major release and + `engines` bump). Dependabot ignores its major updates for this reason. +- **TypeScript stays on 6.x.** As of 1.0.0, `typescript-eslint` declares its `typescript` peer as + `<6.1.0`, so TypeScript 7 breaks `pnpm run lint` (in CI and in the pre-commit hook). Move to 7.x + only once a `typescript-eslint` release accepts it. +- **`typedoc` also gates TypeScript:** its `typescript` peer lists explicit minors (up to 6.0.x on + 0.28.20), so any TypeScript bump, even to 6.1, needs a `typedoc` release that lists it, or + `pnpm run docs` (and CI) break. + +### API reference (TypeDoc) + +- Entry points are `src/index.ts` (`glassnode-api`) and `src/x402.ts` (`glassnode-api/x402`), + named by their `@module` comments; README.md is the landing page. Config in `typedoc.json`, + output in `api-docs/` (not committed, not published to npm). +- `typedoc-plugin-zod` expands `z.infer`/`z.input` aliases, so Zod-derived types such as + `MetricMetadata` and `GlassnodeConfig` render as objects with their field comments. +- Every warning is an error (`treatWarningsAsErrors`; `notExported`, `invalidLink`, `invalidPath` + and `rewrittenLink` on). `notDocumented` is off: it only reports nested members of Zod objects + and `z.enum` literals, which cannot carry comments. Every top-level export and class member is + documented; keep it so. +- `.github/workflows/docs.yml` deploys the reference to GitHub Pages + (https://glassnode.github.io/glassnode-api-ts-client/) on every push to `main`. It needs the repo + setting Settings → Pages → Source: **GitHub Actions**. + +### Build targets and packaging + +- **CJS:** `tsc` → `dist/` (the `require` entry). **ESM:** `tsc -p tsconfig.esm.json` → + `dist/esm/`, unbundled with `zod` external (the `import` entry), plus a generated + `dist/esm/package.json` (`{"type":"module"}`). `exports` has per-condition `types`, checked by + `publint` and `@arethetypeswrong/cli` in CI. The package itself is `"type": "commonjs"`. +- **Browser:** Rollup (`tsconfig.browser.json`) builds minified UMD + (`dist/glassnode-api.umd.min.js`, global `GlassnodeAPI`) and ESM + (`dist/glassnode-api.esm.min.js`) bundles from `src/index.ts`, with `zod` bundled in and + `src/x402.ts` left out. Source maps are `hidden` and not published. `module` points at the + unbundled `dist/esm/index.js`, not a bundle. `tsconfig.browser.json` uses + `moduleResolution: bundler` (not the deprecated `node`/`node10`); Rollup's node-resolve plugin + does the actual resolution. +- **No `browser` field.** `unpkg` and `jsdelivr` point at the UMD bundle so the bare CDN URL serves + it. There is deliberately no top-level `browser` field and no `browser` condition in `exports`: + the condition would send webpack 5, Vite and esbuild to the pre-minified bundle with `zod` + inlined instead of the tree-shakeable ESM build (the top-level field only affected + `exports`-unaware tools such as Browserify). Keep both out, even though `publint` suggests the + condition (#37). +- **`src/` imports end in `.js`** (`./foo.js` for `foo.ts`). `dist/esm/` is unbundled `tsc` + output, Node's ESM loader needs full extensions, and `tsc` never rewrites a `.js` specifier. + Extensionless specifiers only work behind a bundler or CommonJS. `.ts` specifiers with + `rewriteRelativeImportExtensions` were rejected: `tsc` leaves them as `.ts` in the emitted + `.d.ts`. Tools that run `src/` directly must map `.js` to `.ts`; ts-node needs + `experimentalResolver`, set in `examples/tsconfig.json`, and CI guards it by importing `../src` + through ts-node from `examples/`. +- **`GlassnodeConfigSchema` stays a plain `z.object`** (no `.transform()`), so `.shape`, `.pick`, + `.extend` and `.partial` keep working. Defaults that depend on other options (`maxRetries` is + `DEFAULT_MAX_RETRIES`, or `DEFAULT_X402_MAX_RETRIES` with `x402`) are applied in the constructor. +- TypeScript configs: `tsconfig.json` (CJS), `tsconfig.esm.json` (ESM), `tsconfig.browser.json` + (browser), `tsconfig.test.json` (tests and IDE), `tsconfig.examples.json` (type-checks + `examples/` against `src/` with the root dependencies), `examples/tsconfig.json` (ts-node, for + running the examples) and `typecheck/x402-node16/tsconfig.json` (a node16 consumer with + `skipLibCheck: false`). + +### CI and workflows + +- `ci.yml` runs on PRs into `main` and `release/**`, and `publish.yml` calls it as its `verify` job, + so a release runs the same checks. Its concurrency group cancels superseded runs only for PRs; a + run called by `publish.yml` is never cancelled. +- Its jobs are `test` (Node 24: the step-4 check list, `test:coverage` and the examples' ts-node + import guard) and `compat-node-floor` (Node 22). The rulesets require them by name, so renaming + a job means updating the rulesets; `compat-node-floor` is version-neutral so a floor bump only + changes its `node-version`. +- Every action is pinned by full commit SHA with a `# vX.Y.Z` comment, never by a movable tag. To + bump one, resolve the tag with `gh api repos///git/ref/tags/` (for an + annotated tag, dereference it with `gh api repos///git/tags/` to get the + commit), update SHA and comment together, and read the release notes for changed inputs or + defaults. +- Every workflow sets minimal `permissions:`: `contents: read` by default, and write access only on + the `publish` job, which needs it. + +### Publishing setup + +- `publish.yml` publishes with **npm Trusted Publishing (OIDC)** and provenance: there is no + `NPM_TOKEN` secret. The Trusted Publisher for `glassnode-api` on npmjs.com names repo + `glassnode/glassnode-api-ts-client`, workflow `publish.yml` and environment `npm`. +- The `npm` environment (Settings → Environments) has the maintainer as required reviewer and + allows deployments from `main` only. Only the `publish` job has write permissions: + `id-token: write` (OIDC) and `contents: write` (tag and Release, via `GITHUB_TOKEN`). +- The `release` job runs `scripts/release-state.sh`: only an E404 from `npm view` means "not + published"; any other failure fails the job and never publishes. `publish` re-checks npm before + publishing. An existing tag on the same commit or an existing Release is fine; a tag on another + commit is left alone with a warning. If the version is on npm from this very commit (npm records + its `gitHead`), a re-run only (re)creates the missing tag and Release. +- npm CLI >= 11.5.1 does the OIDC exchange, so the workflow installs the latest npm and runs + `npm publish`, not `pnpm publish` (which uses the setup-node placeholder token and gets a 404). + The setup-node `.npmrc` is overwritten first so npm authenticates via OIDC. +- The repo `.npmrc` sets `ignore-scripts=true`, so `npm publish` skips `prepublishOnly`: the + workflow runs `build` and `build:browser` itself. The `pnpm/action-setup` steps set + `npm_config_ignore_scripts: 'false'` because the standalone pnpm needs its own setup script. +- **Dist-tag guard** (`scripts/release-plan.mjs`, SemVer 2.0.0 precedence, no dependencies): + `npm publish` always gets an explicit `--tag`: `latest`, `next` for a prerelease, or + `backport-.` for a version below the current `latest`. Not `v.`: npm + rejects a tag that is a valid semver range. `release-state.sh` fails on any other tag. + `package.json` `publishConfig` deliberately sets **no** `tag`: `tag: "latest"` there would force + `latest` on a manual publish or an npm that doesn't let `--tag` override it. Keep it out. + ## License of contributions This project is licensed under the [Apache License 2.0](./LICENSE). Under its Section 5, any diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..953d755 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security policy + +## Contents + +- [Reporting a vulnerability](#reporting-a-vulnerability) +- [Supported versions](#supported-versions) + +## Reporting a vulnerability + +Don't open a public issue. Report it privately: + +- **GitHub:** the repository's **Security** tab → **Report a vulnerability** + ([private vulnerability reporting](https://github.com/glassnode/glassnode-api-ts-client/security/advisories/new)). +- **Email:** security@glassnode.com, if you can't use GitHub. + +Include the affected version, steps to reproduce and the impact. Glassnode has no bug bounty +program. + +## Supported versions + +Only the latest release receives security fixes.