Skip to content
Udibo
On this page
View Markdown

Contributing to @udibo/oauth2

Thank you for your interest in contributing! This document covers development setup, conventions, and the release process.

Code of Conduct

Please be respectful and inclusive in all interactions. We are committed to providing a welcoming and harassment-free experience for everyone. Be kind, constructive, and professional in your communications.

Security issues

Never open a public issue for a security vulnerability. Follow SECURITY.md instead.

Getting started

Prerequisites

  • Node.js 22.18 or later (.nvmrc pins 26). The scripts are TypeScript that Node runs directly, which needs 22.18+.

  • pnpm 12 (packageManager in package.json names the exact version; corepack enable installs it)

  • Git

  • A code editor with TypeScript and oxc support

Development setup

Shell
pnpm install                          # install dependencies
pnpm test                             # package and script tests (Vitest)
pnpm test src/utils/crypto.test.ts    # one file
pnpm test:coverage                    # with V8 coverage
pnpm exec playwright install chromium # once, for the browser test
pnpm test:browser                     # React form behavior in headless Chromium
pnpm check                            # lint, format check, types, and docs gates
pnpm fmt                              # format
pnpm build && pnpm smoke              # pack the package and prove it from a clean consumer
pnpm jsr:dry-run                      # check the JSR publish

Run all commands from the repository root. The example apps and the template are pnpm workspace members; run one with pnpm --filter <name> start or test after pnpm build (see each README for its name and port).

Repository structure

oauth2/
├── src/                  # Package source (what publishes to JSR)
│   ├── server/           # Authorization + resource servers, grants, signing keys
│   ├── client/           # Typed OAuth2 client
│   ├── identity/         # Own-auth flows (sign-in, reset, verification, lockout)
│   ├── adapters/hono/    # Hono adapters (servers, BFF, identity routes)
│   ├── cli/              # The `udibo-oauth2` command (bin): keygen, local identity provider
│   ├── react/            # React bindings for the BFF
│   ├── testing/          # Test helpers + contract test suites
│   ├── models/           # Shared protocol types
│   └── utils/            # Crypto, URL, PKCE helpers
├── examples/hono/        # Runnable example apps
├── templates/            # Auth-preconfigured starter (React Router)
├── docs/                 # Quickstart, guides, stability policy, known limitations, triage
├── scripts/              # Documentation gates, npm smoke test, release guards, PSL updater
├── PUBLISHING.md         # Maintainer runbook: release configuration and verification (not shipped)
├── package.json          # npm manifest; `exports` map to dist/
├── jsr.json              # JSR manifest; `exports` map to src/
├── CHANGELOG.md          # Generated by semantic-release
├── SECURITY.md           # Vulnerability disclosure process
└── .releaserc.json       # Release pipeline configuration

src/ is erasable TypeScript with explicit .ts import extensions: tsc (pnpm build) emits dist/ for npm, and the same files publish to JSR as written. Keep the two export maps (package.json and jsr.json) in step; pnpm smoke fails when an entrypoint is missing from the tarball or from scripts/npm-smoke/consumer.tsx.

