Ithibati.Identity.Invitations (Ithibati v0.7.0)

Copy Markdown View Source

Finds, lists and withdraws pending invitations, and composes their acceptance into application transactions.

The application owns the invitation schema and any membership or permission it grants. Ithibati checks the token, expiry and acceptance state, and binds the invitation's identifier to the account created in the transaction.

For acceptance outside a WebAuthn ceremony:

alias Ecto.Multi
alias MyApp.Accounts.User

Multi.new()
|> Multi.insert(:account, User.changeset(%User{}, account_attrs(invitation)))
|> Ithibati.Identity.Invitations.accept(invitation)
|> Ithibati.Identity.Grant.with_key_and_codes(key_attrs)
|> MyApp.Repo.transaction()

Insert application steps such as membership creation before the grant. In a WebAuthn handler, build the account from the subject approved at the challenge step, rather than rereading its identifier from the final request's invitation. See Invitations.

Summary

Functions

Appends an :invitation acceptance step and returns the multi.

Returns a map containing the invitation's identifier under its declared field name.

Deletes expired, unaccepted invitations and returns the number of rows removed.

Returns every expired invitation nobody accepted.

Returns the query behind expired/0 and delete_expired/0, for an application to narrow.

Returns the pending invitation for a plaintext token, or nil.

Returns every pending invitation, in whatever order the database gives.

Returns the query that finds pending invitations, for the application to narrow.

Takes back an invitation nobody has accepted, so its link opens nothing.

Functions

accept(multi, invitation, opts \\ [])

Appends an :invitation acceptance step and returns the multi.

Pass an invitation struct, not nil; handle a failed fetch/1 lookup before composing this step. The :account option names an earlier account step and defaults to :account.

When the account step exists, acceptance checks that its identifier matches the invitation. A mismatch fails with :identifier_mismatch. If no account step exists, that comparison is skipped, allowing acceptance to be composed independently.

The write rechecks expiry and acceptance state. If the invitation is no longer available, the step fails with :invalid_invitation. Concurrent attempts cannot both accept the same row.

On success, :invitation contains the updated invitation. On failure, Repo.transaction/1 returns {:error, :invitation, reason, changes_so_far} and rolls back the transaction. Place this step before Ithibati.Identity.Grant.with_key_and_codes/3.

account_attrs(invitation)

Returns a map containing the invitation's identifier under its declared field name.

Use it to initialize an account outside a WebAuthn ceremony. During registration, use the subject supplied to Ithibati.Web.Handler.register/4, which was approved with the challenge.

delete_expired()

Deletes expired, unaccepted invitations and returns the number of rows removed.

expired()

Returns every expired invitation nobody accepted.

Ithibati schedules no cleanup. Use this list to look before delete_expired/0 removes them. Expired invitations are refused by fetch/1 whether or not their rows are still there.

Every one of them, in whatever order the database gives. An application that wants a site's, or an order, or a preload, narrows expired_query/0 instead.

expired_query()

Returns the query behind expired/0 and delete_expired/0, for an application to narrow.

The predicate rather than the rows, the way pending_query/0 hands over the other one. An application with sites, roles or an order of its own scopes this instead of writing the where again — and a second copy of it would be a second opinion about when an invitation has run out, which is the word delete_expired/0 deletes by.

fetch(token)

Returns the pending invitation for a plaintext token, or nil.

Pending means unaccepted and unexpired. Unknown, accepted and expired tokens all return nil, as do non-string inputs. This lookup does not reserve the invitation; accept/3 rechecks its state when writing.

pending()

Returns every pending invitation, in whatever order the database gives.

An application that wants a site's, or an order, or a preload, narrows pending_query/0 instead — which is what every one written so far has wanted.

pending_query()

Returns the query that finds pending invitations, for the application to narrow.

Pending means the same thing it means to fetch/1: unaccepted and unexpired. The application owns the table and whatever columns it added, so the list it wants is rarely all of them — it scopes, orders and preloads on top of this. Taking the predicate from here rather than writing it again is what keeps a page from offering a link that no longer opens anything, and keeps whatever the page can show to exactly what withdraw/1 will take back.

The query holds the moment it was built, not the moment it runs. Build it where you run it. A query kept across requests goes on listing invitations that have since expired, and each one offers a link that opens nothing. Ithibati asks no database for its own clock here, because the three it supports do not agree on what that answer means.

withdraw(invitation)

Takes back an invitation nobody has accepted, so its link opens nothing.

Returns {:ok, invitation}, or {:error, :already_accepted} when the row was accepted or is no longer there. The state is rechecked inside the delete rather than read beforehand: an invitation accepted between the reading and the writing would otherwise be withdrawn along with the account it just made.

An expired invitation can still be withdrawn. It opens nothing either way, and leaving the row for delete_expired/0 is a separate decision.