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 (
.nvmrcpins 26). The scripts are TypeScript that Node runs directly, which needs 22.18+.pnpm 12 (
packageManagerinpackage.jsonnames the exact version;corepack enableinstalls it)A code editor with TypeScript and oxc support
Development setup
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 publishRun 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 configurationsrc/ 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
Branch from
main— short-lived feature branches, squash-merged.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.
Keep repository links relative in documentation.
pnpm doc:linkschecks that every local link and heading resolves.llms-full.txtresolves each page's links to the public repository before concatenation so they keep their original context; regenerate it withpnpm llms:generateafter editing a guide, and list any new page inllms.txt.No new runtime dependencies without discussion. The package is deliberately close to zero-dependency; crypto uses Web Crypto only.
Respect the design invariants below. They are the shape of the package, not preferences, and each one has been re-litigated at least once.
Run the gate before pushing:
pnpm checkandpnpm test.pnpm checkruns the type-aware lint, the format check,tsc, and the documentation gates:doc:lintrequires JSDoc on every exported symbol and public member,doc:checktype-checks the fenced snippets in the markdown and every JSDoc@examplein the API reference (mark a fence```ts ignorewhen a snippet is deliberately not compilable; the number of opt-outs is capped),doc:linkschecks local links, andllms:checkchecks the AI-ready index and corpus. Runpnpm build && pnpm smokeafter changing an export map, a dependency, or anything that affects the packed files.Run
pnpm test:browserafter 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 withpnpm 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.
ClientInterfacestays 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 throughclientService.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 inhandleConsent, 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.OAuth2Errorand friends are built withcreateHttpErrorClassfrom@udibo/http-error, soinstanceof HttpErrorholds and each carriesextensions.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 genericHttpError(403, "…"): it discards the code and theWWW-Authenticateheader. Let them flow through.Rate limiting is a bring-your-own seam.
IdentityServicekeeps the smallRateLimitercontract (check/reset) and the package takes no dependency on any limiter library. The built-in fixed-windowRateLimiter/MemoryRateLimitStoreis the zero-dependency implementation and is feature frozen — no bursts, penalties, or distributed stores. Itsstoreis 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,HonoAuthorizationServerand the rest as module-level constants — no factory functions, no injectedfetchoption, 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 aBREAKING 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 withBREAKING CHANGE:followed by the note. Write it as one paragraph in the body's## Closessection, directly before theCloses #<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 — thedepsscope 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.

