Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 30 Oct 2026. What's built · Install 0.1.0
jadpo
Reference: Identity

Identity

Starting and ending a session

On this page

Magic links, passkeys and providers start the session themselves (Checking the person). Jadpo has no password sign-in.

When your app checks a person some other way, such as someone accepting an invitation it sent, the action that checks them starts the session with sessions.start. sessions.end signs someone out. Jadpo sets and clears the cookie and its CSRF token on the route's response:

example
action accept_invitation(token: InvitationToken) fails InvitationExpired -> Welcome {    var invitation: Invitation = attempt Invitation.redeem(token)    sessions.start(invitation.member_id)    ...}action sign_out() -> Unit {    sessions.end()}

sessions.start(invitation.member_id) starts a session for that member, and the response of the route that ran the action sets its cookie. sessions.end() ends the session the request came with.

  • sessions.start keeps the session in the action's own transaction, in the jadpo_sessions system table of the store the principal's user entity lives in (System tables). So an action that fails starts no session. The route's response sets the cookies once the action commits.
  • sessions.end deletes the session the request came with, and the response clears the cookies.
  • A session or token past its end, which a lookup ignores, and a service key past its expiry, are deleted on the tick, Jadpo's regular background sweep, in the store each is kept in (Jobs and events).
  • Only an action calls them (type.auth_sessions_context). A test reaches them through the route, or calls the action, which keeps the session without a response to set it on.
  • Only verifiers are kept: a session's token and its CSRF token are 32 random bytes each, stored as their digests, so a copy of the database signs no one in.

API tokens

Your own mobile and command-line clients sign in with an API token. An action issues one once it has checked the person, and returns it in its output. That is the one time anyone sees it:

example
action accept_invitation_in_app(token: InvitationToken) fails InvitationExpired -> AppSignedIn {    var invitation: Invitation = attempt Invitation.redeem(token)    return AppSignedIn {        token: sessions.issue_token(invitation.member_id)    }}

sessions.issue_token gives an ApiToken, which the client keeps and sends as Authorization: Bearer. With magic links, the generated POST /auth/magic-link/redeem trades a code for a token instead (Magic links).

  • sessions.issue_token(user_id) takes the user entity's id and gives an ApiToken, text an Output can send. The token is a session without a cookie: kept as a verifier in jadpo_sessions in the action's transaction, lasting 30 days from when it is issued, with no CSRF token, since a browser never sends it by itself. The response that carries it is never kept for an idempotent repeat, and an ApiToken can't be stored in an entity.
  • sessions.end() ends the token a request came with, as it ends a browser session.
  • sessions.tokens(user_id) lists the user's tokens that haven't ended, oldest first, as IssuedTokens, which code reads id, started_at and ends_at from; sessions.revoke_token(token_id) ends one by its id, and an id no token has ends nothing. Together they let an application end a user's other devices.
  • Like sessions.start, only an action calls them, except that a GET route can run sessions.tokens itself (below). Each needs api_token: in authentication { }.

Listing from a GET route

An application's "signed-in devices" screen is one route. A GET runs sessions.tokens(user_id), and Service.keys(service_id) for a service's keys, directly, as generated queries that read what Jadpo keeps:

example
routes Devices {    prefix: /devices    GET / {        output: List<IssuedToken>        run: sessions.tokens(current_principal.user.id)    }}
  • run: passes the holder's id, from a placeholder or from current_principal, and nothing else; the route's output: is List<IssuedToken> (List<IssuedKey> for keys), exactly what the listing returns. Any other method is route.run_kind, and the writes (sessions.issue_token, .revoke_token, issue_key, revoke_key) are route.run_invalid: an action calls those.
  • IssuedToken and IssuedKey are built-in values an Output can hold (tokens: List<IssuedToken>) and a route can send. On the wire each is an object of its id, started_at and ends_at, never the credential; the API record and OpenAPI give it a section of its own.
  • The list goes through Policy like any query's. It holds the tokens or keys of a holder whose record the caller may read, and is empty for any other.
  • A route that lists a holder nobody may read is policy.effect_ungranted. One anyone may call needs Access.public on the holder's read:.

