Skip to content
Paykit
Esc
↑↓navigate↵open⌘Jpreview
On this page

Auth

Gate the Api with any session, without teaching the model who answered.

paykit does not own authentication. Session and Admin are middleware over a single question: who is calling, and for which account? A deployment answers that from its own session — Better Auth, yielded/auth, a cookie, a bearer token — and the catalog and the model never learn which one.

One resolver, two guards

import { Paykit } from "@potion.run/paykit";
import * as Effect from "effect/Effect";

const resolve: Paykit.ActorResolver = ({ headers }) =>
  sessionFor(headers).pipe(
    Effect.map((session) =>
      session === null ? null : Paykit.actorAccount(session.user.id),
    ),
  );

const Tenant = Paykit.SessionFrom(resolve);
const Operator = Paykit.AdminFrom(resolve);
// or both from one resolver, for a Worker that serves both surfaces
const Guards = Paykit.GuardsFrom(resolve);

The resolver returns ActorValue | null:

Answer The guard does
Paykit.actorAccount(id) Provides the Actor; a command may target only id.
Paykit.actorAdmin(id) Provides the Actor; a command may target any account.
null Refuses with Unauthenticated.
A failure Becomes a defect. A broken session store is not an anonymous caller, and must not sign a valid user out.

SessionFrom reads the command’s target and refuses with Forbidden when an account actor names another account. AdminFrom requires kind: "admin". The views re-check the key, so a read is scoped even if a route forgot to be.

Better Auth

The auth example wires Better Auth to D1 and reduces it to one resolver:

import { BetterAuth } from "@alchemy.run/better-auth";

const auth = yield* BetterAuth({
  basePath: "/api/auth",
  emailAndPassword: { enabled: true },
});

const resolve: Paykit.ActorResolver = ({ headers }) =>
  auth.getSession(toWebHeaders(headers)).pipe(
    Effect.map((session) =>
      session === null ? null : Paykit.actorAccount(session.user.id),
    ),
  );

const Guards = Paykit.GuardsFrom(resolve);

Mount Better Auth’s own routes outside the payment guards:

if (path.startsWith("/api/auth")) return yield* auth.fetch;
if (path.startsWith("/api")) return yield* tenant.fetch;
if (path.startsWith("/admin")) return yield* admin.fetch;

A caller cannot have a session before it can sign in, so /api/auth/* must not require one.

yielded/auth or your own session

The resolver API is not specific to Better Auth. A yielded/auth session, a same-origin cookie or a signed header is the same shape:

const Guards = Paykit.GuardsFrom(({ headers }) =>
  Effect.sync(() => {
    const session = readCookie(headers);
    return session === null ? null : Paykit.actorAccount(session.accountId);
  }),
);

examples/admin uses exactly that with a cookie, and InsecureSessionFromHeaders is the development implementation over x-paykit-actor / x-paykit-kind — the name is the warning. They differ only in how they answer “who is this?”.

Where the account comes from

Choose one identity and use it everywhere:

  • A user is one account: Paykit.actorAccount(user.id).
  • A team owns the subscription: map the session’s active organization or team id, and the same user acts for that account.

Never derive the account from a query parameter or a request body. The guard compares the command’s target with the session’s account, so a caller cannot name another account — but the route must still pass the session’s id when it binds Paykit.Subscribe(account) or Paykit.TrackUsage(account).

What the deployment must preserve

A session is a cookie over state the deployment owns. Four things must survive a deploy; lose one and a deploy signs everyone out or treats the database as new:

  • The signing secret. BetterAuth({ secret }) signs every session cookie. Alchemy provisions a stable secret by default and binds it into the host environment; when you pass your own, keep it across deploys. Rotating it is a deliberate sign-out of every user, not a routine change.
  • The stack state. State records the auth database, the secret resource, the migration fingerprints and the vendor objects. Losing it makes the next deploy create new resources beside the old ones and run migrations against what looks like a new target. The examples use Alchemy.localState(); a deployment uses a durable backend.
  • The migration history. Better Auth’s migrator and paykit’s are both additive and forward-only. Never edit a file a database already applied; the registry records its hash and refuses the rewrite. Migrations run at apply, never in the deployed Worker, so a broken migration fails the deploy rather than a request.
  • D1’s missing interactive transactions. D1 offers one batch, not BEGIN … COMMIT. Sessions fit; a plugin that needs interactive transactions and the billing store belong on Postgres.

paykit’s side of that — the database port, the target layers and the converge step — is in Database.

Was this page helpful?