Add your first capability

Everything so far you could have written yourself in an afternoon. This is the page where that stops being true.

One command gives your app passwordless sign-in — magic link, email one-time code, Google, Apple — sessions, short-lived access tokens, a device registry, and two tiers of rate limiting. None of that code lands in your repository.

One command, three capabilities

pithy add auth --with-prerequisites

Auth does not stand alone, and the flag is why. Its manifest declares two peer capabilities: secrets, because its session signing key is read through the secret registry rather than an environment literal, and email, because magic-link and one-time-code delivery enqueues a durable mail job instead of sending inline. These are not advice. createBackend refuses to assemble a Worker missing one, with the capability named — so a project without them does not boot at all.

They compose deepest first: secrets, then email, then auth. The order is walked from the declarations rather than read off the list, and each one is a real add — its own package, its own config, its own bindings, its own dev secrets.

At a terminal you would be asked once — “auth requires secrets, email. Compose them too?” — for the whole cascade. --with-prerequisites is the answer written down, which is what a script or an agent needs. Anything that cannot be asked, including --json and any run without a terminal, is refused with the exact commands in the order they must run, rather than quietly composing three capabilities nobody named.

What landed

Look at the diff before you run anything. It is smaller than you expect.

apps/api/pithy.config.ts gained three imports and three registration calls, inside the managed region:

capabilities: [
  // pithy:capabilities (managed region — do not remove this marker)
  secrets(),
  email({ fromAddress: "noreply@example.com" }),
  auth({ basePath: "/auth" }),
],

apps/api/wrangler.jsonc gained the bindings each manifest requires, written into every environment stanza the file declares: the DB you already had, EMAIL_SUPPRESSIONS, the EMAIL_SENDER Workflow, and AUTH_RATE_LIMITER.

That last one is worth a sentence. A rate limiter is a policy, not a resource — nothing exists behind it in your account — and add writes it at 100 requests per 60 seconds, per client IP. That is a flood guard, not a product rule. Tune it in wrangler.jsonc; add never rewrites an entry you have changed. Cloudflare accepts a period of 10 or 60 and nothing else.

Your local database gained the capabilities’ tables. add re-reads the config after wiring and runs that Worker’s dev migrations, so the migration that just arrived is in the registry. Auth’s are all pithy_auth_*: users, sessions, accounts, verifications, jwks, rate_limit, devices. Your notes table is untouched beside them.

Your dev secrets were minted — outside your repository. The session signing key and the email link-signing key landed in <config>/<project>/secrets.jsonc in the Pithy config directory. There is nothing to gitignore, nothing git add -A can reach, and nothing npm pack can carry. Deployed environments get their own with pithy secrets create.

Nothing touched Cloudflare. add writes config and local D1 only. Bindings whose resources live in your account come back as notes, to be created by pithy email provision and pithy secrets provision when you are ready.

Run it a second time and nothing changes. Every command in this kit is idempotent, which is what makes them safe to put in a script.

Sign somebody in

pithy dev

The ready banner now says one extra thing: whether mail is being delivered for real or simulated. Magic-link delivery in dev runs with remote: true by default, so a link you trigger from localhost goes out through Cloudflare Email Service with the same DKIM and the same delivery logs as production. That needs a Cloudflare login and a sending domain already onboarded. With neither — which is where you are right now — pithy dev says so, names the command that would fix it, and starts the session against the local simulator instead. The simulator logs the sender, recipient and subject, and writes the rendered HTML and text bodies to disk.

So the link is readable either way. Ask for one:

curl -X POST http://localhost:8787/auth/sign-in/magic-link \
  -H "content-type: application/json" \
  -d '{"email":"you@example.com","callbackURL":"/"}'

Then open the URL from the simulator’s output. That verifies the token and mints a session.

Protect a route

One import and one middleware:

import { requireAuth } from "@pithy-sh/auth";

routes: (a) => {
  a.get("/notes", requireAuth(), async (c) => {
    const rows = await c.var.db.app
      .selectFrom("notes")
      .where("authorId", "=", c.var.auth.userId)
      .selectAll()
      .execute();
    return c.json({ notes: rows });
  });
},

c.var.auth carries the resolved identity — the user id, and the session and device ids. It is null until a verification strategy sets it, and requireAuth() is what turns that null into a 401 before your handler runs.

Every other capability gates its own routes exactly this way. None of them validates a token itself; they read the same AuthContext seam. That is why adding payments or storage later needs no auth wiring from you — the seam is already filled.

The token model, briefly

A successful sign-in mints a session: the long-lived refresh credential. On mobile it lives in secure device storage; on web it is a CSRF-protected cookie.

The session is not what you put on every request. Your app exchanges it for a short-lived JWT access token — 15 minutes, EdDSA — by calling GET /auth/token, and sends that as Authorization: Bearer <jwt>. The Worker verifies it locally against the JWKS published at /auth/jwks, so a request costs no database round-trip. When the access token expires, mint another. When the session expires, sign in again.

The token model is the long version, and Sessions and devices covers the registry that makes sign me out on that lost phone a single call.

What is deliberately not here

There is no password. emailAndPassword is never enabled, and that is a security stance rather than an unfinished feature. No password database to leak, phish, or reset. If your requirements name password sign-in, this capability says no rather than not yet.

There is no impersonation. “Sign in as this user” mints a credential indistinguishable from the person’s own, so every action taken with it reads in the audit trail as theirs. It is excluded on purpose, and it is not reachable by composing what is here.

ESC