Signing in, end to end

A whole app that signs members up, keeps their notes and refuses a suspended member. To show sessions on their own, signing up here starts a session for a handle without checking the person. A real app checks the person with sign_in:. The principal and its one way in:

example
use journal/entities { Member }use journal/types { MemberStatus }application {}principal {    user: {        subject: Member.handle        active: Member.status == MemberStatus.active    }}
example
authentication {    browser_session: {        cookie: "notes_session"    }}

Signing up checks the handle, starts a session and answers with no content; its response sets the cookies. Writing a note takes the signed-in member as a parameter:

example
use journal/entities { Member }use journal/types { Handle }use journal/failures { HandleTaken }use journal/actions { register }/// What signing up takes.type SignUp = Input {    handle: Handle}/// Registers a member and signs them in.action sign_up(input: SignUp) fails HandleTaken -> Unit {    var member: Member = attempt register(input.handle)    sessions.start(member.id)}
example
/// Ends the session the request came with.action sign_out() -> Unit {    sessions.end()}
example
use journal/entities { Note }use journal/types { NoteText, NoteView }/// What writing a note takes.type WriteNote = Input {    text: NoteText}/// Writes a note as the signed-in member.action write_note(input: WriteNote, author: Principal.user) -> NoteView {    var note: Note = Note.create {        set: {            author_id: author.id            text: input.text        }    }    return NoteView {        text: note.text    }}

Signing up is the one route anyone may call; every other needs the session:

example
use journal/actions { SignUp, sign_out, sign_up }/// Signing up and out.routes Account {    prefix: /account    /// Anyone may sign up; the response sets the session's cookies.    POST /sign-up {        authentication: RouteAuthentication.none        input: SignUp        status: SuccessStatus.no_content        run: sign_up(input)    }    /// Ends the session and clears its cookies.    POST /sign-out {        status: SuccessStatus.no_content        run: sign_out()    }}
example
use journal/types { NoteView }use journal/actions { WriteNote, write_note }/// The signed-in member's notes.routes Notes {    prefix: /notes    POST / {        input: WriteNote        output: NoteView        status: SuccessStatus.created        run: write_note(input, current_principal.user)    }}

A route test signs in with as: through the real browser session (a session kept for the member, its cookie, its CSRF token and an Origin), so a suspended member is refused exactly as a browser would be:

example
use journal/entities { Member }use journal/actions { WriteNote, register, suspend }test "a note needs someone signed in" {    var response: Response = request {        method: HttpMethod.post        path: "/notes"        input: WriteNote {            text: "Buy milk"        }    }    assert response.status == 401    assert response.code == "authentication_required"}test "a signed-in member writes a note" {    var member: Member = attempt register("ann")    var response: Response = request {        method: HttpMethod.post        path: "/notes"        as: Principal.user(member)        input: WriteNote {            text: "Buy milk"        }    }    assert response.status == 201}test "a suspended member is refused" {    var member: Member = attempt register("bob")    var suspended: Member = attempt suspend(member.id)    var response: Response = request {        method: HttpMethod.post        path: "/notes"        as: Principal.user(suspended)        input: WriteNote {            text: "Buy milk"        }    }    assert response.status == 403    assert response.code == "user_disabled"}

Tokens and keys, end to end

The same notes, written from the members' app with an API token, and a partner system that reads itself with a service key. Both kinds of caller are declared, and both bearer methods:

example
use journal/entities { Member, Partner }use journal/types { MemberStatus, PartnerStatus }application {}principal {    user: {        subject: Member.handle        active: Member.status == MemberStatus.active    }    service: {        subject: Partner.name        active: Partner.status == PartnerStatus.active    }}
example
authentication {    api_token: {}    service_key: {}}
example
use journal/entities { Member }use journal/types { PartnerName, PartnerStatus }/// Another system that reads the journal with its keys, managed by the/// member who added it.entity Partner {    id: Uuid    owner_id: Member.id {        on_delete: OnDelete.restricted    }    name: PartnerName {        unique: true    }    status: PartnerStatus    policy: {        create: [owner_id]        read: [id, owner_id]        update: [owner_id]    }}

