Ithibati.Identity.Invitations (Ithibati v0.6.2)

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 expired invitations that have not been accepted.

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

Returns every pending invitation. See pending_query/0 to narrow the list first.

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 expired invitations that have not been accepted.

Ithibati schedules no cleanup. Use this list to inspect pending deletions, or call delete_expired/0 to remove them. Expired invitations are refused by fetch/1 regardless of whether their rows remain in the database.

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. See pending_query/0 to narrow the list first.

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.