Skip to main content

max / makenotwork

Invite a member to a SyncKit group with an expiring one-use token The admin hands out a token instead of collecting a pasted public key. Redeeming it grants nothing: it records the invitee's key against the invitation and leaves the admin to confirm, so the key a member is admitted under is still one a human looked at. An unredeemed link is a standing credential, so it always expires; the default is seven days and the ceiling thirty. The preview path reads the group by id alone, which is safe only because the unguessable token has already named exactly one group.
Author: Max Johnson <me@maxj.phd> · 2026-08-07 00:20 UTC
Signed with PGP, not checked
Commit: beda9e8c548bb84adfa6b99ad4e326cbd2c1eb3a
Parent: edf7bb9
11 files changed, +1259 insertions, -6 deletions
@@ -436,6 +436,15 @@
436 436 // String / buffer limits
437 437 pub const USER_AGENT_MAX_LENGTH: usize = 512;
438 438 pub const SYNCKIT_MAX_KEY_ENVELOPE_BYTES: usize = 4096;
439 +
440 + // SyncKit group invitations. An unredeemed invite link is a standing credential,
441 + // so it always expires; these bound how long an admin may leave one open.
442 + /// Default life of an invite link when the caller names none.
443 + pub const SYNCKIT_INVITE_DEFAULT_HOURS: i64 = 168; // 7 days
444 + /// Longest an admin may leave an invite link redeemable.
445 + pub const SYNCKIT_INVITE_MAX_HOURS: i64 = 720; // 30 days
446 + /// Shortest usable life. Below this the link expires before it can be delivered.
447 + pub const SYNCKIT_INVITE_MIN_HOURS: i64 = 1;
439 448 pub const MAX_PRICE_CENTS: i32 = 1_000_000; // $10,000
440 449 /// Minimum for a non-zero buy-once price, Stripe rejects charges under $0.50.
441 450 pub const MIN_BUY_ONCE_PRICE_CENTS: i32 = 50; // $0.50
@@ -153,6 +153,7 @@
153 153 SyncDeviceId,
154 154 SyncBlobId,
155 155 SyncGroupId,
156 + SyncGroupInvitationId,
156 157 LoginTokenId,
157 158 OAuthCodeId,
158 159 OAuthRefreshTokenId,
@@ -42,6 +42,7 @@
42 42 mod db_ssh_keys_layer;
43 43 mod db_synckit_billing_layer;
44 44 mod db_synckit_groups;
45 + mod db_synckit_invitations;
45 46 mod db_synckit_rotation;
46 47 mod db_transactions_layer;
47 48 mod db_users_layer;
@@ -6,7 +6,7 @@
6 6
7 7 use super::super::id_types::{
8 8 ItemId, OtaArtifactId, OtaReleaseId, ProjectId, SyncAppId, SyncBlobId, SyncDeviceId,
9 - SyncGroupId, UserId,
9 + SyncGroupId, SyncGroupInvitationId, UserId,
10 10 };
11 11
12 12 /// A registered sync app with a hashed API key.
@@ -143,6 +143,45 @@
143 143 pub added_at: DateTime<Utc>,
144 144 }
145 145
146 + /// An outstanding or settled invitation to join a group.
147 + ///
148 + /// The invitation exists so onboarding is one link rather than a two-channel
149 + /// exchange of an email address and a pasted public key. It never carries the
150 + /// Group Content Key: the admin still seals the grant client-side, after
151 + /// confirming the invitee's key fingerprint, so a link is an invitation and not
152 + /// membership.
153 + ///
154 + /// The token is absent by construction. Only its SHA-256 is stored, and the
155 + /// plaintext lives in the link the admin sent.
156 + #[derive(Debug, Clone, FromRow, Serialize)]
157 + pub struct DbSyncGroupInvitation {
158 + /// Database primary key.
159 + pub id: SyncGroupInvitationId,
160 + /// The group this invitation joins.
161 + pub group_id: SyncGroupId,
162 + /// The admin who issued it.
163 + pub inviter_user_id: UserId,
164 + /// The account that accepted, or `None` while the invitation is outstanding.
165 + pub invitee_user_id: Option<UserId>,
166 + /// The accepting account's email, joined from `users`. What the admin reads
167 + /// in the pending list; `None` until acceptance.
168 + pub invitee_email: Option<String>,
169 + /// The invitee's identity public key (base64), posted on acceptance. This is
170 + /// what the admin seals the GCK to, and the value whose fingerprint the admin
171 + /// confirms out of band before doing so.
172 + pub invitee_pubkey: Option<String>,
173 + /// When the invitee accepted.
174 + pub accepted_at: Option<DateTime<Utc>>,
175 + /// When the admin confirmed and sealed the grant. Terminal.
176 + pub redeemed_at: Option<DateTime<Utc>>,
177 + /// When the admin cancelled it.
178 + pub revoked_at: Option<DateTime<Utc>>,
179 + /// When an unredeemed invitation stops being redeemable.
180 + pub expires_at: DateTime<Utc>,
181 + /// When the invitation was issued.
182 + pub created_at: DateTime<Utc>,
183 + }
184 +
146 185 /// An entry in a group's append-only shared change log (`sync_group_log`).
147 186 ///
148 187 /// The group changelog is a separate table from the personal [`DbSyncLogEntry`]
@@ -98,6 +98,27 @@
98 98 Ok(group)
99 99 }
100 100
101 + /// Fetch a group by id alone, without an app scope.
102 + ///
103 + /// The app scope on [`get_group`] guards against a caller guessing ids. This
104 + /// variant exists for the invitation preview, where the unguessable token has
105 + /// already named exactly one group and the caller is by definition not a member
106 + /// of it yet. Do not reach for it on a path where the caller supplies the id.
107 + #[tracing::instrument(skip_all)]
108 + pub async fn get_group_by_id(pool: &PgPool, group_id: SyncGroupId) -> Result<Option<DbSyncGroup>> {
109 + let group = sqlx::query_as::<_, DbSyncGroup>(
110 + r"
111 + SELECT id, app_id, admin_user_id, name, gck_version, created_at
112 + FROM sync_groups
113 + WHERE id = $1
114 + ",
115 + )
116 + .bind(group_id)
117 + .fetch_optional(pool)
118 + .await?;
119 + Ok(group)
120 + }
121 +
101 122 /// List the groups a user belongs to within an app, most recent first.
102 123 #[tracing::instrument(skip_all)]
103 124 pub async fn list_groups_for_user(
@@ -8,6 +8,7 @@
8 8 mod blobs;
9 9 mod devices;
10 10 mod groups;
11 + mod invitations;
11 12 mod keys;
12 13 mod log;
13 14 mod rotation;
@@ -18,6 +19,7 @@
18 19 pub use blobs::*;
19 20 pub use devices::*;
20 21 pub use groups::*;
22 + pub use invitations::*;
21 23 pub use keys::*;
22 24 pub use log::*;
23 25 pub use rotation::*;
@@ -20,21 +20,24 @@
20 20 http::StatusCode,
21 21 response::{IntoResponse, Response},
22 22 };
23 + use chrono::{Duration, Utc};
23 24 use serde_json::json;
24 25 use sqlx::PgPool;
25 26
26 27 use crate::{
27 28 constants,
28 - db::{self, DbSyncGroup, SyncGroupId, UserId},
29 + db::{self, DbSyncGroup, SyncGroupId, SyncGroupInvitationId, UserId},
29 30 error::{AppError, Result},
30 31 synckit_auth::SyncUser,
31 32 validation,
32 33 };
33 34
34 35 use super::{
35 - AddMemberRequest, CreateGroupRequest, GrantQuery, GroupGrantResponse, GroupMemberPubkey,
36 - GroupMemberResponse, GroupResponse, PullChangeEntry, PullRequest, PullResponse, PushRequest,
37 - PushResponse, RotateGroupKeyRequest,
36 + AcceptInvitationRequest, AddMemberRequest, ConfirmInvitationRequest, CreateGroupRequest,
37 + CreateInvitationRequest, CreateInvitationResponse, GrantQuery, GroupGrantResponse,
38 + GroupMemberPubkey, GroupMemberResponse, GroupResponse, InvitationPreviewResponse,
39 + InvitationResponse, PullChangeEntry, PullRequest, PullResponse, PushRequest, PushResponse,
40 + RotateGroupKeyRequest,
38 41 };
39 42
40 43 /// Fetch a group scoped to the caller's app, or 404. Guards every group handler
@@ -597,3 +600,336 @@
597 600 has_more,
598 601 }))
599 602 }
603 +
604 + // ── Invitations ──
605 +
606 + /// Issue an invite link for a group. Admin only.
607 + ///
608 + /// The token is minted here, hashed, and only the hash is stored, so this
609 + /// response is the one and only time the plaintext exists server-side. The admin
610 + /// puts it in a link and sends it however they like; the server is not involved
611 + /// in delivery and never sees the link again until it is redeemed.
612 + #[utoipa::path(post, path = "/api/v1/sync/groups/{id}/invitations", tag = "SyncKit",
613 + params(("id" = String, Path, description = "Group ID")),
614 + request_body = CreateInvitationRequest,
615 + responses(
616 + (status = 200, description = "Invitation issued", body = CreateInvitationResponse),
617 + (status = 403, description = "Not the group admin"),
618 + ),
619 + security(("bearer" = [])),
620 + )]
621 + #[tracing::instrument(skip_all, name = "synckit::create_group_invitation")]
622 + pub(super) async fn create_invitation(
623 + State(db): State<PgPool>,
624 + sync_user: SyncUser,
625 + Path(group_id): Path<SyncGroupId>,
626 + Json(req): Json<CreateInvitationRequest>,
627 + ) -> Result<impl IntoResponse> {
628 + require_group(&db, sync_user.app_id, group_id).await?;
629 + require_admin(&db, group_id, sync_user.user_id).await?;
630 +
631 + let hours = req
632 + .expires_in_hours
633 + .unwrap_or(constants::SYNCKIT_INVITE_DEFAULT_HOURS);
634 + if !(constants::SYNCKIT_INVITE_MIN_HOURS..=constants::SYNCKIT_INVITE_MAX_HOURS).contains(&hours)
635 + {
636 + return Err(AppError::BadRequest(format!(
637 + "expires_in_hours must be between {} and {}",
638 + constants::SYNCKIT_INVITE_MIN_HOURS,
639 + constants::SYNCKIT_INVITE_MAX_HOURS
640 + )));
641 + }
642 +
643 + let token = generate_invite_token();
644 + let expires_at = Utc::now() + Duration::hours(hours);
645 + let invitation = db::synckit::create_invitation(
646 + &db,
647 + group_id,
648 + sync_user.user_id,
649 + &crate::crypto::sha256_hex(&token),
650 + expires_at,
651 + )
652 + .await?;
653 +
654 + tracing::info!(%group_id, invitation_id = %invitation.id, "issued group invite link");
655 +
656 + Ok(Json(CreateInvitationResponse {
657 + id: invitation.id,
658 + token,
659 + expires_at: invitation.expires_at,
660 + }))
661 + }
662 +
663 + /// A fresh invite token: 32 bytes of randomness, hex.
664 + ///
665 + /// Hex rather than base64 so the token survives being pasted through anything
666 + /// that mangles `+/=`, which a link handed between humans routinely is.
667 + fn generate_invite_token() -> String {
668 + use rand::RngExt;
669 + let mut rng = rand::rng();
670 + let bytes: [u8; 32] = rng.random();
671 + hex::encode(bytes)
672 + }
673 +
674 + /// List a group's invitations. Admin only.
675 + ///
676 + /// The admin's confirmation queue: an accepted invitation shows the invitee's
677 + /// email and public key so the client can render a fingerprint to check against
678 + /// what the invitee reads out over some other channel.
679 + #[utoipa::path(get, path = "/api/v1/sync/groups/{id}/invitations", tag = "SyncKit",
680 + params(("id" = String, Path, description = "Group ID")),
681 + responses(
682 + (status = 200, description = "Invitations, newest first", body = Vec<InvitationResponse>),
683 + (status = 403, description = "Not the group admin"),
684 + ),
685 + security(("bearer" = [])),
686 + )]
687 + #[tracing::instrument(skip_all, name = "synckit::list_group_invitations")]
688 + pub(super) async fn list_invitations(
689 + State(db): State<PgPool>,
690 + sync_user: SyncUser,
691 + Path(group_id): Path<SyncGroupId>,
692 + ) -> Result<impl IntoResponse> {
693 + require_group(&db, sync_user.app_id, group_id).await?;
694 + require_admin(&db, group_id, sync_user.user_id).await?;
695 +
696 + let invitations = db::synckit::list_invitations(&db, group_id).await?;
697 + let response: Vec<InvitationResponse> = invitations
698 + .into_iter()
699 + .map(InvitationResponse::from)
700 + .collect();
701 + Ok(Json(response))
702 + }
703 +
704 + /// Confirm an accepted invitation and seal the grant. Admin only.
705 + ///
706 + /// This is the step the link deliberately does not remove. The admin has, by
707 + /// this point, compared the invitee's key fingerprint against what the invitee
708 + /// told them over a channel the server does not control; without that check a
709 + /// server able to substitute a public key at acceptance would receive a grant to
710 + /// the group key. Possession of a link gets someone into this queue and no
711 + /// further.
712 + ///
713 + /// The grant is sealed to the public key **stored on the invitation**, not to one
714 + /// the admin re-supplies, so the key that was confirmed is the key that is used.
715 + #[utoipa::path(post, path = "/api/v1/sync/groups/{id}/invitations/{invitation_id}/confirm", tag = "SyncKit",
716 + params(
717 + ("id" = String, Path, description = "Group ID"),
718 + ("invitation_id" = String, Path, description = "Invitation ID"),
719 + ),
720 + request_body = ConfirmInvitationRequest,
721 + responses(
722 + (status = 204, description = "Member added"),
723 + (status = 400, description = "Invitation is not awaiting confirmation"),
724 + (status = 403, description = "Not the group admin"),
725 + ),
726 + security(("bearer" = [])),
727 + )]
728 + #[tracing::instrument(skip_all, name = "synckit::confirm_group_invitation")]
729 + pub(super) async fn confirm_invitation(
730 + State(db): State<PgPool>,
731 + sync_user: SyncUser,
732 + Path((group_id, invitation_id)): Path<(SyncGroupId, SyncGroupInvitationId)>,
733 + Json(req): Json<ConfirmInvitationRequest>,
734 + ) -> Result<impl IntoResponse> {
735 + let group = require_group(&db, sync_user.app_id, group_id).await?;
736 + require_admin(&db, group_id, sync_user.user_id).await?;
737 +
738 + if req.sealed_gck.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES {
739 + return Err(AppError::BadRequest(
740 + "Sealed key exceeds size limit".to_string(),
741 + ));
742 + }
743 + let role = req.role.as_deref().unwrap_or("member");
744 + if role != "member" && role != "admin" {
745 + return Err(AppError::BadRequest(
746 + "role must be 'member' or 'admin'".to_string(),
747 + ));
748 + }
749 +
750 + let invitation = db::synckit::get_invitation(&db, invitation_id)
751 + .await?
752 + .filter(|i| i.group_id == group_id)
753 + .ok_or(AppError::NotFound)?;
754 +
755 + if super::invitation_state(&invitation) != "accepted" {
756 + return Err(AppError::BadRequest(
757 + "That invitation is not awaiting confirmation".to_string(),
758 + ));
759 + }
760 + // An accepted invitation always carries both, but reading them out of Options
761 + // is where that invariant gets stated rather than assumed.
762 + let (Some(invitee_user_id), Some(invitee_pubkey)) = (
763 + invitation.invitee_user_id,
764 + invitation.invitee_pubkey.as_deref(),
765 + ) else {
766 + return Err(AppError::BadRequest(
767 + "That invitation has no accepted key".to_string(),
768 + ));
769 + };
770 +
771 + db::synckit::add_or_update_member(
772 + &db,
773 + group_id,
774 + invitee_user_id,
775 + role,
776 + &req.sealed_gck,
777 + group.gck_version,
778 + invitee_pubkey,
779 + )
780 + .await?;
781 +
782 + // Close the invitation after the grant lands. The other order would mark it
783 + // spent and then fail to add the member, leaving a token that opens nothing
784 + // and an invitee with no way back in but a fresh link.
785 + if !db::synckit::redeem_invitation(&db, group_id, invitation_id).await? {
786 + tracing::warn!(
787 + %group_id, %invitation_id,
788 + "member added but invitation was already closed; concurrent confirm"
789 + );
790 + }
791 +
792 + tracing::info!(%group_id, %invitation_id, "confirmed invitation and sealed grant");
793 + Ok(StatusCode::NO_CONTENT)
794 + }
795 +
796 + /// Cancel an invitation. Admin only.
797 + ///
798 + /// Works on an accepted invitation as well as an outstanding one, because the
799 + /// case that matters is an admin who looked at a fingerprint and did not
800 + /// recognise it.
801 + #[utoipa::path(delete, path = "/api/v1/sync/groups/{id}/invitations/{invitation_id}", tag = "SyncKit",
802 + params(
803 + ("id" = String, Path, description = "Group ID"),
804 + ("invitation_id" = String, Path, description = "Invitation ID"),
805 + ),
806 + responses(
807 + (status = 204, description = "Invitation revoked"),
808 + (status = 403, description = "Not the group admin"),
809 + (status = 404, description = "No such open invitation"),
810 + ),
811 + security(("bearer" = [])),
812 + )]
813 + #[tracing::instrument(skip_all, name = "synckit::revoke_group_invitation")]
814 + pub(super) async fn revoke_invitation(
815 + State(db): State<PgPool>,
816 + sync_user: SyncUser,
817 + Path((group_id, invitation_id)): Path<(SyncGroupId, SyncGroupInvitationId)>,
818 + ) -> Result<impl IntoResponse> {
819 + require_group(&db, sync_user.app_id, group_id).await?;
820 + require_admin(&db, group_id, sync_user.user_id).await?;
821 +
822 + if db::synckit::revoke_invitation(&db, group_id, invitation_id).await? {
823 + Ok(StatusCode::NO_CONTENT)
824 + } else {
825 + Err(AppError::NotFound)
826 + }
827 + }
828 +
829 + /// Show what an invite link leads to, before accepting it.
830 + ///
831 + /// Authenticated, but not gated on membership: the caller is by definition not a
832 + /// member yet. It answers which group, from whom, and whether the link is still
833 + /// good. Nothing else is exposed, because anyone holding the link can read it.
834 + #[utoipa::path(get, path = "/api/v1/sync/invitations/{token}", tag = "SyncKit",
835 + params(("token" = String, Path, description = "Invite token")),
836 + responses(
837 + (status = 200, description = "What the link leads to", body = InvitationPreviewResponse),
838 + (status = 404, description = "No such invitation"),
839 + ),
840 + security(("bearer" = [])),
841 + )]
842 + #[tracing::instrument(skip_all, name = "synckit::preview_invitation")]
843 + pub(super) async fn preview_invitation(
844 + State(db): State<PgPool>,
845 + _sync_user: SyncUser,
846 + Path(token): Path<String>,
847 + ) -> Result<impl IntoResponse> {
848 + let invitation = db::synckit::get_invitation_by_token(&db, &crate::crypto::sha256_hex(&token))
849 + .await?
850 + .ok_or(AppError::NotFound)?;
851 +
852 + // The group is read without an app scope because the token, not the caller,
853 + // established which group is meant; the token is unguessable and names
854 + // exactly one.
855 + let group = db::synckit::get_group_by_id(&db, invitation.group_id)
856 + .await?
857 + .ok_or(AppError::NotFound)?;
858 + let inviter_email = db::users::get_user_by_id(&db, invitation.inviter_user_id)
859 + .await?
860 + .map(|u| u.email.to_string())
861 + .unwrap_or_default();
862 +
863 + let state = super::invitation_state(&invitation);
864 + Ok(Json(InvitationPreviewResponse {
865 + group_name: group.name,
866 + inviter_email,
867 + redeemable: state == "pending",
868 + state: state.to_string(),
869 + expires_at: invitation.expires_at,
870 + }))
871 + }
872 +
873 + /// Accept an invitation by posting your identity public key against its token.
874 + ///
875 + /// This grants nothing. It records the key the admin will seal the group key to
876 + /// once they have confirmed its fingerprint, which is why an invitee is not a
877 + /// member when this returns and their client should say so.
878 + ///
879 + /// One-shot: the underlying update carries every liveness condition in its
880 + /// predicate, so two devices racing the same link produce one acceptance.
881 + #[utoipa::path(post, path = "/api/v1/sync/invitations/accept", tag = "SyncKit",
882 + request_body = AcceptInvitationRequest,
883 + responses(
884 + (status = 204, description = "Accepted; awaiting the admin's confirmation"),
885 + (status = 400, description = "Link is expired, revoked, or already used"),
886 + (status = 409, description = "Already a member, or already awaiting confirmation"),
887 + ),
888 + security(("bearer" = [])),
889 + )]
890 + #[tracing::instrument(skip_all, name = "synckit::accept_invitation")]
891 + pub(super) async fn accept_invitation(
892 + State(db): State<PgPool>,
893 + sync_user: SyncUser,
894 + Json(req): Json<AcceptInvitationRequest>,
895 + ) -> Result<impl IntoResponse> {
896 + if req.invitee_pubkey.is_empty()
897 + || req.invitee_pubkey.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES
898 + {
899 + return Err(AppError::BadRequest("Invalid public key".to_string()));
900 + }
901 +
902 + let token_hash = crate::crypto::sha256_hex(&req.token);
903 +
904 + // Read first, only to tell the invitee why a link that plainly exists does
905 + // not work. The read is not the gate; the conditional update below is, so a
906 + // token going stale between the two changes the message and not the outcome.
907 + let existing = db::synckit::get_invitation_by_token(&db, &token_hash)
908 + .await?
909 + .ok_or(AppError::NotFound)?;
910 +
911 + if db::synckit::is_group_member(&db, existing.group_id, sync_user.user_id).await? {
912 + return Err(AppError::Conflict(
913 + "You are already a member of that group".to_string(),
914 + ));
915 + }
916 +
917 + let accepted =
918 + db::synckit::accept_invitation(&db, &token_hash, sync_user.user_id, &req.invitee_pubkey)
919 + .await?;
920 +
921 + match accepted {
922 + Some(invitation) => {
923 + tracing::info!(
924 + group_id = %invitation.group_id,
925 + invitation_id = %invitation.id,
926 + "invitation accepted; awaiting admin confirmation"
927 + );
928 + Ok(StatusCode::NO_CONTENT)
929 + }
930 + None => Err(AppError::BadRequest(format!(
931 + "That invite link is {}",
932 + super::invitation_state(&existing)
933 + ))),
934 + }
935 + }
@@ -34,7 +34,10 @@
34 34 CsrfRouter, delete_csrf, delete_csrf_skip, patch_csrf, post_csrf, post_csrf_skip, put_csrf,
35 35 put_csrf_skip,
36 36 },
37 - db::{self, SyncAppId, SyncDeviceId, SyncGroupId, SyncOperation, SyncPlatform, UserId},
37 + db::{
38 + self, SyncAppId, SyncDeviceId, SyncGroupId, SyncGroupInvitationId, SyncOperation,
39 + SyncPlatform, UserId,
40 + },
38 41 };
39 42
40 43 /// Reason strings for synckit CSRF Skip routes. The auth_routes and
@@ -292,6 +295,135 @@
292 295 pub role: Option<String>,
293 296 }
294 297
298 + /// Issue an invite link, from `POST /groups/{id}/invitations`.
299 + #[derive(Deserialize, utoipa::ToSchema)]
300 + pub(crate) struct CreateInvitationRequest {
301 + /// How long the link stays redeemable, in hours. Clamped server-side; an
302 + /// omitted value takes the default. An unredeemed invitation always expires,
303 + /// so there is no "never" to ask for.
304 + #[serde(default)]
305 + pub expires_in_hours: Option<i64>,
306 + }
307 +
308 + /// A freshly issued invitation. The token appears here and nowhere else: the
309 + /// server keeps only its hash, so this response is the single opportunity to
310 + /// capture it.
311 + #[derive(Serialize, utoipa::ToSchema)]
312 + pub(crate) struct CreateInvitationResponse {
313 + #[schema(value_type = String)]
314 + pub id: SyncGroupInvitationId,
315 + /// The one-use token, to be carried in the link the admin sends.
316 + pub token: String,
317 + #[schema(value_type = String)]
318 + pub expires_at: DateTime<Utc>,
319 + }
320 +
321 + /// Accept an invitation, from `POST /sync/invitations/accept`.
322 + ///
323 + /// Not nested under the group: the invitee is not a member yet and cannot be
324 + /// asked to know a group id they have no access to. The token names the group.
325 + #[derive(Deserialize, utoipa::ToSchema)]
326 + pub(crate) struct AcceptInvitationRequest {
327 + /// The one-use token from the link.
328 + pub token: String,
329 + /// The accepting user's identity public key (base64). What the admin will
330 + /// seal the group key to, once they have confirmed its fingerprint.
331 + pub invitee_pubkey: String,
332 + }
333 +
334 + /// What an invitee is shown before accepting, from
335 + /// `GET /sync/invitations/{token}`.
336 + ///
337 + /// Deliberately thin. It answers "which group, from whom, is this still good"
338 + /// and nothing else, because it is readable by anyone holding the link.
339 + #[derive(Serialize, utoipa::ToSchema)]
340 + pub(crate) struct InvitationPreviewResponse {
341 + pub group_name: String,
342 + /// The inviting admin's email, so the invitee can tell whether the link came
343 + /// from who they think it did.
344 + pub inviter_email: String,
345 + /// Whether the token can still be accepted. False covers expired, revoked,
346 + /// redeemed, and already-accepted alike; the reason is in `state`.
347 + pub redeemable: bool,
348 + /// `pending` | `accepted` | `redeemed` | `revoked` | `expired`.
349 + pub state: String,
350 + #[schema(value_type = String)]
351 + pub expires_at: DateTime<Utc>,
352 + }
353 +
354 + /// Confirm an accepted invitation, from
355 + /// `POST /groups/{id}/invitations/{invitation_id}/confirm`.
356 + ///
357 + /// No public key here on purpose. The grant is sealed to the key recorded on the
358 + /// invitation, so the key the admin confirmed is the key that gets used; letting
359 + /// the caller re-supply one would reintroduce the substitution the confirmation
360 + /// step exists to catch.
361 + #[derive(Deserialize, utoipa::ToSchema)]
362 + pub(crate) struct ConfirmInvitationRequest {
363 + /// The group's current GCK sealed to the invitee's recorded public key
364 + /// (base64). Opaque to the server.
365 + pub sealed_gck: String,
366 + /// Optional role: "member" (default) or "admin".
367 + #[serde(default)]
368 + pub role: Option<String>,
369 + }
370 +
371 + /// One invitation in the admin's list, from `GET /groups/{id}/invitations`.
372 + ///
373 + /// Carries the invitee's public key so the admin's client can render its
374 + /// fingerprint for the out-of-band check. The token is absent: the server does
375 + /// not have it.
376 + #[derive(Serialize, utoipa::ToSchema)]
377 + pub(crate) struct InvitationResponse {
378 + #[schema(value_type = String)]
379 + pub id: SyncGroupInvitationId,
380 + /// `pending` | `accepted` | `redeemed` | `revoked` | `expired`.
381 + pub state: String,
382 + /// The accepting account's email, or `None` while outstanding.
383 + pub invitee_email: Option<String>,
384 + /// The accepting account's identity public key (base64), or `None` while
385 + /// outstanding. The admin seals the GCK to this after confirming it.
386 + pub invitee_pubkey: Option<String>,
387 + #[schema(value_type = String)]
388 + pub expires_at: DateTime<Utc>,
389 + #[schema(value_type = String)]
390 + pub created_at: DateTime<Utc>,
391 + }
392 +
393 + /// The lifecycle state of an invitation as one word.
394 + ///
395 + /// Expiry is derived rather than stored as a state, so a row does not need
396 + /// touching when its deadline passes. Order matters: a redeemed or revoked
397 + /// invitation reports as such even after its expiry, because what happened to it
398 + /// is more informative than the clock running out afterwards.
399 + pub(crate) fn invitation_state(inv: &db::DbSyncGroupInvitation) -> &'static str {
400 + if inv.redeemed_at.is_some() {
401 + "redeemed"
402 + } else if inv.revoked_at.is_some() {
403 + "revoked"
404 + } else if inv.accepted_at.is_some() {
405 + "accepted"
406 + } else if inv.expires_at <= Utc::now() {
407 + "expired"
408 + } else {
409 + "pending"
410 + }
411 + }
412 +
413 + impl From<db::DbSyncGroupInvitation> for InvitationResponse {
414 + fn from(inv: db::DbSyncGroupInvitation) -> Self {
415 + let state = invitation_state(&inv);
416 + Self {
417 + id: inv.id,
418 + state: state.to_string(),
419 + invitee_email: inv.invitee_email,
420 + invitee_pubkey: inv.invitee_pubkey,
421 + expires_at: inv.expires_at,
422 + created_at: inv.created_at,
423 + }
424 + }
425 + }
426 +
295 427 /// One member's identity public key, from `GET /groups/{id}/pubkeys`. The admin
296 428 /// re-seals a rotated GCK to each of these.
297 429 #[derive(Serialize, utoipa::ToSchema)]
@@ -915,6 +1047,57 @@
915 1047 "/api/v1/sync/groups/{id}/members/{user_id}",
916 1048 delete_csrf_skip(SYNCKIT_JWT_SKIP, groups::remove_member),
917 1049 )
1050 + // Invitations. The two accept-side routes are not nested under the group:
1051 + // the caller is not a member yet and cannot be asked for a group id they
1052 + // have no access to, so the token names the group instead.
1053 + .route(
1054 + "/api/sync/groups/{id}/invitations",
1055 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::create_invitation),
1056 + )
1057 + .route(
1058 + "/api/v1/sync/groups/{id}/invitations",
1059 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::create_invitation),
1060 + )
1061 + .route_get(
1062 + "/api/sync/groups/{id}/invitations",
1063 + get(groups::list_invitations),
1064 + )
1065 + .route_get(
1066 + "/api/v1/sync/groups/{id}/invitations",
1067 + get(groups::list_invitations),
1068 + )
1069 + .route(
1070 + "/api/sync/groups/{id}/invitations/{invitation_id}/confirm",
1071 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::confirm_invitation),
1072 + )
1073 + .route(
1074 + "/api/v1/sync/groups/{id}/invitations/{invitation_id}/confirm",
1075 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::confirm_invitation),
1076 + )
1077 + .route(
1078 + "/api/sync/groups/{id}/invitations/{invitation_id}",
1079 + delete_csrf_skip(SYNCKIT_JWT_SKIP, groups::revoke_invitation),
1080 + )
1081 + .route(
1082 + "/api/v1/sync/groups/{id}/invitations/{invitation_id}",
1083 + delete_csrf_skip(SYNCKIT_JWT_SKIP, groups::revoke_invitation),
1084 + )
1085 + .route_get(
1086 + "/api/sync/invitations/{token}",
1087 + get(groups::preview_invitation),
1088 + )
1089 + .route_get(
1090 + "/api/v1/sync/invitations/{token}",
1091 + get(groups::preview_invitation),
1092 + )
1093 + .route(
1094 + "/api/sync/invitations/accept",
1095 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::accept_invitation),
1096 + )
1097 + .route(
1098 + "/api/v1/sync/invitations/accept",
1099 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::accept_invitation),
1100 + )
918 1101 .route_get("/api/sync/groups/{id}/grant", get(groups::get_grant))
919 1102 .route_get("/api/v1/sync/groups/{id}/grant", get(groups::get_grant))
920 1103 .route_get("/api/sync/groups/{id}/pubkeys", get(groups::list_pubkeys))
@@ -1,0 +1,65 @@
1 + -- SyncKit Groups: invite links, so onboarding a member is one link instead of a
2 + -- two-channel out-of-band exchange.
3 + --
4 + -- Adding a member needs their account email AND their pasted identity public key
5 + -- (`POST /groups/{id}/members`). That is two channels before anything works, and
6 + -- it is the first thing a group's second user hits. An invitation carries a
7 + -- one-use token: the invitee posts their public key against it instead of the
8 + -- admin collecting the key by hand.
9 + --
10 + -- The server is not a party to the key exchange, and this table is what keeps it
11 + -- that way. It records two public keys and a pairing, which is exactly what
12 + -- `add_member` already teaches the server. The Group Content Key never appears
13 + -- here; the grant is still sealed client-side by the admin, after the admin
14 + -- confirms the invitee's key fingerprint. Possession of a link is therefore an
15 + -- invitation, not membership.
16 + --
17 + -- Design: wiki synckit-groups-design, synckit-roadmap ("content always encrypted,
18 + -- organization is the product": an invitation is coordination metadata, reachable
19 + -- only through named endpoints, never as a general store).
20 +
21 + CREATE TABLE IF NOT EXISTS sync_group_invitations (
22 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
23 + group_id UUID NOT NULL REFERENCES sync_groups(id) ON DELETE CASCADE,
24 +
25 + -- SHA-256 of the invite token, hex. The token itself exists only in the link
26 + -- the admin sends, so a read of this table hands out no live invitations and
27 + -- a leaked backup cannot be redeemed. Unique: the lookup on accept is by
28 + -- hash, and two invitations sharing a token would be ambiguous.
29 + token_hash TEXT NOT NULL UNIQUE,
30 +
31 + inviter_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
32 +
33 + -- Both set together when the invitee accepts; NULL while the invitation is
34 + -- outstanding, because until then it names nobody. The public key is the
35 + -- whole point of the round trip: it is what the admin seals the GCK to.
36 + invitee_user_id UUID REFERENCES users(id) ON DELETE CASCADE,
37 + invitee_pubkey TEXT,
38 + accepted_at TIMESTAMPTZ,
39 +
40 + -- Set when the admin has confirmed the fingerprint and sealed the grant.
41 + -- Terminal: the token opens nothing afterwards. Redemption is separate from
42 + -- acceptance precisely so the admin's confirmation is a real gate rather than
43 + -- a formality applied after the fact.
44 + redeemed_at TIMESTAMPTZ,
45 +
46 + -- An admin cancelling an invitation they no longer want outstanding.
47 + revoked_at TIMESTAMPTZ,
48 +
49 + -- An unredeemed invitation must stop being redeemable on its own. A token
50 + -- that lives forever is a standing credential nobody remembers issuing.
51 + expires_at TIMESTAMPTZ NOT NULL,
52 +
53 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
54 + );
55 +
56 + -- The admin's pending-invitations panel: newest first, per group.
57 + CREATE INDEX IF NOT EXISTS idx_sync_group_invitations_group
58 + ON sync_group_invitations (group_id, created_at DESC);
59 +
60 + -- One outstanding invitation per (group, invitee). Partial, so it constrains only
61 + -- live acceptances: an invitee who was invited, accepted, and was then removed can
62 + -- be invited again, and revoked or redeemed rows never block a fresh invite.
63 + CREATE UNIQUE INDEX IF NOT EXISTS idx_sync_group_invitations_pending_invitee
64 + ON sync_group_invitations (group_id, invitee_user_id)
65 + WHERE invitee_user_id IS NOT NULL AND redeemed_at IS NULL AND revoked_at IS NULL;
@@ -1,0 +1,215 @@
1 + //! SyncKit group invitations: the one-link onboarding path into a group.
2 + //!
3 + //! Adding a member directly (`add_or_update_member`) needs the admin to already
4 + //! hold the member's account email and their pasted identity public key. An
5 + //! invitation removes that exchange: the admin issues a token, the invitee posts
6 + //! their public key against it, and the admin seals the grant after confirming
7 + //! the key's fingerprint.
8 + //!
9 + //! Three properties this module is responsible for:
10 + //!
11 + //! 1. **The token is never stored.** Every lookup is by SHA-256 of the token, so
12 + //! a read of the table (or of a backup) yields nothing redeemable.
13 + //! 2. **Acceptance is one-shot.** [`accept_invitation`] is a single conditional
14 + //! UPDATE, so two clients racing the same token produce one winner and one
15 + //! `None` rather than two members.
16 + //! 3. **Acceptance is not membership.** Accepting records a public key and
17 + //! nothing else. Only the admin's later seal (via the normal add-member path,
18 + //! followed by [`redeem_invitation`]) grants access.
19 + //!
20 + //! Design: wiki synckit-groups-design.
21 +
22 + use chrono::{DateTime, Utc};
23 + use sqlx::PgPool;
24 +
25 + use crate::db::models::DbSyncGroupInvitation;
26 + use crate::db::{SyncGroupId, SyncGroupInvitationId, UserId};
27 + use crate::error::Result;
28 +
29 + /// Every column the model needs, with the invitee's email joined in. The join is
30 + /// LEFT because an outstanding invitation names nobody yet.
31 + const SELECT_INVITATION: &str = r"
32 + SELECT i.id, i.group_id, i.inviter_user_id, i.invitee_user_id,
33 + u.email AS invitee_email, i.invitee_pubkey,
34 + i.accepted_at, i.redeemed_at, i.revoked_at, i.expires_at, i.created_at
35 + FROM sync_group_invitations i
36 + LEFT JOIN users u ON u.id = i.invitee_user_id
37 + ";
38 +
39 + /// Issue an invitation. The caller generates the token, hashes it, and keeps the
40 + /// plaintext for the link; only the hash arrives here.
41 + #[tracing::instrument(skip_all)]
42 + pub async fn create_invitation(
43 + pool: &PgPool,
44 + group_id: SyncGroupId,
45 + inviter_user_id: UserId,
46 + token_hash: &str,
47 + expires_at: DateTime<Utc>,
48 + ) -> Result<DbSyncGroupInvitation> {
49 + let id: SyncGroupInvitationId = sqlx::query_scalar(
50 + r"
51 + INSERT INTO sync_group_invitations
52 + (group_id, inviter_user_id, token_hash, expires_at)
53 + VALUES ($1, $2, $3, $4)
54 + RETURNING id
55 + ",
56 + )
57 + .bind(group_id)
58 + .bind(inviter_user_id)
59 + .bind(token_hash)
60 + .bind(expires_at)
61 + .fetch_one(pool)
62 + .await?;
63 +
64 + // Re-read through the shared projection rather than RETURNING the columns
65 + // directly: the model carries a joined email, and a second shape of this
66 + // query is a second thing to keep in step.
67 + get_invitation(pool, id)
68 + .await?
69 + .ok_or_else(|| sqlx::Error::RowNotFound.into())
70 + }
71 +
72 + /// Fetch one invitation by id.
73 + #[tracing::instrument(skip_all)]
74 + pub async fn get_invitation(
75 + pool: &PgPool,
76 + id: SyncGroupInvitationId,
77 + ) -> Result<Option<DbSyncGroupInvitation>> {
78 + let sql = format!("{SELECT_INVITATION} WHERE i.id = $1");
79 + let invitation = sqlx::query_as::<_, DbSyncGroupInvitation>(&sql)
80 + .bind(id)
81 + .fetch_optional(pool)
82 + .await?;
83 + Ok(invitation)
84 + }
85 +
86 + /// Fetch one invitation by the SHA-256 of its token, whatever state it is in.
87 + ///
88 + /// Used to show the invitee what they are about to join before they accept.
89 + /// Deliberately returns expired, revoked and redeemed rows too, so the caller can
90 + /// tell the invitee *why* a link does not work rather than answering "no such
91 + /// invitation" to a link that plainly exists.
92 + #[tracing::instrument(skip_all)]
93 + pub async fn get_invitation_by_token(
94 + pool: &PgPool,
95 + token_hash: &str,
96 + ) -> Result<Option<DbSyncGroupInvitation>> {
97 + let sql = format!("{SELECT_INVITATION} WHERE i.token_hash = $1");
98 + let invitation = sqlx::query_as::<_, DbSyncGroupInvitation>(&sql)
99 + .bind(token_hash)
100 + .fetch_optional(pool)
101 + .await?;
102 + Ok(invitation)
103 + }
104 +
105 + /// Record the invitee's acceptance against a token: their account and their
106 + /// identity public key.
107 + ///
108 + /// One statement, and the predicate carries every liveness condition, so
109 + /// consuming a token is atomic. Two devices redeeming the same link concurrently
110 + /// yield one `Some` and one `None`; a token that is expired, revoked, redeemed,
111 + /// or already accepted yields `None` without the caller needing to have read the
112 + /// row first. Callers distinguish "no such token" from "not live" with
113 + /// [`get_invitation_by_token`].
114 + ///
115 + /// Acceptance grants nothing. It records the key the admin will seal to.
116 + #[tracing::instrument(skip_all)]
117 + pub async fn accept_invitation(
118 + pool: &PgPool,
119 + token_hash: &str,
120 + invitee_user_id: UserId,
121 + invitee_pubkey: &str,
122 + ) -> Result<Option<DbSyncGroupInvitation>> {
123 + let id: Option<SyncGroupInvitationId> = sqlx::query_scalar(
124 + r"
125 + UPDATE sync_group_invitations
126 + SET invitee_user_id = $2,
127 + invitee_pubkey = $3,
128 + accepted_at = NOW()
129 + WHERE token_hash = $1
130 + AND accepted_at IS NULL
131 + AND redeemed_at IS NULL
132 + AND revoked_at IS NULL
133 + AND expires_at > NOW()
134 + RETURNING id
135 + ",
136 + )
137 + .bind(token_hash)
138 + .bind(invitee_user_id)
139 + .bind(invitee_pubkey)
140 + .fetch_optional(pool)
141 + .await?;
142 +
143 + match id {
144 + Some(id) => get_invitation(pool, id).await,
145 + None => Ok(None),
146 + }
147 + }
148 +
149 + /// List a group's invitations, newest first. The admin's pending panel.
150 + #[tracing::instrument(skip_all)]
151 + pub async fn list_invitations(
152 + pool: &PgPool,
153 + group_id: SyncGroupId,
154 + ) -> Result<Vec<DbSyncGroupInvitation>> {
155 + let sql = format!("{SELECT_INVITATION} WHERE i.group_id = $1 ORDER BY i.created_at DESC");
156 + let invitations = sqlx::query_as::<_, DbSyncGroupInvitation>(&sql)
157 + .bind(group_id)
158 + .fetch_all(pool)
159 + .await?;
160 + Ok(invitations)
161 + }
162 +
163 + /// Mark an accepted invitation as redeemed, once the admin has sealed the grant.
164 + /// Returns `false` if it was already redeemed or revoked, which is how a
165 + /// double-confirm is caught.
166 + ///
167 + /// The `group_id` is part of the predicate so an id from another group cannot be
168 + /// closed by an admin who does not own it.
169 + #[tracing::instrument(skip_all)]
170 + pub async fn redeem_invitation(
171 + pool: &PgPool,
172 + group_id: SyncGroupId,
173 + id: SyncGroupInvitationId,
174 + ) -> Result<bool> {
175 + let result = sqlx::query(
176 + r"
177 + UPDATE sync_group_invitations
178 + SET redeemed_at = NOW()
179 + WHERE id = $1 AND group_id = $2
180 + AND accepted_at IS NOT NULL
181 + AND redeemed_at IS NULL
182 + AND revoked_at IS NULL
183 + ",
184 + )
185 + .bind(id)
186 + .bind(group_id)
187 + .execute(pool)
188 + .await?;
189 + Ok(result.rows_affected() > 0)
190 + }
191 +
192 + /// Cancel an invitation. Works whether or not it has been accepted: an admin who
193 + /// does not recognise the fingerprint needs to be able to throw it away, and that
194 + /// is the case that matters most.
195 + #[tracing::instrument(skip_all)]
196 + pub async fn revoke_invitation(
197 + pool: &PgPool,
198 + group_id: SyncGroupId,
199 + id: SyncGroupInvitationId,
200 + ) -> Result<bool> {
201 + let result = sqlx::query(
202 + r"
203 + UPDATE sync_group_invitations
204 + SET revoked_at = NOW()
205 + WHERE id = $1 AND group_id = $2
206 + AND redeemed_at IS NULL
207 + AND revoked_at IS NULL
208 + ",
209 + )
210 + .bind(id)
211 + .bind(group_id)
212 + .execute(pool)
213 + .await?;
214 + Ok(result.rows_affected() > 0)
215 + }