Signing up issues the app's token and answers with it, once; ending every device lists the member's tokens and revokes each:

example
use journal/entities { Member }use journal/types { Handle }use journal/failures { HandleTaken }use journal/actions { register }/// What signing up takes.type SignUp = Input {    handle: Handle}/// The app's token, which it sends back as `Authorization: Bearer`.type SignedUp = Output {    token: ApiToken}/// Registers a member and gives their app a token, shown once.action sign_up(input: SignUp) fails HandleTaken -> SignedUp {    var member: Member = attempt register(input.handle)    return SignedUp {        token: sessions.issue_token(member.id)    }}
example
/// Ends every token the member's apps hold, this one's too.action sign_out_everywhere(member: Principal.user) -> Unit {    for token in sessions.tokens(member.id) {        sessions.revoke_token(token.id)    }}

Adding a partner issues its first key, which expires when the input says; the partner reads itself with it. The member who adds a partner owns it, so its grants let them issue and revoke its keys (Grants):

example
use journal/entities { Partner }use journal/types { PartnerName }use journal/failures { PartnerNameTaken }use journal/actions { create_partner }/// What adding a partner takes.type AddPartner = Input {    name: PartnerName    key_expires_at: Instant}/// The partner's key, which it sends as `Authorization: Bearer`.type PartnerAdded = Output {    key: ServiceKey}/// Adds a partner and gives its first key, shown once.action add_partner(    input: AddPartner,    owner: Principal.user) fails PartnerNameTaken -> PartnerAdded {    var partner: Partner = attempt create_partner(input.name, owner)    return PartnerAdded {        key: Partner.issue_key(partner.id, input.key_expires_at)    }}
example
use journal/entities { Partner, PartnerNotFound }use journal/types { PartnerView }/// The partner a key belongs to.query this_partner(partner: Principal.service) fails PartnerNotFound -> PartnerView {    from: Partner    where: Partner.id == partner.id    missing: PartnerNotFound}
example
use journal/actions { SignUp, SignedUp, sign_out, sign_out_everywhere, sign_up }/// Signing up and out, from the app.routes Account {    prefix: /account    /// Anyone may sign up; the response holds the app's token.    POST /sign-up {        authentication: RouteAuthentication.none        input: SignUp        output: SignedUp        status: SuccessStatus.created        run: sign_up(input)    }    /// Ends the token the request came with.    POST /sign-out {        status: SuccessStatus.no_content        run: sign_out()    }    /// Ends every token the member's apps hold.    POST /sign-out-everywhere {        status: SuccessStatus.no_content        run: sign_out_everywhere(current_principal.user)    }}

A member's own devices are one GET route, and so are another member's, for whoever may read their record. The list is limited the way a query's records are (Policy), so it is empty for a member the caller may not read. Member grants a read of itself (read: [id]):

example
use journal/entities { Member }/// The devices that hold a token for a member.routes Devices {    prefix: /devices    /// The signed-in member's own, oldest first.    GET / {        output: List<IssuedToken>        run: sessions.tokens(current_principal.user.id)    }    /// Another member's: a list holds only the tokens of members the    /// caller may read, so it is empty for anyone else's.    GET /{member_id: Member.id} {        output: List<IssuedToken>        run: sessions.tokens(member_id)    }}
example
use journal/entities { Partner }use journal/types { PartnerView }use journal/actions { AddPartner, PartnerAdded, add_partner }use journal/queries { this_partner }/// Partners and their keys.routes Partners {    prefix: /partners    /// A signed-in member adds a partner; the response holds its key.    POST / {        input: AddPartner        output: PartnerAdded        status: SuccessStatus.created        run: add_partner(input, current_principal.user)    }    /// A partner reads itself, signed in with its key.    GET /me {        output: PartnerView        run: this_partner(current_principal.service)    }    /// The keys a partner holds that haven't expired, for the member who    /// owns it.    GET /{partner_id: Partner.id}/keys {        output: List<IssuedKey>        run: Partner.keys(partner_id)    }}

