//! SyncKit group invitations: the one-link onboarding path into a group. //! //! Adding a member directly (`add_or_update_member`) needs the admin to already //! hold the member's account email and their pasted identity public key. An //! invitation removes that exchange: the admin issues a token, the invitee posts //! their public key against it, and the admin seals the grant after confirming //! the key's fingerprint. //! //! Three properties this module is responsible for: //! //! 1. **The token is never stored.** Every lookup is by SHA-256 of the token, so //! a read of the table (or of a backup) yields nothing redeemable. //! 2. **Acceptance is one-shot.** [`accept_invitation`] is a single conditional //! UPDATE, so two clients racing the same token produce one winner and one //! `None` rather than two members. //! 3. **Acceptance is not membership.** Accepting records a public key and //! nothing else. Only the admin's later seal (via the normal add-member path, //! followed by [`redeem_invitation`]) grants access. //! //! Design: wiki synckit-groups-design. use chrono::{DateTime, Utc}; use sqlx::PgPool; use crate::db::models::DbSyncGroupInvitation; use crate::db::{SyncGroupId, SyncGroupInvitationId, UserId}; use crate::error::Result; /// Every column the model needs, with the invitee's email joined in. The join is /// LEFT because an outstanding invitation names nobody yet. const SELECT_INVITATION: &str = r" SELECT i.id, i.group_id, i.inviter_user_id, i.invitee_user_id, u.email AS invitee_email, i.invitee_pubkey, i.accepted_at, i.redeemed_at, i.revoked_at, i.expires_at, i.created_at FROM sync_group_invitations i LEFT JOIN users u ON u.id = i.invitee_user_id "; /// Issue an invitation. The caller generates the token, hashes it, and keeps the /// plaintext for the link; only the hash arrives here. #[tracing::instrument(skip_all)] pub async fn create_invitation( pool: &PgPool, group_id: SyncGroupId, inviter_user_id: UserId, token_hash: &str, expires_at: DateTime, ) -> Result { let id: SyncGroupInvitationId = sqlx::query_scalar( r" INSERT INTO sync_group_invitations (group_id, inviter_user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4) RETURNING id ", ) .bind(group_id) .bind(inviter_user_id) .bind(token_hash) .bind(expires_at) .fetch_one(pool) .await?; // Re-read through the shared projection rather than RETURNING the columns // directly: the model carries a joined email, and a second shape of this // query is a second thing to keep in step. get_invitation(pool, id) .await? .ok_or_else(|| sqlx::Error::RowNotFound.into()) } /// Fetch one invitation by id. #[tracing::instrument(skip_all)] pub async fn get_invitation( pool: &PgPool, id: SyncGroupInvitationId, ) -> Result> { let sql = format!("{SELECT_INVITATION} WHERE i.id = $1"); let invitation = sqlx::query_as::<_, DbSyncGroupInvitation>(&sql) .bind(id) .fetch_optional(pool) .await?; Ok(invitation) } /// Fetch one invitation by the SHA-256 of its token, whatever state it is in. /// /// Used to show the invitee what they are about to join before they accept. /// Deliberately returns expired, revoked and redeemed rows too, so the caller can /// tell the invitee *why* a link does not work rather than answering "no such /// invitation" to a link that plainly exists. #[tracing::instrument(skip_all)] pub async fn get_invitation_by_token( pool: &PgPool, token_hash: &str, ) -> Result> { let sql = format!("{SELECT_INVITATION} WHERE i.token_hash = $1"); let invitation = sqlx::query_as::<_, DbSyncGroupInvitation>(&sql) .bind(token_hash) .fetch_optional(pool) .await?; Ok(invitation) } /// Record the invitee's acceptance against a token: their account and their /// identity public key. /// /// One statement, and the predicate carries every liveness condition, so /// consuming a token is atomic. Two devices redeeming the same link concurrently /// yield one `Some` and one `None`; a token that is expired, revoked, redeemed, /// or already accepted yields `None` without the caller needing to have read the /// row first. Callers distinguish "no such token" from "not live" with /// [`get_invitation_by_token`]. /// /// Acceptance grants nothing. It records the key the admin will seal to. #[tracing::instrument(skip_all)] pub async fn accept_invitation( pool: &PgPool, token_hash: &str, invitee_user_id: UserId, invitee_pubkey: &str, ) -> Result> { let id: Option = sqlx::query_scalar( r" UPDATE sync_group_invitations SET invitee_user_id = $2, invitee_pubkey = $3, accepted_at = NOW() WHERE token_hash = $1 AND accepted_at IS NULL AND redeemed_at IS NULL AND revoked_at IS NULL AND expires_at > NOW() RETURNING id ", ) .bind(token_hash) .bind(invitee_user_id) .bind(invitee_pubkey) .fetch_optional(pool) .await?; match id { Some(id) => get_invitation(pool, id).await, None => Ok(None), } } /// List a group's invitations, newest first. The admin's pending panel. #[tracing::instrument(skip_all)] pub async fn list_invitations( pool: &PgPool, group_id: SyncGroupId, ) -> Result> { let sql = format!("{SELECT_INVITATION} WHERE i.group_id = $1 ORDER BY i.created_at DESC"); let invitations = sqlx::query_as::<_, DbSyncGroupInvitation>(&sql) .bind(group_id) .fetch_all(pool) .await?; Ok(invitations) } /// Mark an accepted invitation as redeemed, once the admin has sealed the grant. /// Returns `false` if it was already redeemed or revoked, which is how a /// double-confirm is caught. /// /// The `group_id` is part of the predicate so an id from another group cannot be /// closed by an admin who does not own it. #[tracing::instrument(skip_all)] pub async fn redeem_invitation( pool: &PgPool, group_id: SyncGroupId, id: SyncGroupInvitationId, ) -> Result { let result = sqlx::query( r" UPDATE sync_group_invitations SET redeemed_at = NOW() WHERE id = $1 AND group_id = $2 AND accepted_at IS NOT NULL AND redeemed_at IS NULL AND revoked_at IS NULL ", ) .bind(id) .bind(group_id) .execute(pool) .await?; Ok(result.rows_affected() > 0) } /// Cancel an invitation. Works whether or not it has been accepted: an admin who /// does not recognise the fingerprint needs to be able to throw it away, and that /// is the case that matters most. #[tracing::instrument(skip_all)] pub async fn revoke_invitation( pool: &PgPool, group_id: SyncGroupId, id: SyncGroupInvitationId, ) -> Result { let result = sqlx::query( r" UPDATE sync_group_invitations SET revoked_at = NOW() WHERE id = $1 AND group_id = $2 AND redeemed_at IS NULL AND revoked_at IS NULL ", ) .bind(id) .bind(group_id) .execute(pool) .await?; Ok(result.rows_affected() > 0) }