Skip to main content

max / goingson

Describe the invitation flow in Settings > Sharing Five new queue kinds (create_invite, revoke_invite, confirm_invite, preview_invite, accept_invite) plus a directory read cover the six group invitation commands, so the whole flow is describable. The token an admin issues is never a queue column: the server keeps only a hash, so what create_invitation returns is the only copy and a queue row is swept an hour after it lands. The drainer writes it straight into sync_invitations. A code the user pasted is on the row, because nothing here is the authority on it. A queued confirm carries the group and the invitation and no fingerprint, so the drainer re-reads the server's answer rather than acting on a copy taken a minute ago. The screen still names the fingerprint on the row, resolved from the directory as it draws. Accepted invitations sit above the group list, across every administered group: it is the security-bearing step and the only one where somebody is waiting. Pending ones show their code; every other state shows none. The confirmations section states the group directory's freshness rather than the invitation list's, and says which one it is. synckit exposes no reader for sync_invitations.refreshed_at; filed as synckit-client 0e8af5b2. Migration 071 adds invitation_id, invite_token and expires_in_hours. group_admin_queue is already in EXCLUDED_TABLES and absent from the manifest, so the storage version does not move.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-24 20:50 UTC
Commit: fb72b458aaf2d5fe4410913ffa2731ea540d3f97
Parent: 292877c
7 files changed, +1132 insertions, -35 deletions
@@ -71,3 +71,4 @@
71 71 068 ccdddaa4176f36169bb90b1eec191ae776a87ee1cd56ba2bd789f905fb212bcd2b04b884e820498fab8b6d03fb7e5c6d
72 72 069 afc5492dfbda7dde625f1ca00e06e8ae0c26ad10e3a26399aec7bf0fee66d4beaabb0e4b00ee8fab1ddb695790194c46
73 73 070 b95a1c7f6c7c3103de2d952f9571b8d284b5fbc1679c70757e1a322023f2a4d6f0c213dd0e8b95136483d931b290e896
74 + 071 acbd80a6ade97c1120ecaf4ab7152f5045ccc7f91f6565754fff89dd4afea6de7e11af1391c73a3a7df5e900ff60612e
@@ -65,7 +65,11 @@
65 65 const CHECK_INTERVAL_SECS: u64 = 60;
66 66
67 67 /// One queued admin write.
68 - #[derive(Debug, Clone)]
68 + ///
69 + /// [`Default`] rather than eight literal `None`s at every call site: there are
70 + /// eight kinds now and no kind reads more than three of the payload columns, so
71 + /// a construction that names only what it uses says which those are.
72 + #[derive(Debug, Clone, Default)]
69 73 pub struct QueuedOp {
70 74 pub id: String,
71 75 pub kind: String,
@@ -74,6 +78,15 @@
74 78 pub email: Option<String>,
75 79 pub pubkey: Option<String>,
76 80 pub member_user_id: Option<String>,
81 + /// The invitation `confirm_invite` and `revoke_invite` address.
82 + pub invitation_id: Option<String>,
83 + /// A code the user pasted, for `preview_invite` and `accept_invite`.
84 + ///
85 + /// Never an *issued* token: that one is returned once and is written
86 + /// straight into the directory by the drainer. See migration 071.
87 + pub invite_token: Option<String>,
88 + /// How long an issued invitation should stand, for `create_invite`.
89 + pub expires_in_hours: Option<i64>,
77 90 pub attempts: i32,
78 91 pub last_error: Option<String>,
79 92 pub done_at: Option<String>,
@@ -88,6 +101,23 @@
88 101 /// apart.
89 102 #[must_use]
90 103 pub fn describe(&self) -> String {
104 + self.describe_confirming(None)
105 + }
106 +
107 + /// The same, with the fingerprint a queued confirm is authorizing.
108 + ///
109 + /// A confirm carries a group and an invitation id and deliberately carries
110 + /// no fingerprint, because the drainer must re-read the server's answer
111 + /// rather than act on a copy (migration 071). But "Confirm an invitation"
112 + /// authorizes nothing legible: the fingerprint is the whole content of the
113 + /// decision, and a queue row that does not name it asks the reader to take
114 + /// the pending act on trust for the minute it sits there.
115 + ///
116 + /// So the screen resolves it from the directory as it draws and passes it
117 + /// in. That is a display of the current answer rather than a second copy of
118 + /// it, which is why it lives in the argument and not in the row.
119 + #[must_use]
120 + pub fn describe_confirming(&self, fingerprint: Option<&str>) -> String {
91 121 match self.kind.as_str() {
92 122 "create_group" => format!(
93 123 "Create the group {}",
@@ -98,6 +128,18 @@
98 128 self.email.as_deref().unwrap_or("(no address)")
99 129 ),
100 130 "remove_member" => "Remove a member from a group".to_owned(),
131 + "create_invite" => "Issue an invite code".to_owned(),
132 + "revoke_invite" => "Cancel an invitation".to_owned(),
133 + "confirm_invite" => fingerprint.map_or_else(
134 + // The invitation has left `accepted` since it was queued, or
135 + // the directory has not caught up. Both are honest reasons not
136 + // to be able to name the fingerprint, and neither is a reason
137 + // to name a stale one.
138 + || "Admit somebody, once their fingerprint is checked".to_owned(),
139 + |fingerprint| format!("Admit the holder of {fingerprint}"),
140 + ),
141 + "preview_invite" => "Read what an invite code leads to".to_owned(),
142 + "accept_invite" => "Accept an invite code".to_owned(),
101 143 // A kind this build does not know, held rather than refused. It can
102 144 // only come from a newer build that queued it, and saying so is
103 145 // better than drawing a blank row.
@@ -111,8 +153,9 @@
111 153 let conn = state.db.conn().map_err(|e| e.to_string())?;
112 154 conn.execute(
113 155 "INSERT INTO group_admin_queue \
114 - (id, user_id, kind, group_id, name, email, pubkey, member_user_id) \
115 - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
156 + (id, user_id, kind, group_id, name, email, pubkey, member_user_id, \
157 + invitation_id, invite_token, expires_in_hours) \
158 + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
116 159 rusqlite::params![
117 160 op.id,
118 161 DESKTOP_USER_ID.to_string(),
@@ -122,6 +165,9 @@
122 165 op.email,
123 166 op.pubkey,
124 167 op.member_user_id,
168 + op.invitation_id,
169 + op.invite_token,
170 + op.expires_in_hours,
125 171 ],
126 172 )
127 173 .map_err(|e| e.to_string())?;
@@ -139,6 +185,7 @@
139 185 let mut stmt = conn
140 186 .prepare(
141 187 "SELECT id, kind, group_id, name, email, pubkey, member_user_id, \
188 + invitation_id, invite_token, expires_in_hours, \
142 189 attempts, last_error, done_at \
143 190 FROM group_admin_queue WHERE user_id = ?1 ORDER BY queued_at",
144 191 )
@@ -153,9 +200,12 @@
153 200 email: row.get(4)?,
154 201 pubkey: row.get(5)?,
155 202 member_user_id: row.get(6)?,
156 - attempts: row.get(7)?,
157 - last_error: row.get(8)?,
158 - done_at: row.get(9)?,
203 + invitation_id: row.get(7)?,
204 + invite_token: row.get(8)?,
205 + expires_in_hours: row.get(9)?,
206 + attempts: row.get(10)?,
207 + last_error: row.get(11)?,
208 + done_at: row.get(12)?,
159 209 })
160 210 })
161 211 .map_err(|e| e.to_string())?
@@ -310,6 +360,23 @@
310 360 .map(synckit_client::GroupId::new)
311 361 .map_err(|_| "The queued action names a group id that will not parse.".to_owned())
312 362 };
363 + let invitation = |raw: &Option<String>| -> Result<synckit_client::InvitationId, String> {
364 + raw.as_deref()
365 + .ok_or_else(|| "No invitation on the queued action.".to_owned())?
366 + .parse::<uuid::Uuid>()
367 + .map(synckit_client::InvitationId::new)
368 + .map_err(|_| "The queued action names an invitation id that will not parse.".to_owned())
369 + };
370 + // Already normalised by the handler that queued it, deliberately: there is
371 + // one accepted spelling of a pasted code and `normalize_invite_token` owns
372 + // it. Re-normalising here would be a second place for the format to live.
373 + //
374 + // A function rather than a closure beside the two above, because it borrows
375 + // out of its argument and a closure cannot state that the returned `&str`
376 + // outlives the call.
377 + fn token(raw: Option<&str>) -> Result<&str, String> {
378 + raw.ok_or_else(|| "No invite code on the queued action.".to_owned())
379 + }
313 380
314 381 match op.kind.as_str() {
315 382 "create_group" => {
@@ -364,6 +431,80 @@
364 431 .await
365 432 .map_err(|e| e.to_string())
366 433 }
434 + "create_invite" => {
435 + let group = group(&op.group_id)?;
436 + let invitation = client
437 + .create_invitation(group, op.expires_in_hours)
438 + .await
439 + .map_err(|e| e.to_string())?;
440 + // Not a nicety, unlike `create_group`'s `add_group` above. The
441 + // server keeps only a hash of the token, so what came back is the
442 + // only copy that will ever exist and this is the one moment it can
443 + // be written down. A failure here has issued an invitation whose
444 + // code nobody holds, which is why it is reported as a failure of
445 + // the write rather than logged past.
446 + let conn = conn.ok_or_else(|| {
447 + "The invite was issued and could not be written down: no database \
448 + connection. Revoke it from the group and issue another."
449 + .to_owned()
450 + })?;
451 + synckit_client::store::directory::record_issued(conn, group, &invitation).map_err(
452 + |error| {
453 + format!(
454 + "The invite was issued and could not be written down ({error}). \
455 + Revoke it from the group and issue another."
456 + )
457 + },
458 + )
459 + }
460 + "revoke_invite" => client
461 + .revoke_invitation(group(&op.group_id)?, invitation(&op.invitation_id)?)
462 + .await
463 + .map_err(|e| e.to_string()),
464 + // No fingerprint is passed, and none is stored. The queue row names the
465 + // invitation and the server answers with the key it currently holds; a
466 + // fingerprint copied at queue time would be authorizing a value nobody
467 + // re-checked.
468 + "confirm_invite" => client
469 + .confirm_invitation(group(&op.group_id)?, invitation(&op.invitation_id)?, None)
470 + .await
471 + .map_err(|e| e.to_string()),
472 + "preview_invite" => {
473 + let token = token(op.invite_token.as_deref())?;
474 + let preview = client
475 + .preview_invitation(token)
476 + .await
477 + .map_err(|e| e.to_string())?;
478 + let conn = conn.ok_or_else(|| {
479 + "Read the code and could not write down the answer: no database connection."
480 + .to_owned()
481 + })?;
482 + let known = synckit_client::store::directory::KnownPreview {
483 + token: token.to_owned(),
484 + group_name: preview.group_name,
485 + inviter_email: preview.inviter_email,
486 + redeemable: preview.redeemable,
487 + state: preview.state,
488 + expires_at: preview.expires_at.to_rfc3339(),
489 + };
490 + synckit_client::store::directory::write_preview(conn, &known)
491 + .map_err(|error| format!("Could not write down what the code leads to: {error}"))
492 + }
493 + "accept_invite" => {
494 + client
495 + .accept_invitation(token(op.invite_token.as_deref())?)
496 + .await
497 + .map_err(|e| e.to_string())?;
498 + // The code has done its whole job. Clearing it is what takes the
499 + // preview section off the screen, so a failure here would leave an
500 + // Accept control offering an act that already happened.
501 + if let Some(conn) = conn
502 + && let Err(error) = synckit_client::store::directory::clear_preview(conn)
503 + {
504 + error!("Group queue: could not forget the accepted code: {error}");
505 + }
506 + Ok(())
507 + }
367 508 // Held rather than refused, and the message says why so it does not read
368 509 // as a bug. Only a newer build could have written it.
369 510 other => Err(format!(
@@ -390,9 +531,7 @@
390 531 email: Some("them@localhost".to_owned()),
391 532 pubkey: Some("k".to_owned()),
392 533 member_user_id: Some("00000000-0000-0000-0000-000000000002".to_owned()),
393 - attempts: 0,
394 - last_error: None,
395 - done_at: None,
534 + ..Default::default()
396 535 }
397 536 }
398 537
@@ -388,7 +388,11 @@
388 388 /// Today an invite travels as a bare code. Accepting a URL form too costs one
389 389 /// line and means the codes issued now keep working if a landing page or a
390 390 /// deep link is added later, without a second format to support.
391 - fn normalize_invite_token(input: &str) -> String {
391 + ///
392 + /// Shared with the described Sharing pane (`quasi::settings::sharing`) rather
393 + /// than copied: two accepted spellings of one code is how a paste that works in
394 + /// one place stops working in the other.
395 + pub(crate) fn normalize_invite_token(input: &str) -> String {
392 396 let trimmed = input.trim();
393 397 let after_scheme = trimmed
394 398 .rsplit_once("/invite/")
@@ -647,6 +647,42 @@
647 647 sharing_pane(state, said)
648 648 }
649 649
650 + /// Queue a fresh invite code.
651 + fn queue_invite(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
652 + let said = sharing::create_invite(state, &request)?;
653 + sharing_pane(state, said)
654 + }
655 +
656 + /// Queue a revoke, from the group's list or from the confirmations section.
657 + fn queue_revoke(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
658 + let said = sharing::revoke_invite(state, &request)?;
659 + sharing_pane(state, said)
660 + }
661 +
662 + /// Queue a confirm: admit the holder of the key on this invitation.
663 + fn queue_confirm(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
664 + let said = sharing::confirm_invite(state, &request)?;
665 + sharing_pane(state, said)
666 + }
667 +
668 + /// Queue a read of what a pasted code leads to.
669 + fn queue_preview(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
670 + let said = sharing::preview_invite(state, &request)?;
671 + sharing_pane(state, said)
672 + }
673 +
674 + /// Queue an accept of the code this device is holding an answer about.
675 + fn queue_accept(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
676 + let said = sharing::accept_invite(state)?;
677 + sharing_pane(state, said)
678 + }
679 +
680 + /// Forget the previewed code. Local, so nothing is queued.
681 + fn drop_preview(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
682 + let said = sharing::dismiss_preview(state)?;
683 + sharing_pane(state, said)
684 + }
685 +
650 686 /// Turn automatic syncing on or off.
651 687 fn set_sync_auto(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
652 688 let on = request.payload.get(sync::AUTO_SYNC).unwrap_or_default() != "disabled";
@@ -782,6 +818,22 @@
782 818 // `group_queue`'s drainer; see `sharing`.
783 819 .post("/settings/sharing/groups", queue_group)
784 820 .post("/settings/sharing/members", queue_member)
785 - .post("/settings/sharing/queue/{id}/cancel", cancel_queued);
821 + .post("/settings/sharing/queue/{id}/cancel", cancel_queued)
822 + // Unlike `email` and `about` above, these need no ordering care: the
823 + // literals are four segments and the captured pair is six, so nothing
824 + // here can swallow anything else. Grouped for reading, not for
825 + // precedence.
826 + .post("/settings/sharing/invites/preview", queue_preview)
827 + .post("/settings/sharing/invites/accept", queue_accept)
828 + .post("/settings/sharing/invites/dismiss", drop_preview)
829 + .post("/settings/sharing/invites", queue_invite)
830 + .post(
831 + "/settings/sharing/invites/{group_id}/{invitation_id}/revoke",
832 + queue_revoke,
833 + )
834 + .post(
835 + "/settings/sharing/invites/{group_id}/{invitation_id}/confirm",
836 + queue_confirm,
837 + );
786 838 router.get("/settings/{section}", section)
787 839 }
@@ -52,29 +52,84 @@
52 52 //! The queue is shown. A control whose effect is a minute away has to be, or the
53 53 //! section looks like it lost the request.
54 54 //!
55 + //! # The invitation flow, and where its one secret lives
56 + //!
57 + //! Six commands and a state machine rather than one call, and the decision this
58 + //! header used to be waiting on was settled on `34cfd2d1`. Five of the six queue
59 + //! exactly as the three above do; the sixth, listing, is a directory read.
60 + //!
61 + //! **The token is not on the queue row, and that is the load-bearing part.**
62 + //! `create_invitation` returns a code the server keeps only a hash of, so what
63 + //! comes back is the only copy that will ever exist. A queue row is swept an
64 + //! hour after it lands, so putting the code there would destroy it on a timer;
65 + //! the drainer writes it into `sync_invitations` through
66 + //! [`directory::record_issued`] the instant it arrives, and the section reads it
67 + //! from there. A reader who does not know that will re-derive it from the
68 + //! sweep, which is why it is stated here rather than only in migration 071.
69 + //!
70 + //! The mirror of it: a code the *user* pasted is on the queue row
71 + //! (`invite_token`), because nothing on this device is the authority on it and
72 + //! the drainer has to read it a minute later. Two things called a token, one
73 + //! written down here and one never.
74 + //!
75 + //! # Confirming is the security of the flow, so it sits at the top
76 + //!
77 + //! An accepted invitation is a person waiting on a fingerprint comparison, and
78 + //! nothing about them reaches the group until an admin makes it. That is the
79 + //! only step here where somebody is actively blocked and the only one where
80 + //! getting it wrong admits the wrong key, so it is drawn above the group list
81 + //! rather than found by opening each group, and the fingerprint is on the row's
82 + //! face rather than behind a menu.
83 + //!
84 + //! A queued confirm is drawn with the fingerprint too, resolved from the
85 + //! directory as the row is drawn. That is a display of the server's current
86 + //! answer; the queue row itself carries only the invitation's id, so the drainer
87 + //! re-reads rather than acting on a value copied a minute ago.
88 + //!
89 + //! # Two drains, and the section says so
90 + //!
91 + //! Previewing a code and accepting it are separate queued writes, so a paste
92 + //! takes up to two minutes to become a membership request. Collapsing them into
93 + //! one op would remove the step where a person reads what they are joining
94 + //! before they join it, which is the point of a preview. The hint says the wait
95 + //! out loud instead.
96 + //!
55 97 //! # What is still not here
56 98 //!
57 - //! The invitation flow, which is six commands and a state machine rather than
58 - //! one call. `group_create_invite` and its five siblings queue as cleanly as
59 - //! these three; what they need first is a decision about what the section shows
60 - //! for an invitation in flight, and that is not this screen's to make alone.
99 + //! A copy control on a pending code. Nothing in the vocabulary names "copy this
100 + //! value and say so briefly" (quasicoherent `c3e145e0`), so the token is drawn
101 + //! as a field, which is selectable everywhere and is something a host that has a
102 + //! copy affordance can attach it to. That is the same answer, and the same
103 + //! reason, as the identity key below.
61 104
62 105 use quasi_router::screen::{Act, Choice, Field, Row, Tag};
63 106 use quasi_router::{Action, Node, RouteError};
107 + use synckit_client::InvitationState;
64 108 use synckit_client::store::directory;
65 109
110 + use crate::commands::group::normalize_invite_token;
66 111 use crate::state::AppState;
67 112
113 + /// Read a connection out of the pool, or say so in the class that means it.
114 + ///
115 + /// Returned behind [`DerefMut`](std::ops::DerefMut) rather than by its real
116 + /// type, so this module does not have to name r2d2's pooled-connection generic
117 + /// to say "a connection".
118 + fn conn(
119 + app: &AppState,
120 + ) -> Result<impl std::ops::DerefMut<Target = rusqlite::Connection>, RouteError> {
121 + app.db
122 + .conn()
123 + .map_err(|error| RouteError::internal(error.to_string()))
124 + }
125 +
68 126 /// The directory, read through the app's own pool.
69 127 ///
70 128 /// An empty answer is "this device knows of no groups", which is a true
71 129 /// statement about what has reached it rather than a claim that the user belongs
72 130 /// to none. The section says so in those words.
73 131 fn known(app: &AppState) -> Result<(Vec<directory::KnownGroup>, Option<String>), RouteError> {
74 - let conn = app
75 - .db
76 - .conn()
77 - .map_err(|error| RouteError::internal(error.to_string()))?;
132 + let conn = conn(app)?;
78 133 let groups =
79 134 directory::groups(&conn).map_err(|error| RouteError::internal(error.to_string()))?;
80 135 let refreshed =
@@ -114,10 +169,197 @@
114 169 rows.push(member_row);
115 170 }
116 171 }
172 + rows.extend(invitation_rows(app, group)?);
117 173 }
118 174 Ok(rows)
119 175 }
120 176
177 + /// The word for a state, and there is no catch-all.
178 + ///
179 + /// `InvitationState` is `#[non_exhaustive]`, so a variant a newer server knows
180 + /// about arrives here as something this build cannot name. Saying so beats
181 + /// picking the nearest word: every state below decides whether acts are offered,
182 + /// and guessing wrong offers an act on an invitation that cannot take it.
183 + fn state_word(state: InvitationState) -> &'static str {
184 + match state {
185 + InvitationState::Pending => "Waiting to be used",
186 + InvitationState::Accepted => "Waiting on you",
187 + InvitationState::Redeemed => "Used",
188 + InvitationState::Revoked => "Cancelled",
189 + InvitationState::Expired => "Expired",
190 + _ => "In a state this version does not know",
191 + }
192 + }
193 +
194 + /// One administered group's invitations, below its member list.
195 + ///
196 + /// Every read is [`effective_state`](directory::KnownInvitation::effective_state)
197 + /// rather than the stored `state`: the directory refreshes on a cycle and a
198 + /// deadline passes on its own, so between two refreshes the stored value says
199 + /// `Pending` for a code that has stopped working. Offering that as a live code
200 + /// is the one failure this section can produce without anybody doing anything.
201 + fn invitation_rows(app: &AppState, group: &directory::KnownGroup) -> Result<Vec<Row>, RouteError> {
202 + let conn = conn(app)?;
203 + let invitations = directory::invitations(&conn, group.id)
204 + .map_err(|error| RouteError::internal(error.to_string()))?;
205 + drop(conn);
206 +
207 + let mut rows = Vec::new();
208 + for invitation in invitations {
209 + let state = invitation.effective_state();
210 + // Drawn at the top of the pane instead, across every group. An accepted
211 + // invitation is the one state where a person is waiting, and finding it
212 + // by opening each group in turn is how somebody waits a week.
213 + if state == InvitationState::Accepted {
214 + continue;
215 + }
216 +
217 + // These rows share a list with the member rows above them, so the
218 + // primary says what the row *is* and the state goes in a badge, the way
219 + // `Admin` already does on a member. A row led by "Waiting to be used"
220 + // beside three email addresses reads as a fourth person.
221 + let mut row = Row::new("Invite code")
222 + .token(Tag::badge(state_word(state)))
223 + .meta(format!("Issued {}", invitation.created_at));
224 +
225 + // Everything else is terminal, so no act: the directory prunes those
226 + // once the server stops reporting them, and `Redeemed` reads as history
227 + // because by then they are in the member list above.
228 + if state == InvitationState::Pending {
229 + row = row
230 + .secondary(format!("Stops working {}.", invitation.expires_at))
231 + .act(
232 + Act::new(
233 + "Cancel it",
234 + Action::post(format!(
235 + "/settings/sharing/invites/{}/{}/revoke",
236 + group.id, invitation.id
237 + )),
238 + )
239 + .tone(makeover_layout::Tone::Danger),
240 + );
241 + }
242 +
243 + rows.push(row);
244 +
245 + // The token, and only while pending: once somebody has accepted, the
246 + // code has done its whole job and holding it is exposure with no use.
247 + // `write_invitations` drops the column at the same moment, so this is
248 + // the screen agreeing with the store rather than a second rule.
249 + if state == InvitationState::Pending
250 + && let Some(token) = invitation.token
251 + {
252 + rows.push(
253 + Row::new("The code to hand over").part(
254 + makeover_layout::RowPart::Secondary,
255 + Node::field(
256 + Field {
257 + value: Some(token),
258 + ..Field::new(
259 + makeover_layout::FieldKind::Text,
260 + format!("invite_token_{}", invitation.id),
261 + "Invite code",
262 + )
263 + }
264 + .hint("One use. Whoever redeems it still lands in your confirmations."),
265 + ),
266 + ),
267 + );
268 + }
269 + }
270 + Ok(rows)
271 + }
272 +
273 + /// Every invitation waiting on this admin, across every group they administer.
274 + ///
275 + /// Above the group list on purpose. This is the security-bearing step of the
276 + /// whole flow: the invitee has posted a key and nothing about them reaches the
277 + /// group until somebody compares its fingerprint against what they read out over
278 + /// another channel. It is also the only place a person is actively blocked, and
279 + /// it earns the position for the same reason the queue section already sits
280 + /// above `admin_acts`.
281 + ///
282 + /// The fingerprint is on the row's face rather than behind a menu, because it is
283 + /// the thing being decided rather than a detail about the decision.
284 + fn confirmations(app: &AppState) -> Result<Vec<Node>, RouteError> {
285 + let conn = conn(app)?;
286 + let waiting = directory::pending_confirmations(&conn)
287 + .map_err(|error| RouteError::internal(error.to_string()))?;
288 + let refreshed =
289 + directory::refreshed_at(&conn).map_err(|error| RouteError::internal(error.to_string()))?;
290 + drop(conn);
291 +
292 + if waiting.is_empty() {
293 + return Ok(Vec::new());
294 + }
295 +
296 + let rows = waiting.into_iter().map(|invitation| {
297 + let who = invitation
298 + .invitee_email
299 + .clone()
300 + .unwrap_or_else(|| "Somebody".to_owned());
301 + // A key that yielded no fingerprint must never be confirmable: the
302 + // comparison is the whole security of the step, and a row that offers
303 + // Confirm beside "unavailable" invites approving something nobody read.
304 + let Some(fingerprint) = invitation.invitee_fingerprint.clone() else {
305 + return Row::new(who)
306 + .token(Tag::badge("Cannot be checked").tone(makeover_layout::Tone::Danger))
307 + .secondary(
308 + "The key on this invitation does not read as a key, so there is no \
309 + fingerprint to compare. Cancel it from the group and issue another.",
310 + );
311 + };
312 +
313 + Row::new(who)
314 + .secondary(format!(
315 + "Fingerprint {fingerprint}. Ask them to read it out over a channel that is \
316 + not this one, and admit them only if it matches."
317 + ))
318 + .act(Act::new(
319 + "It matches, admit them",
320 + Action::post(format!(
321 + "/settings/sharing/invites/{}/{}/confirm",
322 + invitation.group_id, invitation.id
323 + )),
324 + ))
325 + .act(
326 + Act::new(
327 + "Reject",
328 + Action::post(format!(
329 + "/settings/sharing/invites/{}/{}/revoke",
330 + invitation.group_id, invitation.id
331 + )),
332 + )
333 + .tone(makeover_layout::Tone::Danger),
334 + )
335 + });
336 +
337 + let mut nodes = vec![
338 + Node::section("Waiting on you to admit them"),
339 + Node::list(rows),
340 + ];
341 +
342 + // Stated, and stated as the number it actually is. `directory::refreshed_at`
343 + // is `MIN(refreshed_at)` over `sync_groups`, so it is the group list's
344 + // freshness; `sync_invitations` carries its own `refreshed_at` and synckit
345 + // exposes no reader for it (filed on synckit-client). The two move together
346 + // on a good cycle, because invitations are fetched on the same pass, and
347 + // they come apart on a bad one: `write_invitations` is scoped per group
348 + // precisely so one group's failed fetch does not blank the others.
349 + //
350 + // So the wording says which list the timestamp belongs to rather than
351 + // implying it covers this one. A section whose whole job is a security
352 + // decision should not overstate how current it is.
353 + if let Some(at) = refreshed {
354 + nodes.push(Node::text(format!(
355 + "The group directory was last refreshed {at}. Invitations arrive on the same \
356 + cycle, so somebody who accepted since then is not here yet."
357 + )));
358 + }
359 +
360 + Ok(nodes)
361 + }
362 +
121 363 /// Your identity public key, and the fingerprint to read it out by.
122 364 ///
123 365 /// Both are local: `my_identity_public_key` derives from the master key and
@@ -182,8 +424,25 @@
182 424 return Ok(Vec::new());
183 425 }
184 426
427 + // What a queued confirm is authorizing, read now rather than copied when it
428 + // was queued. Read once for the whole list: a person with three confirms
429 + // queued should not cost three passes over the same table.
430 + let conn = conn(app)?;
431 + let awaiting = directory::pending_confirmations(&conn)
432 + .map_err(|error| RouteError::internal(error.to_string()))?;
433 + drop(conn);
434 + let fingerprint_of = |invitation_id: Option<&str>| -> Option<String> {
435 + let wanted = invitation_id?;
436 + awaiting
437 + .iter()
438 + .find(|invitation| invitation.id.to_string() == wanted)?
439 + .invitee_fingerprint
440 + .clone()
441 + };
442 +
185 443 let rows = queued.into_iter().map(|op| {
186 - let mut row = Row::new(op.describe());
444 + let fingerprint = fingerprint_of(op.invitation_id.as_deref());
445 + let mut row = Row::new(op.describe_confirming(fingerprint.as_deref()));
187 446 row = if op.done_at.is_some() {
188 447 row.token(Tag::badge("Done").tone(makeover_layout::Tone::Success))
189 448 } else if let Some(error) = op.last_error.as_deref() {
@@ -270,15 +529,127 @@
270 529 ],
271 530 });
272 531
532 + // The other way in, and the one that needs nothing from them first. An
533 + // invite code is not a bearer credential for membership: whoever redeems it
534 + // lands in the confirmations section above and gets nothing until their
535 + // fingerprint is checked, which is the same check the manual path makes
536 + // before it is used rather than after.
537 + nodes.push(Node::section("Or hand out an invite code"));
538 + nodes.push(Node::text(
539 + "They paste the code into their own Sharing section. It admits them to nothing \
540 + on its own: it puts them in front of you, with a fingerprint to check.",
541 + ));
542 + nodes.push(Node::Form {
543 + action: Action::post("/settings/sharing/invites"),
544 + submit: "Queue it".to_owned(),
545 + fields: vec![
546 + Field::select(
547 + "group_id",
548 + "Group",
549 + administered
550 + .iter()
551 + .map(|group| Choice::new(group.id.to_string(), &group.name))
552 + .collect(),
553 + )
554 + .required(),
555 + Field {
556 + min: Some("1".to_owned()),
557 + ..Field::new(
558 + makeover_layout::FieldKind::Number,
559 + "expires_in_hours",
560 + "Hours it stays usable",
561 + )
562 + }
563 + .hint("Left blank, the server's own default stands."),
564 + ],
565 + });
566 +
273 567 nodes
274 568 }
275 569
570 + /// The half for somebody who has been handed a code.
571 + ///
572 + /// Not drawn without sync configured, for `admin_acts`' reason: accepting posts
573 + /// this device's identity key, and there is no key until sync is set up, so the
574 + /// control could not act.
575 + ///
576 + /// Two queued writes rather than one, and the hint says so. Previewing is what
577 + /// lets a person read what they are joining before they join it, and folding it
578 + /// into Accept would delete the step rather than speed it up.
579 + fn join(app: &AppState) -> Result<Vec<Node>, RouteError> {
580 + if app.read_recovering().is_none() {
581 + return Ok(Vec::new());
582 + }
583 +
584 + let conn = conn(app)?;
585 + let held =
586 + directory::preview(&conn).map_err(|error| RouteError::internal(error.to_string()))?;
587 + drop(conn);
588 +
589 + let mut nodes = vec![Node::section("Joining a group you were invited to")];
590 +
591 + if let Some(preview) = held {
592 + let state = preview.state;
593 + if preview.redeemable {
594 + nodes.push(Node::list([Row::new(&preview.group_name)
595 + .secondary(format!("Invited by {}.", preview.inviter_email))
596 + .meta(format!("Usable until {}", preview.expires_at))]));
597 + nodes.push(Node::text(
598 + "Accepting sends this device's public key. You are not in the group yet \
599 + at that point: it appears once the group's admin has checked your \
600 + fingerprint against what you read out to them.",
601 + ));
602 + nodes.push(Node::list([Row::new("Accept it")
603 + .act(Act::new(
604 + "Accept",
605 + Action::post("/settings/sharing/invites/accept"),
606 + ))
607 + .act(
608 + Act::new("Dismiss", Action::post("/settings/sharing/invites/dismiss"))
609 + .tone(makeover_layout::Tone::Danger),
610 + )]));
611 + } else {
612 + // `redeemable` is false for every terminal state alike, so which
613 + // one it is has to be read off `state`. "That code has expired" and
614 + // "that code was cancelled" send a person to different places.
615 + nodes.push(Node::empty(format!(
616 + "That code leads to {} and cannot be used: {}.",
617 + preview.group_name,
618 + state_word(state).to_lowercase()
619 + )));
620 + nodes.push(Node::list([Row::new("Nothing to accept").act(Act::new(
621 + "Dismiss",
622 + Action::post("/settings/sharing/invites/dismiss"),
623 + ))]));
624 + }
625 + return Ok(nodes);
626 + }
627 +
628 + nodes.push(Node::Form {
629 + action: Action::post("/settings/sharing/invites/preview"),
630 + submit: "Read it".to_owned(),
631 + fields: vec![
632 + Field::new(makeover_layout::FieldKind::Text, "token", "Invite code")
633 + .required()
634 + .hint(
635 + "Reading the code and accepting it are two queued writes, so it can be \
636 + two minutes before you are asked to accept. Nothing is sent until you do.",
637 + ),
638 + ],
639 + });
640 +
641 + Ok(nodes)
642 + }
643 +
276 644 /// The Sharing pane.
277 645 pub(super) fn pane(app: &AppState) -> Result<Vec<Node>, RouteError> {
278 646 let (groups, refreshed) = known(app)?;
279 647
280 648 let mut nodes = vec![Node::section("Sharing")];
281 649
650 + // Above everything, including the group list. Somebody is waiting.
651 + nodes.extend(confirmations(app)?);
652 +
282 653 if groups.is_empty() {
283 654 nodes.push(Node::empty(
284 655 "This device knows of no groups. A group list arrives with a sync, so \
@@ -299,6 +670,7 @@
299 670
300 671 nodes.extend(queue(app)?);
301 672 nodes.extend(admin_acts(app, &groups));
673 + nodes.extend(join(app)?);
302 674
303 675 nodes.push(Node::section("Your identity key"));
304 676 nodes.extend(identity(app));
@@ -335,9 +707,7 @@
335 707 email: None,
336 708 pubkey: None,
337 709 member_user_id: None,
338 - attempts: 0,
339 - last_error: None,
340 - done_at: None,
710 + ..Default::default()
341 711 },
342 712 )
343 713 .map_err(RouteError::internal)?;
@@ -387,9 +757,7 @@
387 757 email: Some(email),
388 758 pubkey: Some(pubkey),
389 759 member_user_id: None,
390 - attempts: 0,
391 - last_error: None,
392 - done_at: None,
760 + ..Default::default()
393 761 },
394 762 )
395 763 .map_err(RouteError::internal)?;
@@ -397,6 +765,199 @@
397 765 Ok("Queued. They are admitted within a minute, once your encryption key is loaded.")
398 766 }
399 767
768 + /// Queue a fresh invite code for a group.
769 + ///
770 + /// The expiry is the one value with a shape to check, and blank is a real
771 + /// answer rather than a mistake: the server has a default and saying nothing
772 + /// takes it.
773 + pub(super) fn create_invite(
774 + app: &AppState,
775 + request: &quasi_router::Request,
776 + ) -> Result<&'static str, RouteError> {
777 + let group_id = request
778 + .payload
779 + .get("group_id")
780 + .unwrap_or_default()
781 + .trim()
782 + .to_owned();
783 + if group_id.is_empty() {
784 + return Err(RouteError::conflict("An invite needs a group."));
785 + }
786 +
787 + let raw = request
788 + .payload
789 + .get("expires_in_hours")
790 + .unwrap_or_default()
791 + .trim()
792 + .to_owned();
793 + let expires_in_hours = if raw.is_empty() {
794 + None
795 + } else {
Lines truncated
@@ -528,9 +528,7 @@
528 528 email: None,
529 529 pubkey: None,
530 530 member_user_id: None,
531 - attempts: 0,
532 - last_error: None,
533 - done_at: None,
531 + ..Default::default()
534 532 },
535 533 )
536 534 .unwrap();
@@ -556,9 +554,7 @@
556 554 email: Some("them@localhost".to_owned()),
557 555 pubkey: Some("k".to_owned()),
558 556 member_user_id: None,
559 - attempts: 0,
560 - last_error: None,
561 - done_at: None,
557 + ..Default::default()
562 558 },
563 559 )
564 560 .unwrap();
@@ -593,9 +589,7 @@
593 589 email: None,
594 590 pubkey: None,
595 591 member_user_id: None,
596 - attempts: 0,
597 - last_error: None,
598 - done_at: None,
592 + ..Default::default()
599 593 },
600 594 )
601 595 .unwrap();
@@ -626,3 +620,493 @@
626 620 assert!(pane.contains("Your identity key"), "{pane}");
627 621 assert!(pane.contains("no identity key"), "{pane}");
628 622 }
623 +
624 + // The invitation flow. Every test below writes the directory the way the sync
625 + // loop does and then reads the pane, on `known_group`'s reasoning: that the
626 + // loop writes it is synckit's test, and what is under test here is a section
627 + // reading it.
628 +
629 + /// Put an invitation in the directory. `token` is the issued code, which only
630 + /// the device that issued it ever holds.
631 + fn known_invitation(
632 + state: &AppState,
633 + group: u128,
634 + id: u128,
635 + invitation_state: synckit_client::InvitationState,
636 + fingerprint: Option<&str>,
637 + token: Option<&str>,
638 + expires_at: &str,
639 + ) {
640 + let mut conn = state.db.conn().unwrap();
641 + synckit_client::store::directory::ensure_tables(&conn).unwrap();
642 + let known = synckit_client::store::directory::KnownInvitation {
643 + id: synckit_client::InvitationId::new(uuid::Uuid::from_u128(id)),
644 + group_id: synckit_client::GroupId::new(uuid::Uuid::from_u128(group)),
645 + state: invitation_state,
646 + invitee_email: Some("them@localhost".to_owned()),
647 + invitee_fingerprint: fingerprint.map(ToOwned::to_owned),
648 + token: None,
649 + expires_at: expires_at.to_owned(),
650 + created_at: "2026-08-24T09:00:00.000Z".to_owned(),
651 + };
652 + synckit_client::store::directory::write_invitations(
653 + &mut conn,
654 + synckit_client::GroupId::new(uuid::Uuid::from_u128(group)),
655 + std::slice::from_ref(&known),
656 + )
657 + .unwrap();
658 + // The token is not part of the server's answer, so it goes in the way the
659 + // drainer puts it there rather than through the refresh.
660 + if let Some(token) = token {
661 + conn.execute(
662 + "UPDATE sync_invitations SET token = ?1 WHERE invitation_id = ?2",
663 + rusqlite::params![token, uuid::Uuid::from_u128(id).to_string()],
664 + )
665 + .unwrap();
666 + }
667 + }
668 +
669 + fn far_future() -> String {
670 + "2099-01-01T00:00:00.000Z".to_owned()
671 + }
672 +
673 + fn long_past() -> String {
674 + "2020-01-01T00:00:00.000Z".to_owned()
675 + }
676 +
677 + /// Only while pending. Once somebody has accepted, the code has done its whole
678 + /// job and holding it on screen is exposure with no use.
679 + #[tokio::test]
680 + async fn a_pending_invitation_shows_its_code_and_an_accepted_one_does_not() {
681 + let pending = state().await;
682 + known_group(&pending, 1, "The Firm", true);
683 + known_invitation(
684 + &pending,
685 + 1,
686 + 11,
687 + synckit_client::InvitationState::Pending,
688 + None,
689 + Some("CODE-ALPHA"),
690 + &far_future(),
691 + );
692 +
693 + let pane = sharing(&pending);
694 + assert!(pane.contains("CODE-ALPHA"), "{pane}");
695 +
696 + let accepted = state().await;
697 + known_group(&accepted, 1, "The Firm", true);
698 + known_invitation(
699 + &accepted,
700 + 1,
701 + 11,
702 + synckit_client::InvitationState::Accepted,
703 + Some("aa:bb:cc"),
704 + Some("CODE-ALPHA"),
705 + &far_future(),
706 + );
707 + let pane = sharing(&accepted);
708 + assert!(!pane.contains("CODE-ALPHA"), "{pane}");
709 + }
710 +
711 + /// A deadline passes on its own between two refreshes, so the stored state says
712 + /// `pending` for a code that stopped working. Reading `effective_state` is what
713 + /// keeps a dead code from being offered as a live one, and it costs no request.
714 + #[tokio::test]
715 + async fn a_code_past_its_deadline_reads_as_expired_before_any_refresh() {
716 + let state = state().await;
717 + known_group(&state, 1, "The Firm", true);
718 + known_invitation(
719 + &state,
720 + 1,
721 + 11,
722 + synckit_client::InvitationState::Pending,
723 + None,
724 + Some("CODE-STALE"),
725 + &long_past(),
726 + );
727 +
728 + let pane = sharing(&state);
729 + assert!(pane.contains("Expired"), "{pane}");
730 + assert!(
731 + !pane.contains("CODE-STALE"),
732 + "the token goes with it: {pane}"
733 + );
734 + assert!(!pane.contains("Cancel it"), "and so does the act: {pane}");
735 + }
736 +
737 + /// The security-bearing step, and the only one where a person is waiting. It is
738 + /// drawn above the group list rather than found by opening each group in turn.
739 + #[tokio::test]
740 + async fn an_accepted_invitation_waits_at_the_top_with_its_fingerprint() {
741 + let state = state().await;
742 + known_group(&state, 1, "The Firm", true);
743 + known_invitation(
744 + &state,
745 + 1,
746 + 11,
747 + synckit_client::InvitationState::Accepted,
748 + Some("aa:bb:cc:dd"),
749 + None,
750 + &far_future(),
751 + );
752 +
753 + let pane = sharing(&state);
754 + assert!(pane.contains("Waiting on you to admit them"), "{pane}");
755 + assert!(pane.contains("aa:bb:cc:dd"), "{pane}");
756 + assert!(pane.contains("read it out over a channel"), "{pane}");
757 +
758 + let top = pane.find("Waiting on you to admit them").unwrap();
759 + let groups = pane.find("The Firm").unwrap();
760 + assert!(top < groups, "above the group list: {pane}");
761 + }
762 +
763 + /// A key that yields no fingerprint must never be confirmable. The comparison is
764 + /// the whole security of the step, and offering Confirm beside "unavailable"
765 + /// invites approving something nobody read.
766 + #[tokio::test]
767 + async fn an_invitation_with_no_readable_fingerprint_cannot_be_confirmed() {
768 + let state = state().await;
769 + known_group(&state, 1, "The Firm", true);
770 + known_invitation(
771 + &state,
772 + 1,
773 + 11,
774 + synckit_client::InvitationState::Accepted,
775 + None,
776 + None,
777 + &far_future(),
778 + );
779 +
780 + let pane = sharing(&state);
781 + assert!(pane.contains("Cannot be checked"), "{pane}");
782 + assert!(!pane.contains("It matches, admit them"), "{pane}");
783 + }
784 +
785 + #[tokio::test]
786 + async fn queueing_an_invite_records_the_group_and_the_expiry() {
787 + let state = state().await;
788 + let mut params = Params::new();
789 + params.insert("group_id".to_owned(), uuid::Uuid::from_u128(1).to_string());
790 + params.insert("expires_in_hours".to_owned(), "48".to_owned());
791 +
792 + router()
793 + .handle(
794 + &state,
795 + Request::post("/settings/sharing/invites").sending(params),
796 + )
797 + .expect("the route answers");
798 +
799 + let queued = crate::group_queue::pending(&state).unwrap();
800 + assert_eq!(queued.len(), 1);
801 + assert_eq!(queued[0].kind, "create_invite");
802 + assert_eq!(queued[0].expires_in_hours, Some(48));
803 + assert!(
804 + queued[0].invite_token.is_none(),
805 + "the issued code is not on the row; it does not exist yet"
806 + );
807 + }
808 +
809 + /// Blank is a real answer: the server has a default and saying nothing takes it.
810 + #[tokio::test]
811 + async fn an_invite_with_no_expiry_takes_the_servers_default() {
812 + let state = state().await;
813 + let mut params = Params::new();
814 + params.insert("group_id".to_owned(), uuid::Uuid::from_u128(1).to_string());
815 + params.insert("expires_in_hours".to_owned(), String::new());
816 +
817 + router()
818 + .handle(
819 + &state,
820 + Request::post("/settings/sharing/invites").sending(params),
821 + )
822 + .expect("the route answers");
823 +
824 + assert_eq!(
825 + crate::group_queue::pending(&state).unwrap()[0].expires_in_hours,
826 + None
827 + );
828 + }
829 +
830 + #[tokio::test]
831 + async fn an_expiry_that_is_not_a_positive_whole_number_is_refused_now() {
832 + let state = state().await;
833 + for bad in ["nope", "0", "-3"] {
834 + let mut params = Params::new();
835 + params.insert("group_id".to_owned(), uuid::Uuid::from_u128(1).to_string());
836 + params.insert("expires_in_hours".to_owned(), bad.to_owned());
837 + let error = router()
838 + .handle(
839 + &state,
840 + Request::post("/settings/sharing/invites").sending(params),
841 + )
842 + .expect_err("refused rather than queued to fail later");
843 + assert_eq!(error.class, quasi_router::Class::Conflict, "{bad}");
844 + }
845 + assert!(crate::group_queue::pending(&state).unwrap().is_empty());
846 + }
847 +
848 + /// The fingerprint is the thing being authorized, so it is never copied onto the
849 + /// row: the drainer re-reads the server's current answer. A key swapped between
850 + /// the reading and the drain is refused by the same comparison rather than waved
851 + /// through by a copy of it.
852 + #[tokio::test]
853 + async fn a_queued_confirm_carries_the_invitation_and_not_the_fingerprint() {
854 + let state = state().await;
855 + known_group(&state, 1, "The Firm", true);
856 + known_invitation(
857 + &state,
858 + 1,
859 + 11,
860 + synckit_client::InvitationState::Accepted,
861 + Some("aa:bb:cc:dd"),
862 + None,
863 + &far_future(),
864 + );
865 +
866 + router()
867 + .handle(
868 + &state,
869 + Request::post(format!(
870 + "/settings/sharing/invites/{}/{}/confirm",
871 + uuid::Uuid::from_u128(1),
872 + uuid::Uuid::from_u128(11)
873 + )),
874 + )
875 + .expect("the route answers");
876 +
877 + let queued = crate::group_queue::pending(&state).unwrap();
878 + assert_eq!(queued[0].kind, "confirm_invite");
879 + assert_eq!(
880 + queued[0].invitation_id.as_deref(),
881 + Some(uuid::Uuid::from_u128(11).to_string().as_str())
882 + );
883 + assert!(
884 + !format!("{:?}", queued[0]).contains("aa:bb:cc:dd"),
885 + "no fingerprint anywhere on the row: {:?}",
886 + queued[0]
887 + );
888 + }
889 +
890 + /// "Confirm an invitation" authorizes nothing legible, so the queued row names
891 + /// the fingerprint. It is resolved from the directory as the row is drawn, which
892 + /// is a display of the current answer rather than a second copy of it.
893 + #[tokio::test]
894 + async fn a_queued_confirm_names_the_fingerprint_it_is_authorizing() {
895 + let state = state().await;
896 + known_group(&state, 1, "The Firm", true);
897 + known_invitation(
898 + &state,
899 + 1,
900 + 11,
901 + synckit_client::InvitationState::Accepted,
902 + Some("aa:bb:cc:dd"),
903 + None,
904 + &far_future(),
905 + );
906 + crate::group_queue::enqueue(
907 + &state,
908 + &crate::group_queue::QueuedOp {
909 + id: "q1".to_owned(),
910 + kind: "confirm_invite".to_owned(),
911 + group_id: Some(uuid::Uuid::from_u128(1).to_string()),
912 + invitation_id: Some(uuid::Uuid::from_u128(11).to_string()),
913 + ..Default::default()
914 + },
915 + )
916 + .unwrap();
917 +
918 + let pane = sharing(&state);
919 + assert!(pane.contains("Admit the holder of aa:bb:cc:dd"), "{pane}");
920 + }
921 +
922 + /// The invitation has left `accepted` since it was queued, or the directory has
923 + /// not caught up. Both are honest reasons not to name a fingerprint, and neither
924 + /// is a reason to name a stale one.
925 + #[tokio::test]
926 + async fn a_queued_confirm_the_directory_cannot_place_says_so_rather_than_guessing() {
927 + let state = state().await;
928 + crate::group_queue::enqueue(
929 + &state,
930 + &crate::group_queue::QueuedOp {
931 + id: "q1".to_owned(),
932 + kind: "confirm_invite".to_owned(),
933 + group_id: Some(uuid::Uuid::from_u128(1).to_string()),
934 + invitation_id: Some(uuid::Uuid::from_u128(99).to_string()),
935 + ..Default::default()
936 + },
937 + )
938 + .unwrap();
939 +
940 + let pane = sharing(&state);
941 + assert!(pane.contains("once their fingerprint is checked"), "{pane}");
942 + }
943 +
944 + /// Reading a code and accepting it are two queued writes. Collapsing them would
945 + /// delete the step where a person reads what they are joining.
946 + #[tokio::test]
947 + async fn pasting_a_code_queues_a_read_and_sends_nothing() {
948 + let state = state().await;
949 + let mut params = Params::new();
950 + params.insert("token".to_owned(), " CODE-BETA ".to_owned());
951 +
952 + router()
953 + .handle(
954 + &state,
955 + Request::post("/settings/sharing/invites/preview").sending(params),
956 + )
957 + .expect("the route answers");
958 +
959 + let queued = crate::group_queue::pending(&state).unwrap();
960 + assert_eq!(queued[0].kind, "preview_invite");
961 + assert_eq!(
962 + queued[0].invite_token.as_deref(),
963 + Some("CODE-BETA"),
964 + "normalised where the one accepted spelling lives"
965 + );
966 + }
967 +
968 + /// One accepted spelling of a code, owned by `normalize_invite_token`, so a
969 + /// paste that works in the Tauri command works here.
970 + #[tokio::test]
971 + async fn a_pasted_url_is_normalised_to_the_bare_code() {
972 + let state = state().await;
973 + let mut params = Params::new();
974 + params.insert(
975 + "token".to_owned(),
976 + "https://makenot.work/invite/CODE-GAMMA?from=mail".to_owned(),
977 + );
978 +
979 + router()
980 + .handle(
981 + &state,
982 + Request::post("/settings/sharing/invites/preview").sending(params),
983 + )
984 + .expect("the route answers");
985 +
986 + assert_eq!(
987 + crate::group_queue::pending(&state).unwrap()[0]
988 + .invite_token
989 + .as_deref(),
990 + Some("CODE-GAMMA")
991 + );
992 + }
993 +
994 + /// Terminal states are drawn without an Accept control, so reaching the handler
995 + /// with one is a stale screen rather than a misuse. Refused rather than queued:
996 + /// it can only fail, and failing here says so now instead of in a minute.
997 + ///
998 + /// What the *pane* says about a dead code -- which kind of dead, read off
999 + /// `state`, because "expired" and "cancelled" send a person to different places
1000 + /// -- is not asserted here. The join section is withheld without sync
1001 + /// configured, and a test state has none.
1002 + #[tokio::test]
1003 + async fn a_terminal_code_is_refused_now_rather_than_queued_to_fail() {
1004 + let state = state().await;
1005 + let mut conn = state.db.conn().unwrap();
1006 + synckit_client::store::directory::ensure_tables(&conn).unwrap();
1007 + synckit_client::store::directory::write_preview(
1008 + &mut conn,
1009 + &synckit_client::store::directory::KnownPreview {
1010 + token: "CODE-DEAD".to_owned(),
1011 + group_name: "The Firm".to_owned(),
1012 + inviter_email: "boss@localhost".to_owned(),
1013 + redeemable: false,
1014 + state: synckit_client::InvitationState::Revoked,
1015 + expires_at: far_future(),
1016 + },
1017 + )
1018 + .unwrap();
1019 + drop(conn);
1020 +
1021 + let error = router()
1022 + .handle(&state, Request::post("/settings/sharing/invites/accept"))
1023 + .expect_err("a terminal code cannot be accepted");
1024 + assert_eq!(error.class, quasi_router::Class::Conflict);
1025 + }
1026 +
1027 + /// Accepting reads the token from the stored preview rather than from the
1028 + /// request: the control appears only because a preview is on screen, and
1029 + /// re-sending the code through the form would let the two disagree.
1030 + #[tokio::test]
1031 + async fn accepting_with_nothing_previewed_is_a_not_found() {
1032 + let state = state().await;
1033 + let error = router()
1034 + .handle(&state, Request::post("/settings/sharing/invites/accept"))
1035 + .expect_err("there is no code to accept");
1036 + assert_eq!(error.class, quasi_router::Class::NotFound);
1037 + }
1038 +
1039 + /// A preview was a read, so dropping the answer to it is this device's business
1040 + /// alone and there is nothing to tell a server.
1041 + #[tokio::test]
1042 + async fn dismissing_a_preview_queues_nothing() {
1043 + let state = state().await;
1044 + let mut conn = state.db.conn().unwrap();
1045 + synckit_client::store::directory::ensure_tables(&conn).unwrap();
1046 + synckit_client::store::directory::write_preview(
1047 + &mut conn,
1048 + &synckit_client::store::directory::KnownPreview {
1049 + token: "CODE-DELTA".to_owned(),
1050 + group_name: "The Firm".to_owned(),
1051 + inviter_email: "boss@localhost".to_owned(),
1052 + redeemable: true,
1053 + state: synckit_client::InvitationState::Pending,
1054 + expires_at: far_future(),
1055 + },
1056 + )
1057 + .unwrap();
1058 + drop(conn);
1059 +
1060 + router()
1061 + .handle(&state, Request::post("/settings/sharing/invites/dismiss"))
1062 + .expect("the route answers");
1063 +
1064 + let conn = state.db.conn().unwrap();
1065 + assert!(
1066 + synckit_client::store::directory::preview(&conn)
1067 + .unwrap()
1068 + .is_none()
1069 + );
1070 + assert!(crate::group_queue::pending(&state).unwrap().is_empty());
1071 + }
1072 +
1073 + /// Accepting posts this device's identity key, and there is no key without sync
1074 + /// set up, so the control could not act and is not drawn.
1075 + #[tokio::test]
1076 + async fn the_join_section_is_withheld_when_there_is_no_sync_to_use_it() {
1077 + let state = state().await;
1078 + let pane = sharing(&state);
1079 + assert!(
1080 + !pane.contains("Joining a group you were invited to"),
1081 + "{pane}"
1082 + );
1083 + }
1084 +
1085 + /// The only staleness reader synckit exposes is `MIN(refreshed_at)` over
1086 + /// `sync_groups`, so the section can state the group list's freshness and not
1087 + /// the invitation list's. It says which one it is rather than implying the
1088 + /// stronger claim. Filed on synckit-client as `0e8af5b2`.
1089 + #[tokio::test]
Lines truncated
@@ -1,0 +1,44 @@
1 + -- The invitation half of the group-admin queue.
2 + --
3 + -- Migration 070 queued `create_group`, `add_member` and `remove_member` and
4 + -- said in its own header that the kind column is not a CHECK constraint, so
5 + -- five new kinds need no change to it. What they do need is somewhere to put
6 + -- what they carry, and none of 070's four payload columns fits: an invitation
7 + -- is addressed by its own id, a pasted code is neither a name nor a public key,
8 + -- and an expiry is a number.
9 + --
10 + -- THE FIVE KINDS. `create_invite` reads `group_id` and `expires_in_hours`;
11 + -- `revoke_invite` and `confirm_invite` read `group_id` and `invitation_id`;
12 + -- `preview_invite` and `accept_invite` read `invite_token`. Columns rather than
13 + -- a JSON blob, for migration 070's reason and by its precedent.
14 + --
15 + -- `invite_token` IS NOT THE ISSUED TOKEN, and the distinction is the reason
16 + -- this comment is longer than the DDL. An invitation's code is returned once by
17 + -- `create_invitation` and the server keeps only a hash, so the only copy that
18 + -- will ever exist belongs in `sync_invitations.token`, written by
19 + -- `directory::record_issued` at the moment the drainer receives it. It is never
20 + -- a column here: a queue row is swept an hour after it lands, and sweeping the
21 + -- only copy of a live invite code would issue an invitation nobody can use.
22 + --
23 + -- What this column holds is the opposite direction: a code the user was handed
24 + -- by somebody else and pasted in, which they are holding anyway and which the
25 + -- drainer has to be able to read a minute later. Nothing on this device is the
26 + -- authority on it.
27 + --
28 + -- CONFIRM STORES NO FINGERPRINT. `confirm_invite` carries the group and the
29 + -- invitation and stops there, so the drainer re-reads the server's current
30 + -- answer rather than acting on a copy taken at queue time. That is how
31 + -- `remove_member` is already stored, and here it is stronger than consistency:
32 + -- the fingerprint is the thing being authorized, and authorizing a value copied
33 + -- a minute ago is exactly the substitution the out-of-band comparison exists to
34 + -- catch. The screen still names the fingerprint on the queued row -- it reads
35 + -- it from the directory when it draws, which is a display and not a claim.
36 + --
37 + -- STILL LOCAL-ONLY, still absent from `syncstore::manifest`, and still in
38 + -- `EXCLUDED_TABLES`. Adding a column to a table a backup does not carry needs
39 + -- no registration on either side, and the storage version does not move:
40 + -- nothing here crosses the wire.
41 +
42 + ALTER TABLE group_admin_queue ADD COLUMN invitation_id TEXT;
43 + ALTER TABLE group_admin_queue ADD COLUMN invite_token TEXT;
44 + ALTER TABLE group_admin_queue ADD COLUMN expires_in_hours INTEGER;