Guidance for any AI coding agent — Claude Code, Cursor, Aider, GitHub Copilot Chat,
Windsurf, Zed AI — working in this repository. Written to be tool-agnostic; specific
per-tool notes live in each tool's own configuration (.claude/, .cursorrules, etc).
@exortek/auth — a framework-agnostic, server-only authentication toolkit for
Node.js 22+ positioned as a modern successor to Passport.js. Yarn-workspaces
monorepo of 20 packages under the @exortek/* scope, each independently
versioned via Changesets and published to npm. Individual packages are also
consumable standalone; a planned umbrella @exortek/auth package will
re-export everything.
For which packages are currently shipped, check README.md (the source of
truth for shipping status). For the full designed surface, check
ARCHITECTURE.md.
/
├── packages/<name>/ # every workspace package — see per-package layout below
├── web/ # Nextra docs site (auth.memet.dev)
├── docs/ # long-form Markdown (compliance mapping, etc.)
├── .changeset/ # pending version bumps
├── scripts/ # repo tooling (some entries gitignored)
├── ARCHITECTURE.md # design doc — source of truth for planned APIs
├── AGENTS.md # this file — agent-agnostic workflow guide
├── CONTRIBUTING.md # human contributor guide
├── SECURITY.md # vulnerability reporting + supported versions
└── README.md # public entrypoint — the ground truth for shipping status
Root:
yarn install
yarn build # rollup, topological across workspaces
yarn build:watch # parallel watch mode
yarn test # node --test in each workspace
yarn test:coverage # node --test --experimental-test-coverage
yarn typecheck # tsc --noEmit per workspace
yarn lint # eslint packages/*/src/**/*.js
yarn lint:fix
yarn format # prettier --write .
yarn format:check # CI uses this
yarn clean # rm -rf dist tsconfig.tsbuildinfo per workspace
yarn changeset # author a changeset (interactive)
yarn changeset:ver # apply pending changesets — bumps versions + CHANGELOGs
yarn release # LOCAL fallback publish only (login → version → build → publish → tag);
# the normal path is the release.yml Actions workflow (see "Git workflow")
yarn verify # typecheck + lint + format:check + test — the CI mirror
Per-package (from inside packages/<name>/):
yarn build # rollup -c + tsc emit
node --test 'tests/**/*.test.js' # run all tests
node --test tests/path/to/file.test.js # run one test file
CI runs yarn verify (typecheck → lint → format:check → test) on every
push and PR via .github/workflows/ci.yml; it builds all packages first so the
cross-package dist imports resolve. master is branch-protected — work
lands through PRs and the CI check must pass. Run yarn verify locally before
pushing to catch failures early. .github/workflows/codeql.yml adds security
scanning; release.yml is the manual publish job. See
"## Git workflow" below for the full branch / PR / release flow.
@exortek/crypto is the root of the tree; everything asymmetric can build up
from it. Key edges (from ARCHITECTURE.md):
crypto → jwk → jws → jwt → jwe → jwks
crypto → opaque, paseto, password, otp, magic-link, passkey,
session, csrf, rate-limit, device, oauth2 → oidc
otp → challenge
apikey → rate-limit
auth (umbrella) → re-exports everything above
Current policy: new packages are fully standalone. Cross-@exortek/*
runtime dependencies are not taken for new work — utility duplication
(e.g. base64url in both jwk and jws) is accepted deliberately so a
user who installs a single package pulls no transitive workspace deps.
The layering above tells you the semantic order, not the runtime import
graph.
Sanctioned cross-package runtime edges. A handful of packages do declare a
cross-@exortek/* runtime dependency where inlining the primitive would be worse
than the transitive dep. Each is declared in package.json dependencies:
@exortek/session→@exortek/crypto— sealed-cookie primitive (seal/unseal/CryptoError).@exortek/opaque→@exortek/crypto— hashing + token randomness.@exortek/passkey→@exortek/crypto,@exortek/challenge— COSE/encode helpers + challenge store.@exortek/jwks→@exortek/jwk— key import/validate.@exortek/oauth2→@exortek/jwt,@exortek/jws,@exortek/jwe,@exortek/jwk,@exortek/jwks,@exortek/paseto— ID token and client assertion verification, DPoP proofs, JAR/JARM, sealed RP state, client and provider key sets, and the JWT / PASETO access-token issuers.@exortek/oidc→@exortek/oauth2,@exortek/jwt,@exortek/jwks— OIDC is a layer over the oauth2 client and server: it reuses the RP flow and the authorization server, and verifies ID tokens and logout hints.@exortek/auth→ every published package — the umbrella is a pure re-export.
These are the only sanctioned edges. Do not add new ones for new work —
prefer a plain-object boundary or accept the deliberate utility duplication. Use
workspace:^ for a sanctioned edge (it publishes as a caret range that dedupes;
workspace:* pins an exact version and can force a duplicate copy in the
consumer's tree). If session ever gets a major refactor, inline the seal
helpers to drop its edge.
@exortek/crypto may only depend on node:crypto, with no external
exceptions — it is the dependency-light root of the tree.
@exortek/shared is not a cross-package dep — it is inlined at build. It is
private, ships no dist, and its exports point straight at ./src/*.js.
Every other package imports it (e.g. @exortek/shared/asserts,
@exortek/shared/predicates) but declares it nowhere; rollup bundles its
source into each package's dist at build time, so a standalone install pulls no
shared dependency. Consequences to respect: never "fix" the missing
dependencies entry (it would break the standalone guarantee), never publish
shared, and note it emits no .d.ts of its own — consumer types are inferred
through re-export at each package's tsc emit.
Every package in this monorepo is server-only. They rely on node:crypto.
Do not introduce browser code, crypto.subtle polyfills, window / document
references, or /client subpaths — even for protocols with an inherent browser
side (WebAuthn, OAuth2 SPA/PKCE, OPAQUE).
We verify server-side and point users at a maintained client companion:
- Passkey / WebAuthn →
@simplewebauthn/browser - OAuth2 SPA / PKCE (browser redirect) →
oauth4webapi - OPAQUE →
@cloudflare/opaque-ts
@exortek/oauth2/client (planned) is the OAuth term for the application
accessing the authorisation server — it is server-side Node.js code, not
browser code.
Every package follows the same shape:
packages/<name>/
├── src/
│ ├── index.js # public entrypoint; re-exports named surface + namespace
│ ├── <feature>/*.js # implementation
│ └── internal/*.js # helpers not part of the public API
├── tests/
│ └── *.test.js # tests live here, NOT colocated in src/
├── rollup.config.js # one-liner: re-exports createConfig(pkg)
├── tsconfig.json # extends ../../tsconfig.base.json
├── package.json # per-subpath exports, files, scripts
├── LICENSE # MIT — copy from a sibling package
├── README.md # public docs (badges, why, quick start, error catalogue)
└── CHANGELOG.md # generated by Changesets on release
"type": "module"everywhere. Dual output:dist/<name>.mjs(ESM) +dist/<name>.cjs(CJS)..d.tsemitted from JSDoc bytsc --emitDeclarationOnly.- Tests live in
tests/— thetestscript globstests/**/*.test.js. Runs via Node's native test runner. No Jest / Mocha / Vitest. - Packages that ship subpath exports use one rollup input/output pair per subpath.
Current examples:
@exortek/jwk—./generate,./import,./export,./thumbprint,./validate@exortek/jws—./sign,./verify,./decode,./json@exortek/jwt—./token-pair,./stores@exortek/oauth2—./providers/<name>(planned)@exortek/passkey—./server(planned; no/client)
- All builds externalise
node:*plus declared deps / peerDeps viarollup.config.base.js#createConfig.
Every package binds the @exortek/shared/asserts factories it needs to its own
typed error class exactly once, in src/internal/guards.js, and every call site
imports the bound helpers from that local file — never from
@exortek/shared/asserts directly. This keeps err instanceof <Package>Error
working for every argument-shape failure, without every call site paying wrap
boilerplate.
// packages/<pkg>/src/internal/guards.js
import { makeAssertString, makeAssertPositiveInt, makeInvalidArgument } from '@exortek/shared/asserts';
import { CryptoError, ErrorCode } from '../errors.js';
const wrap = (message, extra) => new CryptoError(ErrorCode.INVALID_ARGUMENT, message, extra);
export const assertString = makeAssertString(wrap);
export const assertPositiveInt = makeAssertPositiveInt(wrap);
export const invalidArgument = makeInvalidArgument(wrap);Rules:
- Import only the factories the package's own call sites use. This is not
style — each
make…import is what a bundler keys on, so an unused import ships dead code into every subpath bundle. The predecessor of this API handed back one object holding every guard, andassertBooleanreached fifteen published packages without a single call site. invalidArgument(msg, { cause })for thethrow invalidArgument('…')sites that don't fit theX must be Yshape (canonicalisation errors, cross-field constraint failures).parse(schema, input, path)is the bridge from@exortek/shared/validateschemas — same bound error, one call.- Path-naming convention for the assert
nameargument:<publicFn>[.options|.config][.<field>]— e.g.'createUser.name','scrypt.options.r','pepper.wrap.password'. Keep it short; the stack trace already says which function fired.
Pure JavaScript with JSDoc types, not .ts. tsc --emitDeclarationOnly
generates .d.ts at build time. Maintain JSDoc on every public API. For the
expected style, look at a shipping package — packages/jwk or
packages/session are complete references. strict, noImplicitAny, and
isolatedModules are all on.
ESLint (flat config) enforces: prefer-const, no-var, eqeqeq: always,
curly: all, no-unused-vars (allow _-prefixed), no-console: warn.
Prettier: no semicolons off — semicolons on, single quotes, ES5 trailing
commas, 100-char width, 2-space indent, always-parens arrows.
yarn verify runs all of these together — always let it pass before committing.
The JOSE stack in this repo makes some deliberate departures from jose (the
otherwise-excellent reference library). Preserve them when extending or
implementing new pieces:
- Algorithm allowlist is mandatory on verify. Omitting
options.algraisesMISSING_ALG_ALLOWLIST— no default, no fallback. alg: 'none'is refused everywhere. No flag, no env var, no config. It raisesALGORITHM_NONE_FORBIDDEN. This is defence in depth: the algorithm table has nononeentry and the sign / verify surfaces short-circuit before any lookup.critis strict by default. Unknown critical headers raiseCRIT_UNSUPPORTED. Callers can opt in named extensions viaknownCriticalHeaders.- Key input is polymorphic — JWK object,
KeyObject,Buffer(HMAC only), JWK array (kid dispatch), andasync (header) => keyresolver functions are all first-class. - Granular
ErrorCodeenum per package. Branch onerr.code, never onerr.message. - RFC test vectors are pinned in tests. RFC 7638 §3.1 for JWK, RFC 7515 Appendix A for JWS — spec-compliance canaries. When a curve or algorithm is added, add its published test vector.
master is branch-protected: a PR is required and the CI check
(typecheck · lint · format · test) must pass to merge. Do not commit
straight to master.
- Branch off an updated
master, named<type>/<scope>-<slug>—typeis the conventional-commit kind (feat|fix|chore|docs|refactor|test|perf),scopea package short name (passkey,session, …) orrepo/ci,sluga short kebab description. e.g.feat/passkey-conditional-ui,fix/session-rotation-race,docs/agents-git-workflow. - Commit in the style below;
yarn formatbefore committing; English only; no AI-attribution trailers. - Changeset whenever a published package changed —
yarn changeset(a new package starts at0.0.0somajor→1.0.0). - PR —
gh pr create, let CI go green, merge (squash); the branch auto-deletes.
Releasing is manual, and versioning is separate from publishing:
yarn changeset:ver→ commitchore: version packages→ PR → merge (bumps versions + writes CHANGELOGs).- Trigger
.github/workflows/release.yml(workflow_dispatch): Actions → Release → Run (thedry_runinput builds without publishing). It builds, publishes to npm, pushes tags, and cuts GitHub Releases. - Auth is npm Trusted Publishing over GitHub OIDC — no stored token. Each
@exortek/*package needs a trusted publisher on npmjs.com (repoExorTek/auth, workflowrelease.yml, actionnpm publish) plus the "require 2FA and disallow tokens" publishing-access option. A brand-new package's first publish can't use OIDC — the package must exist before you can configure its trusted publisher — so publish it once locally withyarn npm publish(interactive 2FA), then wire the trusted publisher for every release after.
Split the work into small, self-contained commits — each one should leave the
repository in a green state (yarn verify passes). A typical @exortek/<name>
build-out goes:
- Plan first. Read the target section in
ARCHITECTURE.md, weigh the differentiators vs.jose/jsonwebtoken/helmet/ whichever incumbent the package is displacing, sketch the public surface, agree it with a maintainer before writing code. Save the plan to.claude/plans/(or the equivalent for other agents) so the shape survives context loss. - Scaffold — one commit.
package.json,rollup.config.js,tsconfig.json,LICENSE,README.mdstub, emptysrc/**/*.jsstubs that throwNOT_IMPLEMENTED, one dummytests/smoke.test.jsif useful.yarn install && yarn buildfrom the package directory must succeed — rollup should emit every subpath bundle even with stub implementations. - Internal utility layer — one commit per concern (or one topical commit
if concerns are tightly coupled).
internal/errors.js(theErrorCodeenum is a contract — freeze it early),internal/base64url.jsif the package handles JOSE, algorithm registries, key normalisers, etc. - Public surface — one commit per module.
sign.js,verify.js,decode.js, etc. — each with its own real implementation replacing the stub. Aim to keep each commit self-verifying: the tests you add here should exercise the code you just wrote. - Tests — one commit per test file (or per test theme). RFC test vectors first, then the algorithm / feature matrix, then the CVE / security surface, then edge cases. Every test file should be independently runnable.
- Docs pass 1 — one commit.
packages/<name>/README.md(badges, "why", quick start, error catalogue),AGENTS.mdif the package changes a convention,web/content/_meta.js,web/content/<name>/_meta.js,web/content/<name>/index.mdx. - Docs pass 2 — one commit.
web/content/<name>/*.mdxper-module deep dives. - Changeset — one commit.
.changeset/<name>-initial-release.mdwith amajorbump (workspace-local0.0.0→ npm1.0.0). - Verify.
yarn verifyfrom the repo root.cd packages/<name> && yarn npm publish --dry-runto inspect the tarball. Optional: cross-check against the reference library (e.g. canjoseverify a token we produce?). - Release. Merge the build-out PR once CI is green, then follow the
release flow in "## Git workflow" — a
chore: version packagesPR, then triggerrelease.yml. The initial1.0.0publish is done locally (yarn npm publish, interactive 2FA) because a brand-new package has no trusted publisher yet; wire OIDC trusted publishing straight after so every later release runs from CI. - Post-release documentation sweep — one commit per file group. Root
README.mdShipping table + "N published" counter + install snippet,web/content/index.mdxShipping table + stack row status,SECURITY.mdsupported versions row,.github/ISSUE_TEMPLATE/*.ymldropdown entry,.github/pull_request_template.mdcheckbox,scripts/setup-labels.shlabel entry,docs/compliance.mdif the package unlocks a NIST / ASVS / PCI row.
Failure to walk step 11 is the most common release-time miss — the package hits npm but the repo still advertises "5 published" and issue templates still miss it in their dropdown. Treat step 11 as part of the release, not a follow-up.
- Reproduce with a failing test first. Bug reports without a failing test go back to the reporter — a fix without a regression test invites the same bug back.
- Root-cause it. Do not paper over a symptom (e.g. widen a type,
swallow an error) — trace to the actual defect.
[[design_philosophy]]applies: every primitive ships a high-level helper; if a bug reveals a missing helper, the fix may be a new helper rather than a patched internal. - Fix in the smallest possible diff. One concern per commit. Don't
refactor surrounding code in the fix commit — do it as a separate
refactor(<pkg>):commit before or after. - Add the regression test in the same commit as the fix — the "why" line of the commit message names the CVE class, incident, or reporter where relevant.
- Changeset. Patch-level bump unless the fix requires a behaviour change (fail-open → fail-closed counts as breaking for consumers who relied on the fail-open, so raise a minor or major bump and note it loudly in the changeset body).
- Release the fix. Follow the release flow in "## Git workflow" (a
chore: version packagesPR, then triggerrelease.yml). For high-severity fixes, deprecate previously-shipping vulnerable versions withnpm deprecatepointing at the fixed release.
- Confirm scope with a maintainer. Additions to a published package's public surface are minor bumps at least; new subpaths, new algorithms, or new error codes touch the API contract.
- Add the ErrorCode first if the feature introduces failure modes. The enum is a contract — extending it is trivial, but branching consumer code discovers the new code the moment it exists.
- Implementation → tests → docs → changeset. Same rhythm as new packages, minus the scaffold.
- Cross-package effects. If the feature would tempt you to import
another
@exortek/*package, first check whether it can be solved by accepting a plain object at the API boundary instead — the standalone policy is enforced.
- Prove there is no behaviour change — the existing test suite must pass unchanged. If a test needs to change, it's not a pure refactor and should be split into a preceding behaviour commit.
- Refactor commits do not need a changeset unless they touch a published file surface. Internal reshuffling is invisible to consumers.
- Batch related mechanical edits (e.g. renaming a private helper across every file that imports it) into one commit — many small near-identical commits harm reviewability.
- Small topical commits, split by concern. A "scaffold" commit is separate from the "internal utility layer" commit is separate from the "tests" commit.
- Prefix with the conventional-commits type:
feat(<pkg>):,fix(<pkg>):,docs(<pkg>):,test(<pkg>):,chore(<pkg>):,refactor(<pkg>):. - Multi-line commit messages describe the why and note user-facing behaviour changes, especially around security-sensitive defaults.
- No AI attribution. Do not add "Co-Authored-By: " trailers or similar.
Deliberate constraints — reject PRs that violate them:
- No browser code. Server-only,
node:cryptoonly. - No
alg: 'none'anywhere, under any flag or environment variable. - No sync APIs on primitives that Node 22+ ships async — everything is Promise-based.
- No cross-
@exortek/*runtime dependencies (see standalone policy). - No
SHA-1-based JOSE algorithms. - No unpinned
x5ufetches (SSRF surface). - No hand-rolled cryptographic primitives — everything delegates to
node:crypto(which delegates to OpenSSL). The one long-term exception will be post-quantum whennode:cryptoexposes ML-DSA / ML-KEM natively (Node 25/26 timeframe).
ARCHITECTURE.mddescribes the design principles, the 20-package stack, dependency layering, and RFC references. Read it before writing code for a new package.- Anthropic Claude Code users:
.claude/holds Claude-specific configuration and plan files (gitignored). Other agents pick up their configuration from their own conventions.