Phase 17 invitation lifecycle: create/2, revoke/3, list_pending/2,
list_pending_for_user/2. accept/3 and accept_with_signup/3 land
in Plan 17-05.
All security-critical logic (HMAC token generation via
Sigra.Token.generate_invite_envelope/2, Ecto.Multi atomicity,
dual-key Hammer rate limiting, authorization) lives in this library
module. The generated OrganizationMembersLive is a thin event-handler
wrapper that delegates to use Sigra.Organizations re-exports.
Key invariants
organization_invitations.hashed_tokenis SHA-256 of the raw base64 invite token. The raw token never touches the DB; it is only present in the signed envelope threaded into the accept URL (HMAC-bound to email viaSigra.Token.generate_invite_envelope/2).- Pending uniqueness is enforced by the partial-unique index
organization_invitations_pending_index(IS-NULL-only predicate, IMMUTABLE-safe per D-03). - Re-invite on a pending email (D-05) revokes the old row and inserts
a new row in a single
Ecto.Multi, preserving the partial-unique invariant with zero windows where two rows are pending. - Rate limiting is dual-key:
"sigra:org_invite_create:user:<user_id>"checked first, then"sigra:org_invite_create:org:<org_id>". Fail-open per D-41 is handled insideSigra.RateLimiters.Hammer. - Email delivery happens after successful
repo.transact/2. Mailer failures are logged + telemetry-emitted and never roll back the committed DB row (D-12 discretion).
Asymmetric error exposure (Pitfall 7)
Creating an invite for an existing member returns :already_member
while creating for a user with no account returns {:ok, _}. This is
acceptable because the actor is already an admin who can
list_members/2 and see membership state directly — no new
information is leaked.
Telemetry events
[:sigra, :invitation, :email_sent]— after successful email send[:sigra, :invitation, :email_delivery_failed]— after mailer raises[:sigra, :invitation, :email_skipped]— whenemails_moduleis nil
Summary
Functions
Accept an invitation as an already-signed-in user whose email matches
the invitation email (case-insensitive via citext + String.downcase/1
belt-and-suspenders).
Accept an invitation as an anonymous visitor by atomically registering a new user, confirming them (HMAC-bound invite acceptance proves email ownership), inserting a membership, and stamping the invitation.
Create a pending invitation for email to join organization_id with role.
List pending invitations for org (struct or id). Returns the rows
sorted by inserted_at descending with :invited_by preloaded.
List pending invitations for a user (by email, case-insensitive via
citext). Returns rows with :organization and :invited_by
preloaded, sorted by inserted_at descending.
Load an invitation and classify it into a render-branch tuple for
InvitationAcceptLive (Plan 17-07, D-06).
Revoke a pending invitation.
Functions
@spec accept(map(), String.t(), struct()) :: {:ok, %{membership: struct(), invitation: struct()}} | {:error, :invalid | :expired | :revoked | :already_accepted | :mismatch}
Accept an invitation as an already-signed-in user whose email matches
the invitation email (case-insensitive via citext + String.downcase/1
belt-and-suspenders).
Returns:
{:ok, %{membership: _, invitation: _}}— membership row inserted, invitation stamped withaccepted_at+accepted_by_id, audit event emitted atomically viarepo.transact/1.{:error, :invalid}— HMAC verify failed, base64 decode failed, payload shape wrong, bound_email does not match DB row email, or invitation row missing for the looked-up hashed_token. Collapsed uniformly to:invalidto avoid information leakage.{:error, :expired}— invitation envelope or DBexpires_atis in the past.{:error, :revoked}—revoked_at IS NOT NULL.{:error, :already_accepted}—accepted_at IS NOT NULL(replay).{:error, :mismatch}—current_user.email != invitation.email(Jetstream #907 / CVE-2026-1529 defense). ZERO DB writes.
All non-:ok branches skip audit emission for organization.invitation.accepted.
@spec accept_with_signup(map(), String.t(), map()) :: {:ok, %{user: struct(), membership: struct(), invitation: struct()}} | {:error, :invalid | :expired | :revoked | :already_accepted | :email_mismatch | Ecto.Changeset.t()}
Accept an invitation as an anonymous visitor by atomically registering a new user, confirming them (HMAC-bound invite acceptance proves email ownership), inserting a membership, and stamping the invitation.
All four Multi steps run inside a single repo.transact/1. Any step
failure rolls back the entire transaction — zero orphan rows across
the user, membership, and invitation tables (Pow #534 regression
invariant).
The caller-supplied user_params["email"] is rejected server-side if
it does not match invitation.email case-insensitively, even though
the UI locks the field via disabled + readonly. The locked email is
also force-overwritten onto the registration params as a
belt-and-suspenders defense against direct POST tampering.
Raises RuntimeError when config.user_registration_changeset_fn is
nil — the host app's installer must wire this to
&YourApp.Accounts.User.registration_changeset/1.
@spec create(map(), map()) :: {:ok, struct()} | {:error, :rate_limited_user | :rate_limited_org | :unauthorized | :already_member | Ecto.Changeset.t()}
Create a pending invitation for email to join organization_id with role.
Requires the actor (passed via attrs.actor) to carry a membership with
role in [:owner, :admin]. Returns:
{:ok, %OrganizationInvitation{} = invitation}— invitation row inserted, with an ephemeral__encoded_token__key on the struct carrying the signed envelope (useful if the caller wants to thread it somewhere beyond the built-in email delivery path).{:error, :unauthorized}— actor lacks owner/admin role{:error, :rate_limited_user}— per-user Hammer limit exceeded{:error, :rate_limited_org}— per-org Hammer limit exceeded{:error, %Ecto.Changeset{}}— invitation changeset invalid
Raises RuntimeError when config.secret_key_base or
config.url_builder is nil (Phase 17 runtime requirements).
List pending invitations for org (struct or id). Returns the rows
sorted by inserted_at descending with :invited_by preloaded.
"Pending" means accepted_at IS NULL AND revoked_at IS NULL AND expires_at > now(). The expires_at filter is applied at query time
so expired-but-not-yet-cleaned-up rows don't surface to the admin UI.
List pending invitations for a user (by email, case-insensitive via
citext). Returns rows with :organization and :invited_by
preloaded, sorted by inserted_at descending.
@spec load_for_view(map(), String.t(), struct() | nil) :: {:signup, struct(), struct(), struct()} | {:accept, struct(), struct(), struct(), struct()} | {:mismatch, struct(), struct()} | {:invalid, :invalid} | {:expired, struct() | nil} | {:revoked, struct() | nil} | {:already_accepted, struct() | nil, boolean()}
Load an invitation and classify it into a render-branch tuple for
InvitationAcceptLive (Plan 17-07, D-06).
Does NOT run any Multi — the caller invokes accept/3 or
accept_with_signup/3 separately on user action. This function
performs the HMAC verify + DB lookup + optional org/inviter load and
returns a tuple the LV assigns directly to :branch + related assigns.
The current_user argument is nil for anonymous visitors (signup
branch) or a user struct for signed-in visitors (accept or mismatch
branch depending on email match).
Returned tuples:
{:signup, invitation, org, inviter}— anonymous visitor, token valid + pending{:accept, invitation, org, inviter, current_user}— signed-in user's email matches invitation email{:mismatch, invitation, current_user}— signed-in user's email does NOT match invitation email (Jetstream #907 mismatch branch).organdinviterare deliberately not returned — the mismatch branch does not render them.{:invalid, :invalid}— HMAC verify fail, tampered token, garbage base64, or invitation row missing. Uniformly:invalidfor zero info leakage.{:expired, invitation_or_nil}— envelope age > TTL or DBexpires_at <= now(). The row is reloaded without the pending guard so the view can display inviter context.{:revoked, invitation_or_nil}— DBrevoked_at IS NOT NULL.{:already_accepted, invitation_or_nil, maybe_member?}— DBaccepted_at IS NOT NULL(replay). The boolean is currently alwaysfalse— future work may use it to auto-redirect members to their org dashboard.
@spec revoke(map(), integer() | binary(), map()) :: {:ok, struct()} | {:error, :not_pending | :unauthorized | :not_found}
Revoke a pending invitation.
Requires the actor to carry a membership with role in
[:owner, :admin]. Already-accepted or already-revoked
invitations return {:error, :not_pending} — the DB row is untouched.
Missing invitation id → {:error, :not_found}. The lookup is scoped
to actor_scope.active_organization.id; cross-tenant ids are
collapsed to {:error, :not_found} to prevent enumeration.