Making changes

  1. Branch from main — short-lived feature branches, squash-merged.

  2. Write tests. Every behavioral change needs corresponding coverage; the security-sensitive paths (enumeration safety, timing equalization, throttle and lockout behavior) are guarded by tests and must stay that way.

  3. Keep repository links relative in documentation. pnpm doc:links checks that every local link and heading resolves. llms-full.txt resolves each page's links to the public repository before concatenation so they keep their original context; regenerate it with pnpm llms:generate after editing a guide, and list any new page in llms.txt.

  4. No new runtime dependencies without discussion. The package is deliberately close to zero-dependency; crypto uses Web Crypto only.

  5. Respect the design invariants below. They are the shape of the package, not preferences, and each one has been re-litigated at least once.

  6. Run the gate before pushing: pnpm check and pnpm test. pnpm check runs the type-aware lint, the format check, tsc, and the documentation gates: doc:lint requires JSDoc on every exported symbol and public member, doc:check type-checks the fenced snippets in the markdown and every JSDoc @example in the API reference (mark a fence ```ts ignore when a snippet is deliberately not compilable; the number of opt-outs is capped), doc:links checks local links, and llms:check checks the AI-ready index and corpus. Run pnpm build && pnpm smoke after changing an export map, a dependency, or anything that affects the packed files.

  7. Run pnpm test:browser after changing the React form components. It drives the drop-in forms in headless Chromium through Playwright for behavior jsdom cannot show, such as where focus goes while a submit is pending. Install the browser once with pnpm exec playwright install chromium. CI runs it as its own job.

Design invariants

The package sits deliberately below the turnkey-identity line: the application owns identity policy, and the library owns the protocol. Four invariants carry that, and a change that erodes one is a change to the positioning.

  • ClientInterface stays minimal — id, grants, redirectUris, the fields the framework actually reads. Application policy (firstParty, userId) does not belong in the library's client model; the owner user is resolved through clientService.getUser(client), so the service decides how. Because an application's own entity already has those three fields, it is returned as the client directly — no adapter type, no mapper. Trust decisions go in handleConsent, which is optional: with no handler configured the request is treated as consented, so a self-hosted server whose clients are all first-party is never forced to write one.

  • Errors are HttpError. OAuth2Error and friends are built with createHttpErrorClass from @udibo/http-error, so instanceof HttpError holds and each carries extensions.error — the OAuth2 code — plus a status. That is what lets a consumer render them as RFC 9457 problem details with the codes as extensions (errorFormat). Never re-flatten one to a generic HttpError(403, "…"): it discards the code and the WWW-Authenticate header. Let them flow through.

  • Rate limiting is a bring-your-own seam. IdentityService keeps the small RateLimiter contract (check / reset) and the package takes no dependency on any limiter library. The built-in fixed-window RateLimiter / MemoryRateLimitStore is the zero-dependency implementation and is feature frozen — no bursts, penalties, or distributed stores. Its store is required, so the caller chooses where counters live. Anything beyond fixed-window arrives through the seam, wired app-side or shown in a docs example.

  • Examples contain no test-only seams. They are reference implementations of production code, so they construct IntrospectionTokenReader, HonoAuthorizationServer and the rest as module-level constants — no factory functions, no injected fetch option, no dependency-injection parameter whose only justification is testability. A test that needs to fake a downstream call stubs the method that makes it (vi.spyOn(tokenReader, "getToken")); one that genuinely needs the wire uses a real listener on an ephemeral port. The framework's own tests already cover the wire format — an example test only proves the wiring.

Commit messages

Conventional Commits — they drive releases and the changelog:

  • feat: new feature (minor), fix:/perf: (patch)

  • A breaking change is marked with ! after the type or scope (feat!:, fix(testing)!:) or a BREAKING CHANGE: footer. Either cuts a minor while 0.x (see docs/stability.md for what qualifies pre-1.0) and lists the change under ⚠ BREAKING CHANGES in the release notes. The notes quote the subject unless a footer is present; PRs are squash-merged with the PR body as the commit body, so to put a migration note there, start a line of the PR body with BREAKING CHANGE: followed by the note. Write it as one paragraph in the body's ## Closes section, directly before the Closes #<n> lines, or last in the body when it closes nothing. The note runs until the first of those lines or the end of the message, so a note placed earlier carries every heading after it into the release notes.

  • chore(deps): / build(deps): dependency bumps (patch — the deps scope is required for a release to be cut)

  • Present tense, lowercase, subject under 72 characters.

Releases

Releases are automatic. On every push to main, the release job in .github/workflows/ci-cd.yml runs semantic-release, which analyzes the commits, determines the next version, generates the changelog, publishes to JSR and npm over GitHub OIDC (no stored registry token), and tags a GitHub release (.releaserc.json). The job runs only while the repository variable OAUTH2_RELEASE_ENABLED is true. Nobody hand-edits versions or CHANGELOG entries.

The release configuration, what a release does, and how to verify a published version are in PUBLISHING.md. The stability and breaking-change policy is in docs/stability.md; note that while the package is 0.x, .releaserc.json deliberately maps a breaking change to a minor.

Issue reports

See docs/triage.md for how issues are triaged, labeled, and prioritized.