Models
Every table below carries tenant_id, has row-level security forced on it, and is scoped by
TenantScoped in the application as well.
Tenant
Section titled “Tenant”uuid, subdomain, name, settings, archived_at. The only model not tenant-scoped, because
it is the tenant. Generates a signing key on create.
Tenant.resolve(host) finds one by subdomain; Tenant.switch(tenant) { } sets the Postgres session
variable RLS reads and restores the previous value afterwards.
SigningKey
Section titled “SigningKey”kid, algorithm, private_pem (encrypted), public_jwk, activated_at, retired_at.
SigningKey.active |
what signing uses |
SigningKey.published |
what JWKS lists — includes a key retiring in the future |
SigningKey.rotate! |
mint a replacement, retire the incumbent after an overlap |
A person. uuid (the sub claim), nickname, name, email, password_digest, otp_secret
(encrypted), scopes, plus the rest of the OIDC standard profile: given_name, family_name,
middle_name, profile_url, picture_url, website_url, gender, birthdate, zoneinfo,
locale. All nullable, and nothing edits them yet.
Actor.authenticate(identifier, password) accepts a nickname or an email.
#claims(scopes, requested:) returns only what the granted scopes permit — profile releases the
thirteen standard profile claims, email releases the address and email_verified, and neither is
included without them. requested: is the claims request parameter, which releases a claim named
directly rather than through its scope.
Unset fields are omitted rather than returned as null, so an actor releases what it has and stays
quiet about the rest.
Client
Section titled “Client”An application. client_id, secret_digest (bcrypt), redirect_uris, grant_types,
response_types, scopes, token_endpoint_auth_method, dynamic, registration_token_digest.
#public? is token_endpoint_auth_method == "none", which is what makes PKCE mandatory.
Client.register! is the DCR path and is the only place dynamic is set.
Single table, type column, four subclasses.
| Stored as | ||
|---|---|---|
AuthorizationCode |
SHA-256 of the secret | holds the PKCE challenge, redirect_uri, nonce, authenticated_at, requested_claims |
AccessToken |
the jti of the JWT |
the JWT itself is never stored; parent_id is the code it came from |
RefreshToken |
SHA-256 of the secret | parent_id chains rotations |
Token.mint! generates a secret and returns the record with #secret readable exactly once.
Token.redeem(secret) finds a live one by digest. Token.spent(secret) finds one redeem has
already refused, which is how a replay is noticed at all. #consume! burns it; #revoke! burns it
and everything below it.
parent_id is one chain doing three jobs: it rotates refresh tokens, it records provenance through
an exchange, and — because an access token points back at the code that minted
it — it is what lets a replayed code revoke what it already issued.
Session
Section titled “Session”A browser session. digest, user_agent, ip_address, authenticated_at, expires_at,
revoked_at. The cookie holds the secret; the database holds its digest.
Consent
Section titled “Consent”What an actor has allowed a client to do: scopes and audience, unioned across grants.
Consent.covers? is what decides whether the consent screen is shown again.
