Skip to content

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

  • Deno v2.x or later

  • Git

  • A code editor (VS Code with the Deno extension is recommended)

Development setup

Shell
deno install          # install dependencies
deno task test        # run the package tests
deno task test:all    # package + scripts + examples + templates
deno task check       # type check, lint, and format check

Run all commands from the package root (packages/oauth2 inside the monorepo). The example apps have their own tasks — see deno.json for the serve:* / dev:* / test:* variants.

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)
│   ├── react/            # React bindings for the BFF
│   ├── testing/          # Test helpers + contract test suites
│   ├── models/           # Shared protocol types
│   └── utils/            # Crypto, URL, PKCE helpers
├── examples/             # Runnable example apps (Hono + Juniper)
├── templates/            # Auth-preconfigured starters (Juniper, React Router)
├── docs/                 # Quickstart, guides, stability policy, known limitations, triage
├── PUBLISHING.md         # Founder runbook: extract to a standalone repo and publish (not shipped)
├── smoke-consumer/       # Out-of-workspace canary: resolves the published export map under a foreign config
├── CHANGELOG.md          # Generated by semantic-release once publishing starts
├── SECURITY.md           # Vulnerability disclosure process
└── .releaserc.json       # Release pipeline configuration

src/ intentionally contains no README/LICENSE copies — the release pipeline copies them in at publish time.

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 source documentation. deno task publish:prepare stages the consumer docs and rewrites those links to the public repository, including references to examples and templates that are not part of the JSR payload. llms-full.txt resolves each page's links before concatenation so its links retain their original context.

  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: deno task check and deno task test:all. deno task test covers the package suite only; test:all fans out to the script, example, and template suites CI also runs. deno task doc-check (part of 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.

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 minimalid, 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 default and is feature frozen — no bursts, penalties, or distributed stores. 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 (stub(tokenReader, "getToken", …)); one that genuinely needs the wire uses a real Deno.serve listener. 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)

  • feat!: or a BREAKING CHANGE: footer (minor while 0.x; see docs/stability.md for what qualifies pre-1.0)

  • 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

Nothing has been published yet. The release pipeline is written and wired, but it is switched off: the release-oauth2-package job in this package's .github/workflows/ci-cd.yml only runs when the repository variable OAUTH2_RELEASE_ENABLED is true. Publishing is opt-in. The package also still lives inside the monorepo; the pipeline is shaped for the standalone github.com/udibo/oauth2 repository it will be extracted to.

Once the package is extracted and the gate is flipped, releases are automatic: semantic-release analyzes commits on main, determines the next version, generates the changelog, publishes to JSR over GitHub OIDC (no stored token), and tags a GitHub release (.releaserc.json). Nobody hand-edits versions or CHANGELOG entries.

The ordered steps to get there — extraction, the 0.0.0 baseline tag that makes the first release 0.1.0 rather than 1.0.0, the JSR scope and repository link, flipping the gate, and what to watch on the first run — 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.