Auth
Provider-agnostic session contract, an EdDSA-signed account JWT, and multi-user account membership over row-level security.
@caisson/auth is provider-agnostic: it defines the session contract the rest of the base
depends on, a short-lived EdDSA-signed account JWT for verifying a caller across services, and
multi-user account membership over row-level security. better-auth,
self-hosted, is the reference session provider, wired at the app layer, not re-exported from this
package. There is no third-party identity tenant holding your users.
The contract
Auth defines two seams. A same-process read (a dashboard route) resolves a SessionContext
straight from the session provider. A cross-plane call carries a short-lived EdDSA account JWT
that the receiving service verifies against the issuer's Ed25519 public key, no shared secret, no
network round trip:
import {
requireSession,
verifyAccountJwt,
type SessionContext,
} from "@caisson/auth";
// A cross-plane caller presents a short-lived EdDSA account JWT; the receiving service verifies
// it against the issuer's Ed25519 public key.
const session: SessionContext = verifyAccountJwt(token, issuerPublicKey);
// The ONE call a protected route makes before a tenant read — throws 401 on no session.
requireSession(session);session.accountId is the claim tenancy-rls's withTenant reads:
auth is the only producer of that claim, tenancy-rls the only consumer. That single seam is why a
request can cross from the control plane to the data plane without a shared-secret handshake.
API reference
Session contract
SessionContext:{ userId, accountId, role }.accountIdis the only value the data layer trusts for RLS.Role:"owner" | "seat".SessionProvider: the interface a runtime implements to resolve a request to a session. better-auth is the reference implementation; nothing downstream depends on it directly.requireSession(ctx): guards a protected route. ThrowsAuthnError(401) onnull, otherwise returns the session unchanged.
type Role = "owner" | "seat";
interface SessionContext {
userId: string;
accountId: string;
role: Role;
}
interface SessionProvider {
resolveSession(request: Request): Promise<SessionContext | null>;
}
function requireSession(ctx: SessionContext | null): SessionContext;Account JWT
generateAccountKeyPair(): generates an Ed25519 keypair. The issuer holds the private key; every verifying service holds only the public key.signAccountJwt(claims, privateKey, options?): mints an EdDSA-signed token. Default TTL is 900 seconds (15 minutes);nowis overridable for tests.verifyAccountJwt(token, publicKey, options?): verifies the signature, expiry, and claim shape, and returns theSessionContextthe token asserts. A malformed token, a bad signature, a token signed by a different key, and an expiredexpall collapse to the same genericAuthnError("Invalid token")(401), the verifier never leaks why a token failed, so a caller can't use the error to probe for a valid key or a near-expiry window.
interface AccountClaims {
userId: string;
accountId: string;
role: Role;
}
interface SignOptions {
ttlSeconds?: number; // default 900
now?: number; // seconds; override for tests
}
function generateAccountKeyPair(): {
publicKey: KeyObject;
privateKey: KeyObject;
};
function signAccountJwt(
claims: AccountClaims,
privateKey: KeyObject,
options?: SignOptions,
): string;
function verifyAccountJwt(
token: string,
publicKey: KeyObject,
options?: { now?: number },
): SessionContext;Account membership
resolveUserAccounts(db, userId): every account a signed-in user belongs to, scoped by RLS (withUser) so a user reads only their own memberships. Ordered oldest-first, so the personal account (created at first sign-in) sorts first.ensurePersonalAccount(db, userId): guarantees a user has at least a personal account (accountId === userId, roleowner). Idempotent (a second call is a no-op) and runs account-scoped so the RLSWITH CHECKis satisfied.selectActiveAccount(memberships, requestedAccountId?): pure selection: honorsrequestedAccountIdwhen it names one of the caller's own memberships, else falls back to the personal account, else the first (oldest) membership. Returnsnullonly when the caller has no memberships.
interface AccountMembership {
accountId: string;
userId: string;
role: Role;
}
function resolveUserAccounts(
db: Transactor,
userId: string,
): Promise<AccountMembership[]>;
function ensurePersonalAccount(db: Transactor, userId: string): Promise<void>;
function selectActiveAccount(
memberships: readonly AccountMembership[],
requestedAccountId?: string,
): AccountMembership | null;Transactor is tenancy-rls's driver surface, the same one
withTenant and withUser accept.
Schema
ACCOUNT_MEMBER_SCHEMA_SQL: the DDL for theaccount_membertable: primary key(account_id, user_id), arolecheck constraint, and a dual-GUC RLS policy. A read passes when either the tenant GUC (withTenant: an owner listing their account's members) or the user GUC (withUser: a signed-in user resolving their own memberships) matches; a write requires the tenant GUC, so a seat can't insert a membership row into an account they don't already hold. Emit it into a migration the same way any other base package ships its schema constant.
const ACCOUNT_MEMBER_SCHEMA_SQL: string;Related
Base substrate
The sixteen Apache-2.0 packages every bundle sits on — kernel, auth, tenancy RLS, UI tokens, billing, jobs, email, AI config, the MCP server, registry-schema, observability, rate-limit, ds-manifest, and the generator tooling (cli, migrate, license-verify) — plus the commercial Platform tier.
Tenancy (RLS)
Fail-closed multi-tenant Postgres RLS. A query with no tenant context returns nothing.