AI agent instructions for the ePDS repository.
Pnpm monorepo with three packages:
| Package | Path | Description |
|---|---|---|
@certified-app/shared |
packages/shared/ |
SQLite DB, crypto utils, logger, types |
@certified-app/auth-service |
packages/auth-service/ |
Login UI, OTP, social login, account settings |
@certified-app/pds-core |
packages/pds-core/ |
Wraps @atproto/pds with epds-callback endpoint |
pnpm install # install all dependencies
pnpm build # build all packages (tsc --build)
pnpm typecheck # type-check without emitting
pnpm dev # run all packages in dev/watch mode
pnpm dev:auth # auth-service only (tsx watch)
pnpm dev:pds # pds-core only (tsx watch)
pnpm dev:demo # demo frontend only (Next.js, port 3002)
pnpm format # format all files with Prettier
pnpm format:check # check formatting (CI)
pnpm lint # lint all files with ESLint
pnpm lint:fix # lint and auto-fix where possibleRun every check CI runs — locally — before every push. CI failures that prettier / eslint / tsc would have caught locally waste review cycles. The full set CI runs is:
pnpm format:check # prettier — CI fails if any file is unformatted
pnpm lint # eslint
pnpm typecheck # tsc --noEmit
pnpm test # vitest run
pnpm test:coverage # v8 thresholds in vitest.config.tsThese are cheap. Run them all before git push — not just the ones you
think are relevant to the change. In particular, pnpm format:check
catches prettier drift that pnpm format fixes in-place.
Always update documentation when your changes would render existing docs inaccurate or incomplete. This includes but is not limited to:
- Adding, removing, or renaming environment variables → update
docs/configuration.md, relevant.env.examplefiles, andscripts/setup.sh(if the variable needs prompting or injection). - Changing build steps, Docker workflows, or CLI commands → update
docs/deployment.md, the Docker section below, andscripts/setup.shnext-steps output if it references the changed commands. - Adding new scripts or changing existing ones → update the Build / Dev Commands section above.
- Changing API endpoints, health responses, or OAuth flows → update
docs/tutorial.mdor the relevant design doc. - Changing agent-facing workflows → update this file (
AGENTS.md).
Do not treat docs as a separate follow-up task. Update them in the same commit or PR as the code change.
pnpm test # run all tests (vitest run)
pnpm test:watch # vitest in watch mode
# Run a single test file
pnpm vitest run packages/auth-service/src/__tests__/login-page.test.ts
# Run tests matching a pattern
pnpm vitest run --reporter=verbose -t "creates an auth_flow"
# Run tests for one package
pnpm vitest run packages/sharedTests live in packages/<name>/src/__tests__/. There is no per-package test
script — all tests are run from the root via vitest.
SonarCloud runs on every PR. Check results and fix issues before
merging. See .agents/reference/SonarCloud.md
for API commands and quality gate thresholds.
Check for and address unresolved review comments after every push.
See .agents/reference/github-pr-comments.md
for API commands.
The e2e suite lives in e2e/ and its feature files in features/. Normally
the E2E tests workflow (.github/workflows/e2e-tests.yml) runs itself off
Railway's deployment_status webhook — no action needed on an ordinary PR.
To manually trigger it against a Railway environment (for e2e-only changes
that don't cause a rebuild, or to re-run without a new commit), always
pass both --ref and -f env_name:
# Against a PR environment:
gh workflow run e2e-tests.yml \
--ref <your-branch> \
-f env_name="ePDS / ePDS-pr-<N>"
# Against the persistent pr-base environment (post-merge backstop):
gh workflow run e2e-tests.yml \
--ref main \
-f env_name="ePDS / pr-base"--ref controls which version of the feature files, step definitions, and
workflow YAML get checked out. Without it, gh workflow run defaults to
main and you'll silently test old code against the right environment.
See e2e/README.md
for details (env-name formats, URL derivation, how to handle missing Railway
domains).
The e2e suite uses two demo OAuth clients (trusted and untrusted) for
trust-gated scenarios. See e2e/README.md
for the full setup, tagging conventions, and step-definition patterns.
Before designing or writing new tests, read
docs/design/testing-gaps.md. It documents:
- Current coverage per package/area and known hard-to-test zones.
- Which areas are unit-testable vs. require integration/e2e tests.
- Recommended strategies for covering route handlers, pds-core, and better-auth wiring.
- Remaining low-hanging fruit for coverage improvement.
Follow these guidelines when adding tests:
- Prefer unit tests for pure logic — crypto, validation, DB operations,
middleware. Use mock req/res objects (see
csrf.test.ts,rate-limit.test.ts). - Use
globalThis.fetchmocking for code that calls external services (seeclient-metadata.test.ts,email-template.test.ts). ReturnPromise.resolve(...)instead ofasync () => ...to avoid@typescript-eslint/require-awaitlint errors. - Use in-memory SQLite for DB tests — create a temp file in
os.tmpdir(), clean up inafterEach(seedb.test.ts). - Do not test route handlers with unit tests — they are integration-level
glue. Cover them via
supertestintegration tests or e2e tests instead. - Keep
testing-gaps.mdup to date — when you add tests that close a documented gap, or discover new gaps, update the coverage summary table and relevant sections. - Ratchet thresholds — after improving coverage, bump the thresholds in
vitest.config.tsso coverage cannot regress.
Coverage thresholds in vitest.config.ts must only ever increase.
When a PR increases coverage above the current thresholds, the thresholds
must be ratcheted up to the new floor (rounded down to the nearest integer)
as part of the same PR. This ensures coverage can never regress.
pnpm test:coverage # check current coverage vs thresholdsAfter confirming coverage exceeds thresholds, update vitest.config.ts:
thresholds: {
statements: <new floor>,
branches: <new floor>,
functions: <new floor>,
lines: <new floor>,
},Never lower thresholds. If a change removes tested code (e.g. deleting a feature), add tests for other code to compensate.
# Build images — use pnpm docker:build to auto-stamp the version.
# IMPORTANT: Only rebuild the services that changed. Check the diff to
# determine which packages are affected, then pass service names:
sudo -g docker bash -c "cd /data/projects/ePDS && pnpm docker:build auth"
sudo -g docker bash -c "cd /data/projects/ePDS && pnpm docker:build core"
sudo -g docker bash -c "cd /data/projects/ePDS && pnpm docker:build demo"
# Only use bare 'pnpm docker:build' (all services) when shared/ changed
# or you genuinely need to rebuild everything.
# Run the full stack
sudo -g docker bash -c "cd /data/projects/ePDS && docker compose up -d"
sudo -g docker bash -c "cd /data/projects/ePDS && docker compose logs -f"
# Always use 'up -d' (not 'restart') to pick up .env changes
sudo -g docker bash -c "cd /data/projects/ePDS && docker compose up -d"Service-to-image mapping (use this to decide what to rebuild):
| Docker service | Image | Rebuilds when these packages change |
|---|---|---|
core |
epds-core |
shared/, pds-core/ |
auth |
epds-auth |
shared/, auth-service/ |
demo |
epds-demo |
shared/, demo/ |
caddy |
(upstream) | Only Caddyfile — no build needed |
Container names: epds-core (PDS, port 3000), epds-auth (auth service, port 3001),
and epds-demo (demo frontend, port 3002).
ePDS deploys to Railway. When checking the live config, follow these rules
to avoid wasting time on --help / wrong commands and to avoid leaking
secrets into your context.
railway status --json # current project, service IDs, linked env
railway environment list # all environments in the projectFor any command that targets a specific service or environment, pass
-s <SERVICE> and -e <ENVIRONMENT> directly. Do not run
railway environment link … or railway service link … to "switch
context" first — those mutate local CLI state and are only needed when
-s / -e genuinely don't work for a given subcommand.
railway variable list -s demo-untrusted -e pr-base --kv | cut -d= -f1
railway logs -s pds-core -e pr-base --deploymentThis is non-negotiable. Variable values on Railway include API keys, JWT secrets, database passwords, signing keys, and similar credentials. Anything pulled into your context is effectively logged.
- Allowed: listing variable names to check for presence /
absence (e.g. "is
EPDS_CLIENT_PRIVATE_JWKset on this service?"). - Disallowed without explicit user permission: the actual value of any variable that is not obviously non-sensitive (a hostname, a public URL, a log level, a feature flag boolean). When in doubt, treat it as sensitive.
- This applies to every command that can return values, including
railway variable list(without filtering),railway environment config --json(dumps all variables across all services with values), andrailway run …(injects them into a subprocess).
To check presence without seeing values, list names only:
# Names only — pipe through cut to drop values
railway variable list -s demo-untrusted -e pr-base --kv | cut -d= -f1 | sort
# Or via the JSON API, extracting keys only
railway variable list -s demo-untrusted -e pr-base --json \
| python3 -c 'import json,sys; print("\n".join(sorted(json.load(sys.stdin))))'If a value genuinely needs inspection (e.g. debugging a misconfigured URL), ask the user first.
- Strict mode —
strict: truein all tsconfigs. No implicitany. - Target — ES2022, module Node16, moduleResolution Node16.
- ESM — all packages use
"type": "module". Use.jsextensions in imports even for.tssource files (Node16 resolution requires this). - Type imports — use
import typefor type-only imports:import type { Request, Response } from 'express' import { Router } from 'express'
as anycasts — permitted only when working with untyped internals of@atproto/oauth-providerbranded types. Comment why.
Order (no enforced linter, but follow this convention):
- Node built-ins (
node:crypto,node:path, etc.) — always use thenode:prefix - External packages
- Internal workspace packages (
@certified-app/shared) - Local relative imports (with
.jsextension)
import * as crypto from 'node:crypto'
import express from 'express'
import { createLogger } from '@certified-app/shared'
import { AuthServiceContext } from './context.js'- Files —
kebab-case.ts - Classes —
PascalCase - Interfaces / types —
PascalCase, prefix withIonly if needed to avoid collision with a class of the same name - Functions / variables —
camelCase - Constants —
SCREAMING_SNAKE_CASEfor module-level magic values,camelCasefor const variables - DB column names —
camelCasein TypeScript interfaces,snake_casein raw SQL - Route factories — named
create<Name>Router(ctx)returningRouter
- Prefer small, focused functions. Route handlers delegate to helpers.
- Route files export a single
create*Router(ctx)factory function. - No default exports — use named exports throughout.
- Async
main()pattern for service entry points.
- Use
logger.error({ err }, 'description')(pino structured logging). - Return HTTP error responses with
res.status(N).json({ error: '...' })orres.status(N).type('html').send(renderError(...))for HTML pages. - Never swallow errors silently — log at minimum
debuglevel. - Debug/trace logging at
logger.debug(...), notlogger.info(...).
- File-level JSDoc block at the top of route files explaining the route's purpose, flow steps, and any non-obvious behaviour.
- Inline comments explain why, not what.
- Do not add comments that merely restate what the code does.
- Server-rendered HTML uses template literal functions (e.g.
renderLoginPage()). - Always escape user input with
escapeHtml()from@certified-app/shared. - CSS classes control visibility (
hidden,active) — avoid inlinedisplaystyle except for dynamic values set at render time.
- SQLite via
better-sqlite3. All DB access goes throughEpdsDb(packages/shared/src/db.ts). - Schema changes use versioned migrations in
runMigrations(). - Never drop tables or columns in migrations. Destructive schema changes break emergency rollbacks — rolled-back code still references the dropped schema and crashes. Leave unused tables/columns in place; they're harmless.
- Do not directly read or modify
@atproto/pdsdatabase tables — usepds.ctx.accountManager.*methods.
- Never hand-roll security code. Use upstream or established libraries for SSRF protection, crypto, auth, input sanitization, etc. If a library integration has issues (e.g. test incompatibility), fix the integration — do not reimplement the security logic. If the integration truly can't work, stop and ask before proceeding with any alternative.
- All epds-callback redirects must be HMAC-SHA256 signed using
signCallback()/verifyCallback()from@certified-app/shared. - Use
timingSafeEqual()for all secret/token comparisons. - OTP codes: configurable length (4–12, default 8, via
OTP_LENGTH) and charset (numericoralphanumeric, defaultnumeric, viaOTP_CHARSET), single-use, managed by better-auth. Expiry is hardcoded at 600 s with 5 allowed attempts. - Internal service-to-service calls use
x-internal-secretheader.
- Use
bd(beads) for all task tracking — not TodoWrite or markdown files. bd ready— show available work;bd create— new issue;bd close— done.bd export -o .beads/issues.jsonlto export issues (commit this file).- Do not use
bd sync(obsolete).
ePDS uses Changesets for versioning
and release notes. CHANGELOG.md is prepend-only — the tooling adds new
sections at the top and never modifies old ones. See
.agents/reference/release-notes.md for
how the release workflow operates and how to fix attribution on Version Packages
PRs.
The repo is treated as a single release unit even though the source is split
across packages/*, via a "workspaces": ["."] field in the root
package.json that scopes Changesets to the root ePDS package only. One
CHANGELOG.md at the repo root, one v<version> git tag per release, one
GitHub Release per release.
- When to add a changeset: any user-facing or operator-facing change to the
three in-scope packages (
pds-core,auth-service,shared). Skip for internal refactors, tests-only, CI-only, docs-only, and the internal trust boundary betweenauth-serviceandpds-core(HMAC callback signature,/_internal/routes). If in doubt, add one. - How to add a changeset:
pnpm changesetthen rename the generated file to something descriptive. Commit it in the same PR as the code change. - Required format: every changeset must have a
**Affects:**line listing audiences (End users, Client app developers, Operators — in that order). The release workflow fails hard if any changeset in a release is missing this line, becausescripts/changelog-audience-summary.mjscan't generate the "Who should read this release" block without it. - Summary line: the first non-frontmatter line is read by every listed audience (it appears in the aggregate summary block at the top of the release section). If End users is one of the audiences, write the summary in plain language — no OTP/DID/PAR/OAuth jargon, no field names, no implementation concepts. Technical naming belongs in the per-audience sections below.
- No H2/H3 headings inside changeset bodies.
@changesets/changelog-githubrenders each changeset as a single indented bullet, and indented headings break out of the list on GitHub's renderer. Use bold inline labels (**End users:**,**Operators:**) instead. - Full format reference:
.agents/skills/writing-changesets/SKILL.md(also reachable via the.claude/skills/writing-changesets/SKILL.mdsymlink). Contains the audience list, body structure rules, examples of good and bad summaries, and the plain-language rule in detail. - Cutting a release:
docs/PUBLISHING.mddocuments the release workflow for maintainers — the two-phase "Version Packages PR" → "tag + GitHub Release" flow via.github/workflows/release.yml.
docker compose restartdoes not pick up.envchanges — usedocker compose up -d.- Use
pnpm docker:buildinstead of baredocker compose build— it stamps the ePDS version before building. - better-auth does not auto-migrate —
runBetterAuthMigrations()must be called explicitly on startup. - New PDS accounts need a real password passed to
createAccount()(userandomBytes(32).toString('hex')) — passingundefinedskipsregisterAccount()and leaves theaccounttable empty, breakingupsertDeviceAccount()FK constraints. - Auth service must use
PDS_INTERNAL_URLto reach pds-core over the internal network (Docker:http://core:3000, Railway:http://<service>.railway.internal:<PDS_PORT>substituting whichever port pds-core is actually configured to listen on). The URL must include thehttp://orhttps://scheme —requireInternalEnv()rejects bare hostnames at startup. WithoutPDS_INTERNAL_URL, internal API calls (par-login-hint, account-by-email) fall back to the public URL which is unreachable from containers (no hairpin NAT). - Caddy's on-demand TLS
askURL and reverse proxy upstreams must use the Docker Compose service name (core,auth) — if you rename services, updateCaddyfiledefaults too or Caddy will refuse all TLS connections.