A route test signs in with as: through the method the route's API accepts: a member with an API token here, since nothing declares browser sessions, and a partner with a key from Partner.issue_key, each kept for real and sent as Authorization: Bearer:

example
use journal/entities { Member, Partner }use journal/types { PartnerView }use journal/actions { WriteNote, create_partner, register, suspend_partner }test "a member writes a note with the app's token" {    var member: Member = attempt register("ann")    var response: Response = request {        method: HttpMethod.post        path: "/notes"        as: Principal.user(member)        input: WriteNote {            text: "Buy milk"        }    }    assert response.status == 201}test "a partner reads itself with its key" {    var ann: Member = attempt register("ann")    var partner: Partner = attempt create_partner("Acme", Principal.user(ann))    var response: Response = request {        method: HttpMethod.get        path: "/partners/me"        as: Principal.service(partner)    }    assert response.status == 200    assert response.output == PartnerView {        name: "Acme"    }}test "a partner can't write a member's note" {    var ann: Member = attempt register("ann")    var partner: Partner = attempt create_partner("Acme", Principal.user(ann))    var response: Response = request {        method: HttpMethod.post        path: "/notes"        as: Principal.service(partner)        input: WriteNote {            text: "Buy milk"        }    }    assert response.status == 403    assert response.code == "not_permitted"}test "a suspended partner is refused" {    var ann: Member = attempt register("ann")    var partner: Partner = attempt create_partner("Acme", Principal.user(ann))    var suspended: Partner = attempt suspend_partner(partner.id)    var response: Response = request {        method: HttpMethod.get        path: "/partners/me"        as: Principal.service(suspended)    }    assert response.status == 403    assert response.code == "service_disabled"}

A route test lists as each member and partner would: a signed-in caller's request keeps a real token or key, so there is one to list, and policy decides whose:

example
use journal/entities { Member, Partner }use journal/actions { create_partner, register }test "a member lists the devices that hold a token" {    var ann: Member = attempt register("ann")    var response: Response = request {        method: HttpMethod.get        path: "/devices"        as: Principal.user(ann)    }    assert response.status == 200    assert response.output != []}test "a member's tokens are listed to no one else" {    var ann: Member = attempt register("ann")    var bob: Member = attempt register("bob")    var by_bob: Response = request {        method: HttpMethod.get        path: "/devices"        as: Principal.user(bob)    }    assert by_bob.output != []    var by_ann: Response = request {        method: HttpMethod.get        path: "/devices/{member_id}"        params: {            member_id: ann.id        }        as: Principal.user(ann)    }    assert by_ann.output != []    var ann_on_bob: Response = request {        method: HttpMethod.get        path: "/devices/{member_id}"        params: {            member_id: bob.id        }        as: Principal.user(ann)    }    assert ann_on_bob.status == 200    assert ann_on_bob.output == []}test "a partner's keys are listed to the partner and its owner" {    var ann: Member = attempt register("ann")    var bob: Member = attempt register("bob")    var partner: Partner = attempt create_partner("Acme", Principal.user(ann))    var by_bob: Response = request {        method: HttpMethod.get        path: "/devices"        as: Principal.user(bob)    }    assert by_bob.output != []    var by_partner: Response = request {        method: HttpMethod.get        path: "/partners/{partner_id}/keys"        params: {            partner_id: partner.id        }        as: Principal.service(partner)    }    assert by_partner.output != []    var by_owner: Response = request {        method: HttpMethod.get        path: "/partners/{partner_id}/keys"        params: {            partner_id: partner.id        }        as: Principal.user(ann)    }    assert by_owner.output != []    var by_member: Response = request {        method: HttpMethod.get        path: "/partners/{partner_id}/keys"        params: {            partner_id: partner.id        }        as: Principal.user(bob)    }    assert by_member.output == []}

Markdown for agents: /docs/starting-and-ending-a-session.md.Built from Jadpo main at a707efb.