Skip to main content

max / synckit

The invitation directory, beside the group one An invitation is a state machine rather than an act: it persists across states and two of its transitions happen without this device doing anything, so it is a list to refresh rather than a write that resolves. `sync_invitations` holds one per invitation on a group this user administers, refreshed in the same per-group pass as the member list, under the same admin gate and on the same best-effort terms. The token is the one column the server cannot give back. It keeps only a hash, so the code returned by `create_invitation` is the only copy that will ever exist, and `record_issued` writes it down at the moment it is issued for the same reason `add_group` exists. That is why the refresh upserts and prunes instead of replacing: a delete-then-insert would throw away a live invite code one cycle after issuing it. The token is dropped the moment the invitation stops being pending, in `write_invitations` rather than at a call site, since that is the answer to what clears it. A deadline passes on its own between two refreshes, so `effective_state` derives it at read time and a screen never offers a dead code as a live one. Only `Pending` derives: an accepted invitation is waiting on an admin, and the deadline it carries was for redeeming the link. A previewed code gets its own table. The person holding one is not in the group, so there is no scope to hang it off and no invitation id to key it by; it shares almost no columns with an issued invitation and does not share its table. Also removes a duplicated definition rather than doubling it: the directory DDL was spelled out in both `migrate.rs` and `directory.rs`, and two more tables would have made four places to keep identical. `migration_sql` now concatenates `directory::DDL`, and the test that guarded the drift asserts the concatenation.
Design
goingson task 34cfd2d1.
Author: Max Johnson <me@maxj.phd> · 2026-08-24 19:54 UTC
Signed with PGP, not checked
Commit: aae5870846670ee0b266fd6a7cdcb48c166cba45
Parent: 95ab84d
4 files changed, +634 insertions, -57 deletions
@@ -1,5 +1,8 @@
1 1 //! The group directory: the server's answer about groups, written down locally.
2 2 //!
3 + //! Groups, their members, the invitations outstanding on them, and the one
4 + //! previewed invite code a person who is not in a group yet may be holding.
5 + //!
3 6 //! # Why it exists
4 7 //!
5 8 //! A described screen's handler is a synchronous function. That is not a
@@ -23,8 +26,24 @@
23 26 //! dropping the rest. Writing it down instead is the whole mechanism: the read
24 27 //! already happens, on a schedule, outside any request loop.
25 28 //!
26 - //! Members are the one part that is a new request, and it is made only for
27 - //! groups this user administers, since the server refuses a non-admin.
29 + //! Members and invitations are the parts that are a new request, made only for
30 + //! groups this user administers, since the server refuses a non-admin. Worth
31 + //! being plain about, because the "it costs no request" heading above is only
32 + //! true of the group list itself: the rest is paid for, and it is worth paying
33 + //! because a screen cannot ask for any of it.
34 + //!
35 + //! # Invitations carry one thing the server cannot give back
36 + //!
37 + //! `create_invitation` returns a token the server keeps only a hash of. So
38 + //! `sync_invitations.token` is the single column here that is not a copy of the
39 + //! server's answer: it is written by whoever issued the invitation
40 + //! ([`record_issued`]) and it is the only copy that will ever exist. That is why
41 + //! the refresh upserts instead of replacing, and why it drops the token the
42 + //! moment the invitation stops being pending.
43 + //!
44 + //! A previewed code ([`write_preview`]) is not part of the directory proper and
45 + //! the sync loop never touches it. The person holding one is not in the group,
46 + //! so there is no scope to hang it off and nothing to refresh it against.
28 47 //!
29 48 //! # What it is not
30 49 //!
@@ -45,9 +64,11 @@
45 64
46 65 use rusqlite::{Connection, OptionalExtension, params};
47 66
48 - pub use super::sync::{KnownGroup, KnownMember};
67 + pub use super::sync::{KnownGroup, KnownInvitation, KnownMember, KnownPreview};
49 68 use crate::GroupId;
50 69 use crate::error::Result;
70 + use crate::ids::InvitationId;
71 + use crate::types::InvitationState;
51 72
52 73 /// Replace the group directory with what the server just reported.
53 74 ///
@@ -132,21 +153,346 @@
132 153 Ok(())
133 154 }
134 155
156 + /// Replace one group's invitation list, keeping the tokens this device issued.
157 + ///
158 + /// Upsert-and-prune rather than the delete-then-insert [`write_members`] uses,
159 + /// and the difference is load-bearing: `token` is the one column the server
160 + /// cannot send back, so deleting the row would throw away the only copy of a
161 + /// live invite code on the device that issued it.
162 + ///
163 + /// The token is dropped the moment the invitation stops being `pending`. Once
164 + /// somebody has accepted, the code has done its whole job and holding it is
165 + /// exposure with no use; `revoked` and `expired` drop it for the same reason.
166 + /// That happens here rather than at a call site because it is the answer to
167 + /// "what clears the token", and a caller that forgot would leave a dead code on
168 + /// screen.
169 + ///
170 + /// Scoped to the one group, like [`write_members`]: a cycle where the fetch
171 + /// failed for one group should not blank the others.
172 + pub fn write_invitations(
173 + conn: &mut Connection,
174 + group_id: GroupId,
175 + invitations: &[KnownInvitation],
176 + ) -> Result<()> {
177 + let tx = conn.transaction()?;
178 + for invitation in invitations {
179 + tx.execute(
180 + "INSERT INTO sync_invitations \
181 + (invitation_id, group_id, state, invitee_email, invitee_fingerprint, \
182 + expires_at, created_at) \
183 + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
184 + ON CONFLICT(invitation_id) DO UPDATE SET \
185 + state = excluded.state, \
186 + invitee_email = excluded.invitee_email, \
187 + invitee_fingerprint = excluded.invitee_fingerprint, \
188 + expires_at = excluded.expires_at, \
189 + created_at = excluded.created_at, \
190 + refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), \
191 + token = CASE WHEN excluded.state = 'pending' \
192 + THEN sync_invitations.token ELSE NULL END",
193 + params![
194 + invitation.id.to_string(),
195 + group_id.to_string(),
196 + state_str(invitation.state),
197 + invitation.invitee_email,
198 + invitation.invitee_fingerprint,
199 + invitation.expires_at,
200 + invitation.created_at,
201 + ],
202 + )?;
203 + }
204 +
205 + // Prune what the server no longer reports. Built as a NOT IN rather than a
206 + // delete-first so the upserts above keep their tokens; with no invitations
207 + // at all it is an unconditional delete for the group, which is correct and
208 + // is why the empty case is not special-cased away.
209 + let ids: Vec<String> = invitations.iter().map(|i| i.id.to_string()).collect();
210 + if ids.is_empty() {
211 + tx.execute(
212 + "DELETE FROM sync_invitations WHERE group_id = ?1",
213 + params![group_id.to_string()],
214 + )?;
215 + } else {
216 + let holes = std::iter::repeat_n("?", ids.len())
217 + .collect::<Vec<_>>()
218 + .join(",");
219 + let mut args: Vec<String> = vec![group_id.to_string()];
220 + args.extend(ids);
221 + tx.execute(
222 + &format!(
223 + "DELETE FROM sync_invitations WHERE group_id = ?1 \
224 + AND invitation_id NOT IN ({holes})"
225 + ),
226 + rusqlite::params_from_iter(args),
227 + )?;
228 + }
229 + tx.commit()?;
230 + Ok(())
231 + }
232 +
233 + /// Write down an invitation this device has just issued, with its token.
234 + ///
235 + /// The counterpart to [`add_group`], for the same reason and at the same moment:
236 + /// the server knows about it, the sync loop has not run since, and a screen
237 + /// reading the directory would show nothing landing. Here it is stronger than a
238 + /// convenience. `create_invitation` returns the only copy of the token that will
239 + /// ever exist, so a caller that does not write it down immediately has issued an
240 + /// invitation nobody can use.
241 + ///
242 + /// Additive, and the refresh's upsert takes it from here.
243 + pub fn record_issued(
244 + conn: &Connection,
245 + group_id: GroupId,
246 + invitation: &crate::types::GroupInvitation,
247 + ) -> Result<()> {
248 + conn.execute(
249 + "INSERT INTO sync_invitations \
250 + (invitation_id, group_id, state, token, expires_at, created_at) \
251 + VALUES (?1, ?2, 'pending', ?3, ?4, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) \
252 + ON CONFLICT(invitation_id) DO UPDATE SET token = excluded.token",
253 + params![
254 + invitation.id.to_string(),
255 + group_id.to_string(),
256 + invitation.token,
257 + invitation.expires_at.to_rfc3339(),
258 + ],
259 + )?;
260 + Ok(())
261 + }
262 +
263 + /// A group's invitations, newest first.
264 + ///
265 + /// Empty for a group this user merely belongs to, because the server refuses a
266 + /// non-admin the list, exactly as [`members`] is.
267 + pub fn invitations(conn: &Connection, group_id: GroupId) -> Result<Vec<KnownInvitation>> {
268 + read_invitations(
269 + conn,
270 + "SELECT invitation_id, group_id, state, invitee_email, invitee_fingerprint, \
271 + token, expires_at, created_at FROM sync_invitations \
272 + WHERE group_id = ?1 ORDER BY created_at DESC",
273 + params![group_id.to_string()],
274 + )
275 + }
276 +
277 + /// Every invitation waiting on this admin, across all groups, oldest first.
278 + ///
279 + /// `Accepted` is the state that wants a person: the invitee has posted a key and
280 + /// nothing happens until the admin compares its fingerprint out of band and
281 + /// confirms. That comparison is the security of the whole flow rather than a
282 + /// formality, so it is worth one read that does not make the caller walk groups
283 + /// to find it.
284 + ///
285 + /// Oldest first because the answer to "who has been waiting longest" is the one
286 + /// an admin wants; the per-group list is newest first because there the question
287 + /// is "what did I just issue".
288 + pub fn pending_confirmations(conn: &Connection) -> Result<Vec<KnownInvitation>> {
289 + read_invitations(
290 + conn,
291 + "SELECT invitation_id, group_id, state, invitee_email, invitee_fingerprint, \
292 + token, expires_at, created_at FROM sync_invitations \
293 + WHERE state = 'accepted' ORDER BY created_at",
294 + params![],
295 + )
296 + }
297 +
298 + /// Shared row mapping for the two invitation reads.
299 + fn read_invitations<P: rusqlite::Params>(
300 + conn: &Connection,
301 + sql: &str,
302 + args: P,
303 + ) -> Result<Vec<KnownInvitation>> {
304 + if !present(conn)? {
305 + return Ok(Vec::new());
306 + }
307 + let mut stmt = conn.prepare(sql)?;
308 + let rows = stmt
309 + .query_map(args, |row| {
310 + Ok((
311 + row.get::<_, String>(0)?,
312 + row.get::<_, String>(1)?,
313 + row.get::<_, String>(2)?,
314 + row.get::<_, Option<String>>(3)?,
315 + row.get::<_, Option<String>>(4)?,
316 + row.get::<_, Option<String>>(5)?,
317 + row.get::<_, String>(6)?,
318 + row.get::<_, String>(7)?,
319 + ))
320 + })?
321 + .collect::<std::result::Result<Vec<_>, _>>()?;
322 +
323 + Ok(rows
324 + .into_iter()
325 + // A row this build cannot address is skipped rather than failing the
326 + // read, on the same reasoning as `groups`: it can only come from a build
327 + // that wrote a shape this one does not know, and a screen that cannot
328 + // list its invitations is worse than one missing a row it could not have
329 + // acted on anyway.
330 + .filter_map(
331 + |(id, group, state, email, fingerprint, token, expires, created)| {
332 + Some(KnownInvitation {
333 + id: InvitationId::new(id.parse().ok()?),
334 + group_id: GroupId::new(group.parse().ok()?),
335 + state: state_from_str(&state)?,
336 + invitee_email: email,
337 + invitee_fingerprint: fingerprint,
338 + token,
339 + expires_at: expires,
340 + created_at: created,
341 + })
342 + },
343 + )
344 + .collect())
345 + }
346 +
347 + /// Write down what a pasted invite code leads to.
348 + ///
349 + /// Replaces whatever was there: a person pastes one code at a time, and a
350 + /// previous answer they did not act on is not something to keep beside a new one.
351 + pub fn write_preview(conn: &mut Connection, preview: &KnownPreview) -> Result<()> {
352 + let tx = conn.transaction()?;
353 + tx.execute("DELETE FROM sync_invitation_previews", [])?;
354 + tx.execute(
355 + "INSERT INTO sync_invitation_previews \
356 + (token, group_name, inviter_email, redeemable, state, expires_at) \
357 + VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
358 + params![
359 + preview.token,
360 + preview.group_name,
361 + preview.inviter_email,
362 + i32::from(preview.redeemable),
363 + state_str(preview.state),
364 + preview.expires_at,
365 + ],
366 + )?;
367 + tx.commit()?;
368 + Ok(())
369 + }
370 +
371 + /// The code this device is currently holding an answer about, if any.
372 + pub fn preview(conn: &Connection) -> Result<Option<KnownPreview>> {
373 + if !present(conn)? {
374 + return Ok(None);
375 + }
376 + let row = conn
377 + .query_row(
378 + "SELECT token, group_name, inviter_email, redeemable, state, expires_at \
379 + FROM sync_invitation_previews LIMIT 1",
380 + [],
381 + |row| {
382 + Ok((
383 + row.get::<_, String>(0)?,
384 + row.get::<_, String>(1)?,
385 + row.get::<_, String>(2)?,
386 + row.get::<_, i32>(3)?,
387 + row.get::<_, String>(4)?,
388 + row.get::<_, String>(5)?,
389 + ))
390 + },
391 + )
392 + .optional()?;
393 +
394 + Ok(row.and_then(
395 + |(token, group_name, inviter_email, redeemable, state, expires_at)| {
396 + Some(KnownPreview {
397 + token,
398 + group_name,
399 + inviter_email,
400 + redeemable: redeemable != 0,
401 + state: state_from_str(&state)?,
402 + expires_at,
403 + })
404 + },
405 + ))
406 + }
407 +
408 + /// Forget the previewed code, once it has been accepted or dismissed.
409 + pub fn clear_preview(conn: &Connection) -> Result<()> {
410 + if !present(conn)? {
411 + return Ok(());
412 + }
413 + conn.execute("DELETE FROM sync_invitation_previews", [])?;
414 + Ok(())
415 + }
416 +
417 + /// The stored spelling of a state.
418 + ///
419 + /// The serde `rename_all = "lowercase"` spelling, written by hand so the storage
420 + /// format does not move if the wire format's attribute ever does. `write_invitations`
421 + /// compares against the literal `'pending'` in SQL, so this is the one place the
422 + /// two have to agree.
423 + fn state_str(state: InvitationState) -> &'static str {
424 + match state {
425 + InvitationState::Pending => "pending",
426 + InvitationState::Accepted => "accepted",
427 + InvitationState::Redeemed => "redeemed",
428 + InvitationState::Revoked => "revoked",
429 + InvitationState::Expired => "expired",
430 + // No catch-all arm: `#[non_exhaustive]` does not bind inside the crate
431 + // that defines the enum, so a variant added later is a compile error
432 + // here rather than a silent "unknown" written into the database. That is
433 + // the outcome worth having, and it is why a consumer across the crate
434 + // boundary (goingson's `invitation_state_str`) needs the arm this does
435 + // not.
436 + }
437 + }
438 +
439 + /// Parse a stored state, `None` for one this build does not know.
440 + fn state_from_str(raw: &str) -> Option<InvitationState> {
441 + match raw {
442 + "pending" => Some(InvitationState::Pending),
443 + "accepted" => Some(InvitationState::Accepted),
444 + "redeemed" => Some(InvitationState::Redeemed),
445 + "revoked" => Some(InvitationState::Revoked),
446 + "expired" => Some(InvitationState::Expired),
447 + _ => None,
448 + }
449 + }
450 +
135 451 /// The directory's own DDL, idempotent.
136 452 ///
137 - /// The same two `CREATE TABLE IF NOT EXISTS` statements
453 + /// The same `CREATE TABLE IF NOT EXISTS` statements
138 454 /// [`SyncSchema::migration_sql`](super::schema::SyncSchema::migration_sql) emits,
139 455 /// available on their own so an app can have a directory without building a
140 456 /// `SyncStore`. Nothing here is synced, so there is no manifest to be consistent
141 457 /// with and no ordering against the rest of the sync DDL to respect.
142 458 pub const DDL: &str = "\
459 + -- The groups this user belongs to, and who is in them.
460 + --
461 + -- A DIRECTORY, not synced state. Every table here is the server's answer written
462 + -- down: the sync loop already asks the server which groups to sync on every
463 + -- cycle, and until 2026-08-24 it kept `(id, gck_version)` and dropped the rest
464 + -- of each record on the floor.
465 + --
466 + -- WHY IT HAS TO BE LOCAL. A described screen's handler is a synchronous
467 + -- function, so a screen that needs the group list cannot fetch it. That is not a
468 + -- quasi limitation to work around but the property that lets one description
469 + -- serve a webview, a terminal and an egui window: nothing in a description
470 + -- blocks. The directory is how the answer gets to this side of the network
471 + -- before the question is asked.
472 + --
473 + -- Local-only by construction, like `sync_conflict_stash`: absent from every
474 + -- `SyncSchema`, so it is never group-scoped, never pushed, never on a shared
475 + -- changelog, and it does NOT move the storage version. It is a cache of the
476 + -- server's own state, so the server always wins and there is nothing to merge.
477 + --
478 + -- STALENESS IS A FACT, NOT A FAILURE. `refreshed_at` is what a screen states
479 + -- when it says how current the list is. A directory that has never been
480 + -- refreshed is empty rather than wrong.
143 481 CREATE TABLE IF NOT EXISTS sync_groups (
144 482 group_id TEXT PRIMARY KEY NOT NULL,
145 483 name TEXT NOT NULL,
146 484 gck_version INTEGER NOT NULL,
485 + -- Whether this user administers the group, which decides whether the
486 + -- member list and the invitation list below can be fetched at all.
147 487 is_admin INTEGER NOT NULL DEFAULT 0,
148 488 refreshed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
149 489 );
490 + -- Who is in each group, for the groups this user administers.
491 + --
492 + -- Only an admin may list members, so for a group this user merely belongs to
493 + -- there are no rows and that is correct rather than missing. A screen reads
494 + -- whether the user administers a group from `sync_groups.is_admin` rather than
495 + -- by finding the member list empty, since those are different facts.
150 496 CREATE TABLE IF NOT EXISTS sync_group_members (
151 497 group_id TEXT NOT NULL,
152 498 user_id TEXT NOT NULL,
@@ -156,6 +502,46 @@
156 502 refreshed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
157 503 PRIMARY KEY (group_id, user_id)
158 504 ) WITHOUT ROWID;
505 + -- Invitations outstanding on each group this user administers.
506 + --
507 + -- Fetched on the same terms as the member list, in the same breath, and gated on
508 + -- the same `is_admin`. An invitation is a state machine rather than an act: it
509 + -- persists across states, and two of the transitions happen without this device
510 + -- doing anything (the invitee accepts, the deadline passes). So it is a list to
511 + -- be refreshed, not a queued write that resolves.
512 + CREATE TABLE IF NOT EXISTS sync_invitations (
513 + invitation_id TEXT PRIMARY KEY NOT NULL,
514 + group_id TEXT NOT NULL,
515 + state TEXT NOT NULL,
516 + invitee_email TEXT,
517 + -- The fingerprint, never the key. Derived on the way in, so the raw key is
518 + -- not in the directory at all and a screen cannot draw the wrong one.
519 + invitee_fingerprint TEXT,
520 + -- The one-use code, and the only column here the server did not send. It
521 + -- keeps a hash and cannot re-issue it, so a code dropped between creating an
522 + -- invitation and drawing it is gone. Held only while the invitation is
523 + -- pending: `write_invitations` nulls it on any other state, because once
524 + -- somebody has accepted, the code has done its whole job and showing it
525 + -- again is exposure with no use.
526 + token TEXT,
527 + expires_at TEXT NOT NULL,
528 + created_at TEXT NOT NULL,
529 + refreshed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
530 + );
531 + CREATE INDEX IF NOT EXISTS idx_sync_invitations_group ON sync_invitations(group_id);
532 + CREATE TABLE IF NOT EXISTS sync_invitation_previews (
533 + -- Keyed by the code, because that is all the holder of one has. A previewed
534 + -- invitation has no group this device belongs to and no invitation id: the
535 + -- server tells an outsider what a code leads to and nothing more, so this
536 + -- shares no shape with `sync_invitations` and does not share its table.
537 + token TEXT PRIMARY KEY NOT NULL,
538 + group_name TEXT NOT NULL,
539 + inviter_email TEXT NOT NULL,
540 + redeemable INTEGER NOT NULL,
541 + state TEXT NOT NULL,
542 + expires_at TEXT NOT NULL,
543 + refreshed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
544 + );
159 545 ";
160 546
161 547 /// Create the directory tables if they are not there.
@@ -318,6 +704,29 @@
318 704 }
319 705 }
320 706
707 + fn invite(id: u128, group: u128, state: InvitationState) -> KnownInvitation {
708 + KnownInvitation {
709 + id: InvitationId::new(uuid::Uuid::from_u128(id)),
710 + group_id: GroupId::new(uuid::Uuid::from_u128(group)),
711 + state,
712 + invitee_email: None,
713 + invitee_fingerprint: None,
714 + token: None,
715 + expires_at: "2099-01-01T00:00:00+00:00".to_owned(),
716 + created_at: format!("2026-08-{id:02}T00:00:00+00:00"),
717 + }
718 + }
719 +
720 + fn issued(id: u128, token: &str) -> crate::types::GroupInvitation {
721 + crate::types::GroupInvitation {
722 + id: InvitationId::new(uuid::Uuid::from_u128(id)),
723 + token: token.to_owned(),
724 + expires_at: chrono::DateTime::parse_from_rfc3339("2099-01-01T00:00:00+00:00")
725 + .unwrap()
726 + .with_timezone(&chrono::Utc),
727 + }
728 + }
729 +
321 730 fn member(email: &str, added_at: &str) -> KnownMember {
322 731 KnownMember {
323 732 user_id: format!("user-{email}"),
@@ -495,8 +904,12 @@
495 904 /// sync DDL has never run and the tables are absent. Every read answers as
496 905 /// an empty directory, because that is the same fact: this device knows of
497 906 /// no groups.
498 - /// The DDL const and what `migration_sql` emits are the same statements, so
499 - /// an app that takes either route gets the same tables.
907 + /// Both routes into the directory run [`DDL`] itself: `migration_sql`
908 + /// concatenates it rather than restating it, so an app that builds a
909 + /// `SyncStore` and one that only calls `ensure_tables` get the same tables by
910 + /// construction. This holds the concatenation in place; before 2026-08-24 the
911 + /// statements were spelled out in both files and this test was the only thing
912 + /// standing between them and drift.
500 913 #[test]
501 914 fn the_standalone_ddl_agrees_with_the_migration() {
502 915 let conn = Connection::open_in_memory().expect("a database");
@@ -504,9 +917,19 @@
504 917 assert!(present(&conn).unwrap());
505 918
506 919 let schema = SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])]);
507 - for table in ["sync_groups", "sync_group_members"] {
920 + for table in [
921 + "sync_groups",
922 + "sync_group_members",
923 + "sync_invitations",
924 + "sync_invitation_previews",
925 + ] {
508 926 assert!(schema.migration_sql().contains(table), "{table}");
509 927 }
928 +
929 + // Every statement, not merely the table names: a `migration_sql` that
930 + // reworded the directory would pass the check above and still be a
931 + // second definition.
932 + assert!(schema.migration_sql().contains(DDL));
510 933 }
Lines truncated
@@ -236,6 +236,23 @@
236 236 .await
237 237 .map_err(|e| join_err(&e))??;
238 238 }
239 +
240 + // Invitations, on exactly the terms members are fetched on and in
241 + // the same breath: admin-only because the server refuses anyone
242 + // else, best-effort because an invitation list is directory data and
243 + // no sync correctness rests on it, and a failure leaves the previous
244 + // answer standing rather than blanking a list a screen is drawing.
245 + if group.is_admin
246 + && let Ok(invitations) = self.client.list_group_invitations(group.id).await
247 + {
248 + let db = self.db.clone();
249 + let id = group.id;
250 + tokio::task::spawn_blocking(move || -> Result<()> {
251 + super::directory::write_invitations(&mut db.open()?, id, &invitations)
252 + })
253 + .await
254 + .map_err(|e| join_err(&e))??;
255 + }
239 256 }
240 257
241 258 let blobs = match &self.blob_policy {
@@ -589,6 +606,9 @@
589 606 async move { Ok(groups) }
590 607 }
591 608
609 + // `impl Future` rather than `async fn`, matching the trait's own
610 + // convention: the store's spawned tasks need the explicit Send bound.
611 + #[allow(clippy::manual_async_fn)]
592 612 fn list_group_members(
593 613 &self,
594 614 group_id: crate::ids::GroupId,
@@ -603,6 +623,25 @@
603 623 }
604 624 }
605 625
626 + #[allow(clippy::manual_async_fn)]
627 + fn list_group_invitations(
628 + &self,
629 + group_id: crate::ids::GroupId,
630 + ) -> impl Future<Output = Result<Vec<super::super::sync::KnownInvitation>>> + Send {
631 + async move {
632 + Ok(vec![super::super::sync::KnownInvitation {
633 + id: crate::ids::InvitationId::new(uuid::Uuid::from_u128(42)),
634 + group_id,
635 + state: crate::types::InvitationState::Accepted,
636 + invitee_email: Some("invitee@localhost".to_owned()),
637 + invitee_fingerprint: Some("AAAA-BBBB".to_owned()),
638 + token: None,
639 + expires_at: "2099-01-01T00:00:00+00:00".to_owned(),
640 + created_at: "2026-08-24T00:00:00+00:00".to_owned(),
641 + }])
642 + }
643 + }
644 +
606 645 fn group_scope_push(
607 646 &self,
608 647 group_id: crate::ids::GroupId,
@@ -803,6 +842,21 @@
803 842 let people = super::super::directory::members(&conn, gid).unwrap();
804 843 assert_eq!(people.len(), 1);
805 844 assert_eq!(people[0].email, "member@localhost");
845 +
846 + // And the invitations path beside it, on the same admin gate. This is
847 + // what lets a described screen draw an invitation at all.
848 + let invites = super::super::directory::invitations(&conn, gid).unwrap();
849 + assert_eq!(invites.len(), 1);
850 + assert_eq!(
851 + invites[0].invitee_email.as_deref(),
852 + Some("invitee@localhost")
853 + );
854 + assert_eq!(
855 + invites[0].token, None,
856 + "the server never sends a token back"
857 + );
858 + let waiting = super::super::directory::pending_confirmations(&conn).unwrap();
859 + assert_eq!(waiting.len(), 1, "an accepted invitation wants the admin");
806 860 }
807 861
808 862 /// Pre-stamp a device's pending edit at a controlled time so cross-device HLC
@@ -59,54 +59,6 @@
59 59 INSERT OR IGNORE INTO sync_scope_cursor (scope, cursor)
60 60 VALUES ('', CAST((SELECT value FROM sync_state WHERE key = 'pull_cursor') AS INTEGER));
61 61
62 - -- The groups this user belongs to, and who is in them.
63 - --
64 - -- A DIRECTORY, not synced state. Both tables are the server's answer written
65 - -- down: the sync loop already asks the server which groups to sync on every
66 - -- cycle, and until 2026-08-24 it kept `(id, gck_version)` and dropped the rest
67 - -- of each record on the floor. Keeping it costs no request that was not already
68 - -- being made.
69 - --
70 - -- WHY IT HAS TO BE LOCAL. A described screen's handler is a synchronous
71 - -- function, so a screen that needs the group list cannot fetch it. That is not a
72 - -- quasi limitation to work around but the property that lets one description
73 - -- serve a webview, a terminal and an egui window: nothing in a description
74 - -- blocks. The directory is how the answer gets to this side of the network
75 - -- before the question is asked.
76 - --
77 - -- Local-only by construction, like `sync_conflict_stash`: absent from every
78 - -- `SyncSchema`, so it is never group-scoped, never pushed, never on a shared
79 - -- changelog, and it does NOT move the storage version. It is a cache of the
80 - -- server's own state, so the server always wins and there is nothing to merge.
81 - --
82 - -- STALENESS IS A FACT, NOT A FAILURE. `refreshed_at` is what a screen states
83 - -- when it says how current the list is. A directory that has never been
84 - -- refreshed is empty rather than wrong.
85 - CREATE TABLE IF NOT EXISTS sync_groups (
86 - group_id TEXT PRIMARY KEY NOT NULL,
87 - name TEXT NOT NULL,
88 - gck_version INTEGER NOT NULL,
89 - -- Whether this user administers the group, which decides whether the
90 - -- member list below can be fetched at all.
91 - is_admin INTEGER NOT NULL DEFAULT 0,
92 - refreshed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
93 - );
94 -
95 - -- Who is in each group, for the groups this user administers.
96 - --
97 - -- Only an admin may list members, so for a group this user merely belongs to
98 - -- there are no rows and that is correct rather than missing. A screen reads
99 - -- whether the user administers a group from `sync_groups.is_admin` rather than
100 - -- by finding the member list empty, since those are different facts.
101 - CREATE TABLE IF NOT EXISTS sync_group_members (
102 - group_id TEXT NOT NULL,
103 - user_id TEXT NOT NULL,
104 - email TEXT NOT NULL,
105 - role TEXT NOT NULL,
106 - added_at TEXT NOT NULL,
107 - refreshed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
108 - PRIMARY KEY (group_id, user_id)
109 - ) WITHOUT ROWID;
110 62
111 63 -- Committed HLC per row (conflict gating).
112 64 CREATE TABLE IF NOT EXISTS sync_committed_hlc (
@@ -163,6 +115,13 @@
163 115 /// ledger, an optional `row_id_salt`, and every table's triggers.
164 116 pub fn migration_sql(&self) -> String {
165 117 let mut out = String::from(BASE_TABLES);
118 + // The directory tables, from the module that owns them. Emitted here
119 + // rather than restated: they were spelled out twice, in `BASE_TABLES`
120 + // and in `directory::DDL`, and a third and fourth table would have made
121 + // that four places to keep identical. `directory::DDL` is the one
122 + // definition, and `ensure_tables` runs the same string for an app that
123 + // wants a directory without a `SyncStore`.
124 + out.push_str(super::directory::DDL);
166 125 out.push_str(super::deferred::DEFERRED_DDL);
167 126 out.push_str(super::snapshot::SNAPSHOT_DDL);
168 127 if self.any_hashed() {
@@ -29,8 +29,8 @@
29 29 use super::snapshot;
30 30 use crate::client::SyncKitClient;
31 31 use crate::error::{Result, SyncKitError};
32 - use crate::ids::{DeviceId, GroupId};
33 - use crate::types::{ChangeEntry, ChangeOp, Device, Hlc, PulledChange};
32 + use crate::ids::{DeviceId, GroupId, InvitationId};
33 + use crate::types::{ChangeEntry, ChangeOp, Device, Hlc, InvitationState, PulledChange};
34 34
35 35 /// A sync scope: the personal changelog, or one group's shared changelog. Each
36 36 /// scope has its own changelog partition (`sync_changelog.scope`), pull cursor
@@ -98,6 +98,100 @@
98 98 pub added_at: String,
99 99 }
100 100
101 + /// One invitation to a group this user administers, as the sync loop learns it.
102 + ///
103 + /// What [`SyncTransport::list_group_invitations`] returns, and what the store
104 + /// writes into `sync_invitations`.
105 + ///
106 + /// The fingerprint is derived here rather than carried: the server sends the
107 + /// invitee's public key, and an admin must be shown the fingerprint of it and
108 + /// never the key. A key that will not parse yields `None` rather than a
109 + /// placeholder, so nothing confirmable-looking is ever drawn from something that
110 + /// was not a real key.
111 + ///
112 + /// The token is deliberately absent. The server keeps only a hash of it and
113 + /// cannot re-send it, so the one-use code lives in the directory's own column,
114 + /// written by whoever issued the invitation. See
115 + /// [`record_issued`](super::directory::record_issued).
116 + #[derive(Debug, Clone, PartialEq, Eq)]
117 + pub struct KnownInvitation {
118 + /// The invitation's id, for confirming or revoking it.
119 + pub id: InvitationId,
120 + /// The group it admits somebody to.
121 + pub group_id: GroupId,
122 + /// Where it has got to, as the server last reported it. Read
123 + /// [`effective_state`](KnownInvitation::effective_state) instead when
124 + /// drawing it, which accounts for a deadline that has passed since.
125 + pub state: InvitationState,
126 + /// The accepting account's email, once somebody has accepted.
127 + pub invitee_email: Option<String>,
128 + /// The fingerprint of the accepting account's identity public key, once
129 + /// somebody has accepted. This is what an admin compares out of band.
130 + pub invitee_fingerprint: Option<String>,
131 + /// The one-use code, held only while the invitation is pending, and only on
132 + /// the device that issued it. `None` everywhere else.
133 + pub token: Option<String>,
134 + /// When the link stops being redeemable, RFC 3339.
135 + pub expires_at: String,
136 + /// When it was issued, RFC 3339.
137 + pub created_at: String,
138 + }
139 +
140 + impl KnownInvitation {
141 + /// Whether the deadline has passed, computed now rather than waited for.
142 + ///
143 + /// An unparseable timestamp reads as not expired: the row came from the
144 + /// server, and treating a value this build cannot read as a dead invitation
145 + /// would hide a live one.
146 + #[must_use]
147 + pub fn is_past_deadline(&self) -> bool {
148 + chrono::DateTime::parse_from_rfc3339(&self.expires_at)
149 + .is_ok_and(|at| at.with_timezone(&Utc) < Utc::now())
150 + }
151 +
152 + /// The state to draw, with the deadline accounted for.
153 + ///
154 + /// A directory is refreshed on a cycle and a deadline passes on its own, so
155 + /// between two refreshes the stored state can say `Pending` for a code that
156 + /// stopped working. Deriving it here means a screen never offers a dead code
157 + /// as a live one, and it costs no request.
158 + ///
159 + /// Only `Pending` derives. An `Accepted` invitation is waiting on an admin,
160 + /// not on the invitee, and the deadline it carries was the deadline for
161 + /// redeeming the link, which somebody already did.
162 + #[must_use]
163 + pub fn effective_state(&self) -> InvitationState {
164 + if self.state == InvitationState::Pending && self.is_past_deadline() {
165 + InvitationState::Expired
166 + } else {
167 + self.state
168 + }
169 + }
170 + }
171 +
172 + /// What an invite code this user was given leads to, written down after a
173 + /// preview.
174 + ///
175 + /// Not part of the group directory and not refreshed by the sync loop: the
176 + /// person holding the code is not in the group, so there is no scope to hang it
177 + /// off and nothing to refresh it against. It is a one-shot read the app performs
178 + /// when a code is pasted, kept so the answer survives until they decide.
179 + #[derive(Debug, Clone, PartialEq, Eq)]
180 + pub struct KnownPreview {
181 + /// The code that was pasted, normalised.
182 + pub token: String,
183 + /// The group it admits to.
184 + pub group_name: String,
185 + /// The inviting admin's account email.
186 + pub inviter_email: String,
187 + /// Whether accepting will work. False for every terminal state alike.
188 + pub redeemable: bool,
189 + /// Where the invitation had got to when it was read.
190 + pub state: InvitationState,
191 + /// When the link stops being redeemable, RFC 3339.
192 + pub expires_at: String,
193 + }
194 +
101 195 /// The two operations the sync loops need from a server.
102 196 ///
103 197 /// A seam for testing (an in-memory fake) and the reference point a future
@@ -167,6 +261,22 @@
167 261 async move { Ok(Vec::new()) }
168 262 }
169 263
264 + /// The invitations outstanding on one group this user administers.
265 + ///
266 + /// Best-effort and admin-only on exactly the same terms as
267 + /// [`list_group_members`](SyncTransport::list_group_members), and asked for
268 + /// in the same breath: an invitation list is directory data, no sync
269 + /// correctness rests on it, and a refusal is "no answer this cycle" rather
270 + /// than a sync failure. Defaults to none, for the same reason the directory
271 + /// does.
272 + #[allow(clippy::manual_async_fn)]
273 + fn list_group_invitations(
274 + &self,
275 + _group_id: GroupId,
276 + ) -> impl Future<Output = Result<Vec<KnownInvitation>>> + Send {
277 + async move { Ok(Vec::new()) }
278 + }
279 +
170 280 /// Push encrypted changes to a group's shared changelog. The implementation
171 281 /// resolves the group's GCK internally; the caller never handles keys. The
172 282 /// default errors, it is only reached if [`list_group_scopes`] returns a
@@ -275,6 +385,37 @@
275 385 }
276 386 }
277 387
388 + fn list_group_invitations(
389 + &self,
390 + group_id: GroupId,
391 + ) -> impl Future<Output = Result<Vec<KnownInvitation>>> + Send {
392 + async move {
393 + Ok(self
394 + .list_invitations(group_id)
395 + .await?
396 + .into_iter()
397 + .map(|i| KnownInvitation {
398 + id: i.id,
399 + group_id,
400 + state: i.state,
401 + invitee_email: i.invitee_email,
402 + // Derived here, so the raw key never reaches a caller and
403 + // never reaches the directory. A key that will not parse
404 + // yields no fingerprint rather than a placeholder: an admin
405 + // must never be shown something confirmable-looking that was
406 + // not derived from a real key.
407 + invitee_fingerprint: i.invitee_pubkey.as_deref().and_then(|k| {
408 + crate::identity::IdentityPublicKey::fingerprint_of_base64(k).ok()
409 + }),
410 + // Never the server's to give: it keeps only a hash.
411 + token: None,
412 + expires_at: i.expires_at.to_rfc3339(),
413 + created_at: i.created_at.to_rfc3339(),
414 + })
415 + .collect())
416 + }
417 + }
418 +
278 419 fn group_scope_push(
279 420 &self,
280 421 group_id: GroupId,