Skip to main content

max / makenotwork

35.1 KB · 937 lines History Blame Raw
1 //! SyncKit group management and group-scoped push/pull.
2 //!
3 //! A group is a shared, end-to-end-encrypted changelog. The admin mints a Group
4 //! Content Key (GCK) client-side and seals it to each member's identity public
5 //! key; the server stores membership and the opaque sealed grants and never sees
6 //! the GCK or any plaintext. Group push/pull are gated on membership; management
7 //! actions (add/remove member) are gated on being the group admin.
8 //!
9 //! Member public keys are stored on add, and GCK rotation is exposed at
10 //! `/groups/{id}/rotate`: the grant set the admin posts becomes the new
11 //! membership, so removing a member and re-keying the group are one transaction.
12 //!
13 //! The paid-write gate is implemented: `group_push` checks the group admin's
14 //! entitlement and answers 402 `no_subscription`. Still deferred, to p3: SSE
15 //! push notifications for groups, noted where they would hook in. Design: wiki
16 //! synckit-groups-design.
17
18 use axum::{
19 Json,
20 extract::{Path, Query, State},
21 http::StatusCode,
22 response::{IntoResponse, Response},
23 };
24 use chrono::{Duration, Utc};
25 use serde_json::json;
26 use sqlx::PgPool;
27
28 use crate::{
29 constants,
30 db::{self, DbSyncGroup, SyncGroupId, SyncGroupInvitationId, UserId},
31 error::{AppError, Result},
32 synckit_auth::SyncUser,
33 validation,
34 };
35
36 use super::{
37 AcceptInvitationRequest, AddMemberRequest, ConfirmInvitationRequest, CreateGroupRequest,
38 CreateInvitationRequest, CreateInvitationResponse, GrantQuery, GroupGrantResponse,
39 GroupMemberPubkey, GroupMemberResponse, GroupResponse, InvitationPreviewResponse,
40 InvitationResponse, PullChangeEntry, PullRequest, PullResponse, PushRequest, PushResponse,
41 RotateGroupKeyRequest,
42 };
43
44 /// Fetch a group scoped to the caller's app, or 404. Guards every group handler
45 /// against cross-app id guessing before any membership check.
46 async fn require_group(
47 db: &PgPool,
48 app_id: db::SyncAppId,
49 group_id: SyncGroupId,
50 ) -> Result<DbSyncGroup> {
51 db::synckit::get_group(db, app_id, group_id)
52 .await?
53 .ok_or(AppError::NotFound)
54 }
55
56 /// Reject a caller who is not a member of the group (403). The gate for
57 /// group-scoped reads and writes.
58 async fn require_member(db: &PgPool, group_id: SyncGroupId, user_id: UserId) -> Result<()> {
59 if db::synckit::is_group_member(db, group_id, user_id).await? {
60 Ok(())
61 } else {
62 Err(AppError::Forbidden)
63 }
64 }
65
66 /// Reject a caller who is not the group admin (403). The gate for membership
67 /// management.
68 async fn require_admin(db: &PgPool, group_id: SyncGroupId, user_id: UserId) -> Result<()> {
69 if db::synckit::is_group_admin(db, group_id, user_id).await? {
70 Ok(())
71 } else {
72 Err(AppError::Forbidden)
73 }
74 }
75
76 /// Create a group. The caller becomes its admin and first member, carrying the
77 /// GCK they sealed to their own identity key.
78 #[utoipa::path(post, path = "/api/v1/sync/groups", tag = "SyncKit",
79 request_body = CreateGroupRequest,
80 responses((status = 200, description = "Created group", body = GroupResponse)),
81 security(("bearer" = [])),
82 )]
83 #[tracing::instrument(skip_all, name = "synckit::create_group")]
84 pub(super) async fn create_group(
85 State(db): State<PgPool>,
86 sync_user: SyncUser,
87 Json(req): Json<CreateGroupRequest>,
88 ) -> Result<impl IntoResponse> {
89 validation::validate_sync_group_name(&req.name)?;
90 if req.admin_sealed_gck.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES
91 || req.admin_pubkey.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES
92 {
93 return Err(AppError::BadRequest(
94 "Sealed key exceeds size limit".to_string(),
95 ));
96 }
97
98 let group = db::synckit::create_group(
99 &db,
100 req.id,
101 sync_user.app_id,
102 sync_user.user_id,
103 &req.name,
104 &req.admin_sealed_gck,
105 &req.admin_pubkey,
106 )
107 .await?;
108
109 Ok(Json(GroupResponse::from(group)))
110 }
111
112 /// List the groups the caller belongs to within this app.
113 #[utoipa::path(get, path = "/api/v1/sync/groups", tag = "SyncKit",
114 responses((status = 200, description = "Groups the user belongs to", body = Vec<GroupResponse>)),
115 security(("bearer" = [])),
116 )]
117 #[tracing::instrument(skip_all, name = "synckit::list_groups")]
118 pub(super) async fn list_groups(
119 State(db): State<PgPool>,
120 sync_user: SyncUser,
121 ) -> Result<impl IntoResponse> {
122 let groups =
123 db::synckit::list_groups_for_user(&db, sync_user.app_id, sync_user.user_id).await?;
124 let response: Vec<GroupResponse> = groups.into_iter().map(GroupResponse::from).collect();
125 Ok(Json(response))
126 }
127
128 /// Add a member to a group (or replace their grant). Admin only.
129 ///
130 /// The admin resolves the member out of band, seals the current GCK to that
131 /// member's public key, and posts `{member_email, sealed_gck}`. The server maps
132 /// the email to a verified account and stores the opaque grant at the group's
133 /// current GCK generation.
134 #[utoipa::path(post, path = "/api/v1/sync/groups/{id}/members", tag = "SyncKit",
135 params(("id" = String, Path, description = "Group ID")),
136 request_body = AddMemberRequest,
137 responses((status = 204, description = "Member added"), (status = 403, description = "Not the group admin")),
138 security(("bearer" = [])),
139 )]
140 #[tracing::instrument(skip_all, name = "synckit::add_group_member")]
141 pub(super) async fn add_member(
142 State(db): State<PgPool>,
143 sync_user: SyncUser,
144 Path(group_id): Path<SyncGroupId>,
145 Json(req): Json<AddMemberRequest>,
146 ) -> Result<impl IntoResponse> {
147 let group = require_group(&db, sync_user.app_id, group_id).await?;
148 require_admin(&db, group_id, sync_user.user_id).await?;
149
150 if req.sealed_gck.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES
151 || req.member_pubkey.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES
152 {
153 return Err(AppError::BadRequest(
154 "Sealed key exceeds size limit".to_string(),
155 ));
156 }
157 let role = req.role.as_deref().unwrap_or("member");
158 if role != "member" && role != "admin" {
159 return Err(AppError::BadRequest(
160 "role must be 'member' or 'admin'".to_string(),
161 ));
162 }
163
164 let email = db::Email::new(&req.member_email)
165 .map_err(|_| AppError::BadRequest("Invalid email address".to_string()))?;
166 let member_id = db::users::get_verified_user_id_by_email(&db, &email)
167 .await?
168 .ok_or_else(|| AppError::BadRequest("No verified account for that email".to_string()))?;
169
170 // The grant the admin sends is sealed under the group's current GCK, so it is
171 // stored at that generation, along with the member's public key (for re-seal
172 // on a later rotation).
173 db::synckit::add_or_update_member(
174 &db,
175 group_id,
176 member_id,
177 role,
178 &req.sealed_gck,
179 group.gck_version,
180 &req.member_pubkey,
181 )
182 .await?;
183
184 Ok(StatusCode::NO_CONTENT)
185 }
186
187 /// List every member's identity public key. Admin only: the admin re-seals a
188 /// rotated GCK to each of these on member removal.
189 #[utoipa::path(get, path = "/api/v1/sync/groups/{id}/pubkeys", tag = "SyncKit",
190 params(("id" = String, Path, description = "Group ID")),
191 responses((status = 200, description = "Member public keys", body = Vec<GroupMemberPubkey>)),
192 security(("bearer" = [])),
193 )]
194 #[tracing::instrument(skip_all, name = "synckit::list_group_pubkeys")]
195 pub(super) async fn list_pubkeys(
196 State(db): State<PgPool>,
197 sync_user: SyncUser,
198 Path(group_id): Path<SyncGroupId>,
199 ) -> Result<impl IntoResponse> {
200 require_group(&db, sync_user.app_id, group_id).await?;
201 require_admin(&db, group_id, sync_user.user_id).await?;
202
203 let pubkeys = db::synckit::list_member_pubkeys(&db, group_id).await?;
204 let response: Vec<GroupMemberPubkey> = pubkeys
205 .into_iter()
206 .map(|(user_id, pubkey)| GroupMemberPubkey { user_id, pubkey })
207 .collect();
208 Ok(Json(response))
209 }
210
211 /// Rotate the group's Group Content Key. Admin only.
212 ///
213 /// The admin mints a fresh GCK client-side, seals it to each remaining member's
214 /// stored public key (from `/pubkeys`), and posts the batch. The server bumps the
215 /// generation, drops anyone absent from the batch, and stores the new grants in
216 /// one transaction.
217 ///
218 /// The grant set IS the new membership, which is what makes removal and re-key
219 /// atomic: there is no window in which a removed member's key is still current.
220 /// A member added between the admin's `/pubkeys` read and this call would be
221 /// absent from the batch and dropped, so an admin racing itself loses a member
222 /// rather than leaking a key. Re-adding is one call; the alternative failure is
223 /// silent.
224 #[utoipa::path(post, path = "/api/v1/sync/groups/{id}/rotate", tag = "SyncKit",
225 params(("id" = String, Path, description = "Group ID")),
226 request_body = RotateGroupKeyRequest,
227 responses(
228 (status = 204, description = "Key rotated"),
229 (status = 400, description = "Stale generation, or a grant set the server will not act on"),
230 (status = 403, description = "Not the group admin"),
231 ),
232 security(("bearer" = [])),
233 )]
234 #[tracing::instrument(skip_all, name = "synckit::rotate_group_key")]
235 pub(super) async fn rotate_key(
236 State(db): State<PgPool>,
237 sync_user: SyncUser,
238 Path(group_id): Path<SyncGroupId>,
239 Json(req): Json<RotateGroupKeyRequest>,
240 ) -> Result<impl IntoResponse> {
241 let group = require_group(&db, sync_user.app_id, group_id).await?;
242 require_admin(&db, group_id, sync_user.user_id).await?;
243
244 // A generation that does not advance would re-point every member at a key
245 // some previously-removed member may still hold.
246 if req.gck_version <= group.gck_version {
247 return Err(AppError::BadRequest(format!(
248 "gck_version must be greater than the current generation ({})",
249 group.gck_version
250 )));
251 }
252
253 if req.grants.is_empty() {
254 return Err(AppError::BadRequest(
255 "A rotation must carry at least the admin's own grant".to_string(),
256 ));
257 }
258 if req
259 .grants
260 .iter()
261 .any(|g| g.sealed_gck.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES)
262 {
263 return Err(AppError::BadRequest(
264 "Sealed key exceeds size limit".to_string(),
265 ));
266 }
267
268 // The admin must be able to read the group afterwards. `rotate_group_gck`
269 // deletes everyone outside the batch, so omitting the admin's own grant
270 // orphans the group; the db layer states this as a precondition and nothing
271 // enforced it.
272 if !req.grants.iter().any(|g| g.user_id == group.admin_user_id) {
273 return Err(AppError::BadRequest(
274 "The rotation must include the admin's own re-sealed grant".to_string(),
275 ));
276 }
277
278 // Rotation re-seals; it does not recruit. A grant for a non-member would be a
279 // silent no-op in the db layer's UPDATE, so reject it here rather than let an
280 // admin believe someone was added.
281 let members: std::collections::HashSet<UserId> =
282 db::synckit::list_member_pubkeys(&db, group_id)
283 .await?
284 .into_iter()
285 .map(|(user_id, _)| user_id)
286 .collect();
287 let mut seen = std::collections::HashSet::with_capacity(req.grants.len());
288 for grant in &req.grants {
289 if !members.contains(&grant.user_id) {
290 return Err(AppError::BadRequest(
291 "A rotation grant names someone who is not a member; add members separately"
292 .to_string(),
293 ));
294 }
295 if !seen.insert(grant.user_id) {
296 return Err(AppError::BadRequest(
297 "Duplicate grant for the same member".to_string(),
298 ));
299 }
300 }
301
302 let grants: Vec<(UserId, String)> = req
303 .grants
304 .into_iter()
305 .map(|g| (g.user_id, g.sealed_gck))
306 .collect();
307 let removed = members.len().saturating_sub(grants.len());
308 db::synckit::rotate_group_gck(&db, group_id, req.gck_version, &grants).await?;
309
310 tracing::info!(
311 %group_id,
312 gck_version = req.gck_version,
313 remaining = grants.len(),
314 removed,
315 "rotated group content key"
316 );
317
318 Ok(StatusCode::NO_CONTENT)
319 }
320
321 /// Remove a member from a group. Admin only.
322 ///
323 /// Revocation only: it drops the member from future group writes but leaves the
324 /// GCK generation alone, so a member who kept a copy of the key can still read
325 /// any group ciphertext they can obtain. Forward secrecy comes from
326 /// [`rotate_key`], which removes and re-keys in one transaction and is what
327 /// `SyncKitClient::remove_member` drives. This endpoint remains for a caller that
328 /// wants revocation without a re-key. Data the member already pulled is already
329 /// in their hands either way.
330 #[utoipa::path(delete, path = "/api/v1/sync/groups/{id}/members/{user_id}", tag = "SyncKit",
331 params(
332 ("id" = String, Path, description = "Group ID"),
333 ("user_id" = String, Path, description = "Member user ID"),
334 ),
335 responses((status = 204, description = "Member removed"), (status = 404, description = "Not a member")),
336 security(("bearer" = [])),
337 )]
338 #[tracing::instrument(skip_all, name = "synckit::remove_group_member")]
339 pub(super) async fn remove_member(
340 State(db): State<PgPool>,
341 sync_user: SyncUser,
342 Path((group_id, member_id)): Path<(SyncGroupId, UserId)>,
343 ) -> Result<impl IntoResponse> {
344 let group = require_group(&db, sync_user.app_id, group_id).await?;
345 require_admin(&db, group_id, sync_user.user_id).await?;
346
347 // The admin cannot remove themselves; that would orphan the group. Deleting a
348 // group is a separate action (not yet exposed).
349 if member_id == group.admin_user_id {
350 return Err(AppError::BadRequest(
351 "The group admin cannot be removed".to_string(),
352 ));
353 }
354
355 if !db::synckit::remove_member(&db, group_id, member_id).await? {
356 return Err(AppError::NotFound);
357 }
358
359 Ok(StatusCode::NO_CONTENT)
360 }
361
362 /// List a group's members (id, role, joined-at). Members only. Grants are not
363 /// included; each member fetches only their own via `/grant`.
364 #[utoipa::path(get, path = "/api/v1/sync/groups/{id}/members", tag = "SyncKit",
365 params(("id" = String, Path, description = "Group ID")),
366 responses((status = 200, description = "Group members", body = Vec<GroupMemberResponse>)),
367 security(("bearer" = [])),
368 )]
369 #[tracing::instrument(skip_all, name = "synckit::list_group_members")]
370 pub(super) async fn list_members(
371 State(db): State<PgPool>,
372 sync_user: SyncUser,
373 Path(group_id): Path<SyncGroupId>,
374 ) -> Result<impl IntoResponse> {
375 require_group(&db, sync_user.app_id, group_id).await?;
376 require_member(&db, group_id, sync_user.user_id).await?;
377
378 let members = db::synckit::list_members(&db, group_id).await?;
379 let response: Vec<GroupMemberResponse> = members
380 .into_iter()
381 .map(|m| GroupMemberResponse {
382 user_id: m.user_id,
383 email: m.email,
384 role: m.role,
385 added_at: m.added_at,
386 })
387 .collect();
388 Ok(Json(response))
389 }
390
391 /// Fetch the caller's own sealed GCK grant for a group, so their device can open
392 /// the GCK and read the group changelog. Members only.
393 ///
394 /// Without `version`, returns the newest grant the caller holds, which is what a
395 /// device wants in order to write. With `version`, returns the grant for that
396 /// generation, which is what a device wants in order to read an entry pushed
397 /// before a rotation. A generation the caller never held is 403, the same answer
398 /// as not being a member: a non-member must not be able to probe which
399 /// generations exist.
400 #[utoipa::path(get, path = "/api/v1/sync/groups/{id}/grant", tag = "SyncKit",
401 params(
402 ("id" = String, Path, description = "Group ID"),
403 ("version" = Option<i32>, Query, description = "GCK generation; omit for the newest"),
404 ),
405 responses(
406 (status = 200, description = "The caller's sealed grant", body = GroupGrantResponse),
407 (status = 403, description = "Not a member, or never held that generation"),
408 ),
409 security(("bearer" = [])),
410 )]
411 #[tracing::instrument(skip_all, name = "synckit::get_group_grant")]
412 pub(super) async fn get_grant(
413 State(db): State<PgPool>,
414 sync_user: SyncUser,
415 Path(group_id): Path<SyncGroupId>,
416 Query(query): Query<GrantQuery>,
417 ) -> Result<impl IntoResponse> {
418 require_group(&db, sync_user.app_id, group_id).await?;
419
420 let (sealed_gck, gck_version) = match query.version {
421 Some(version) => {
422 let sealed =
423 db::synckit::get_member_grant_at(&db, group_id, sync_user.user_id, version)
424 .await?
425 .ok_or(AppError::Forbidden)?;
426 (sealed, version)
427 }
428 None => db::synckit::get_member_grant(&db, group_id, sync_user.user_id)
429 .await?
430 .ok_or(AppError::Forbidden)?,
431 };
432
433 Ok(Json(GroupGrantResponse {
434 sealed_gck,
435 gck_version,
436 }))
437 }
438
439 /// Push encrypted changes to a group's shared changelog. Members only.
440 #[utoipa::path(post, path = "/api/v1/sync/groups/{id}/push", tag = "SyncKit",
441 params(("id" = String, Path, description = "Group ID")),
442 request_body = PushRequest,
443 responses((status = 200, description = "New cursor position", body = PushResponse)),
444 security(("bearer" = [])),
445 )]
446 #[tracing::instrument(skip_all, name = "synckit::group_push", fields(group_id))]
447 pub(super) async fn group_push(
448 State(db): State<PgPool>,
449 sync_user: SyncUser,
450 Path(group_id): Path<SyncGroupId>,
451 Json(req): Json<PushRequest>,
452 ) -> Result<Response> {
453 let group = require_group(&db, sync_user.app_id, group_id).await?;
454 require_member(&db, group_id, sync_user.user_id).await?;
455
456 // Group writes bill to the admin's slot (Groups billing decision): the paid
457 // gate is checked against the *admin's* entitlement, not the pushing member's,
458 // so a member with no subscription of their own can still contribute to a
459 // group whose admin pays. Reads (group_pull) stay open, as personal pull does.
460 if !db::synckit::internal_write_allowed(&db, sync_user.app_id, group.admin_user_id).await? {
461 return Ok((
462 StatusCode::PAYMENT_REQUIRED,
463 Json(json!({ "reason": "no_subscription" })),
464 )
465 .into_response());
466 }
467 // NOTE: SSE push notification to group members is deferred to p3; members'
468 // devices pick up group changes on their next scheduler tick meanwhile.
469
470 if req.changes.is_empty() {
471 return Err(AppError::BadRequest("No changes provided".to_string()));
472 }
473 if req.changes.len() > constants::SYNCKIT_PUSH_MAX_CHANGES {
474 return Err(AppError::BadRequest(format!(
475 "Maximum {} changes per push",
476 constants::SYNCKIT_PUSH_MAX_CHANGES
477 )));
478 }
479 for change in &req.changes {
480 validation::validate_sync_table_name(&change.table)?;
481 validation::validate_sync_row_id(&change.row_id)?;
482 if change.op == db::SyncOperation::Delete && change.data.is_some() {
483 return Err(AppError::BadRequest(
484 "DELETE operations should not include data".to_string(),
485 ));
486 }
487 }
488
489 // The pushing device must belong to the pushing user (membership is a
490 // separate, group-level check above).
491 if !db::synckit::sync_device_belongs(&db, req.device_id, sync_user.app_id, sync_user.user_id)
492 .await?
493 {
494 return Err(AppError::BadRequest("Unknown device".to_string()));
495 }
496 db::synckit::touch_sync_device(&db, req.device_id).await?;
497
498 let changes: Vec<_> = req
499 .changes
500 .iter()
501 .map(|c| {
502 (
503 c.table.clone(),
504 c.op.to_string(),
505 c.row_id.clone(),
506 c.timestamp,
507 c.data.clone(),
508 )
509 })
510 .collect();
511
512 let cursor = db::synckit::push_group_changes(
513 &db,
514 sync_user.app_id,
515 group_id,
516 sync_user.user_id,
517 req.device_id,
518 req.batch_id,
519 &changes,
520 )
521 .await?;
522
523 Ok(Json(PushResponse { cursor }).into_response())
524 }
525
526 /// Pull a group's changes after a cursor. Members only.
527 #[utoipa::path(post, path = "/api/v1/sync/groups/{id}/pull", tag = "SyncKit",
528 params(("id" = String, Path, description = "Group ID")),
529 request_body = PullRequest,
530 responses((status = 200, description = "Changes since cursor", body = PullResponse)),
531 security(("bearer" = [])),
532 )]
533 #[tracing::instrument(skip_all, name = "synckit::group_pull", fields(group_id))]
534 pub(super) async fn group_pull(
535 State(db): State<PgPool>,
536 sync_user: SyncUser,
537 Path(group_id): Path<SyncGroupId>,
538 Json(req): Json<PullRequest>,
539 ) -> Result<impl IntoResponse> {
540 require_group(&db, sync_user.app_id, group_id).await?;
541 require_member(&db, group_id, sync_user.user_id).await?;
542
543 if !db::synckit::sync_device_belongs(&db, req.device_id, sync_user.app_id, sync_user.user_id)
544 .await?
545 {
546 return Err(AppError::BadRequest("Unknown device".to_string()));
547 }
548
549 if let Some(ref tables) = req.tables {
550 if tables.len() > 50 {
551 return Err(AppError::BadRequest(
552 "Maximum 50 table names per filter".to_string(),
553 ));
554 }
555 for table in tables {
556 validation::validate_sync_table_name(table)?;
557 }
558 }
559
560 let page_size = constants::SYNCKIT_PULL_PAGE_SIZE;
561 let entries = db::synckit::pull_group_changes_filtered(
562 &db,
563 sync_user.app_id,
564 group_id,
565 req.cursor,
566 page_size,
567 req.tables.as_deref(),
568 req.since,
569 )
570 .await?;
571
572 let has_more = entries.len() as i64 == page_size;
573 let new_cursor = entries.last().map_or(req.cursor, |e| e.seq);
574
575 // Touch the device for activity, but do NOT advance the per-device personal
576 // compaction cursor here; that cursor governs personal-changelog retention
577 // and must not be moved by a group pull. Group changelog retention is a
578 // separate concern (future work).
579 db::synckit::touch_sync_device(&db, req.device_id).await?;
580
581 let changes: Vec<PullChangeEntry> = entries
582 .into_iter()
583 .map(|e| PullChangeEntry {
584 seq: e.seq,
585 device_id: e.device_id,
586 table: e.table_name,
587 op: e.operation.to_string(),
588 row_id: e.row_id,
589 timestamp: e.client_timestamp,
590 data: e.data,
591 // Group entries key off the GCK generation stamped on the row, not a
592 // per-user key_id.
593 key_id: None,
594 gck_version: Some(e.gck_version),
595 })
596 .collect();
597
598 Ok(Json(PullResponse {
599 changes,
600 cursor: new_cursor,
601 has_more,
602 }))
603 }
604
605 // --- Invitations ---
606
607 /// Issue an invite link for a group. Admin only.
608 ///
609 /// The token is minted here, hashed, and only the hash is stored, so this
610 /// response is the one and only time the plaintext exists server-side. The admin
611 /// puts it in a link and sends it however they like; the server is not involved
612 /// in delivery and never sees the link again until it is redeemed.
613 #[utoipa::path(post, path = "/api/v1/sync/groups/{id}/invitations", tag = "SyncKit",
614 params(("id" = String, Path, description = "Group ID")),
615 request_body = CreateInvitationRequest,
616 responses(
617 (status = 200, description = "Invitation issued", body = CreateInvitationResponse),
618 (status = 403, description = "Not the group admin"),
619 ),
620 security(("bearer" = [])),
621 )]
622 #[tracing::instrument(skip_all, name = "synckit::create_group_invitation")]
623 pub(super) async fn create_invitation(
624 State(db): State<PgPool>,
625 sync_user: SyncUser,
626 Path(group_id): Path<SyncGroupId>,
627 Json(req): Json<CreateInvitationRequest>,
628 ) -> Result<impl IntoResponse> {
629 require_group(&db, sync_user.app_id, group_id).await?;
630 require_admin(&db, group_id, sync_user.user_id).await?;
631
632 let hours = req
633 .expires_in_hours
634 .unwrap_or(constants::SYNCKIT_INVITE_DEFAULT_HOURS);
635 if !(constants::SYNCKIT_INVITE_MIN_HOURS..=constants::SYNCKIT_INVITE_MAX_HOURS).contains(&hours)
636 {
637 return Err(AppError::BadRequest(format!(
638 "expires_in_hours must be between {} and {}",
639 constants::SYNCKIT_INVITE_MIN_HOURS,
640 constants::SYNCKIT_INVITE_MAX_HOURS
641 )));
642 }
643
644 let token = generate_invite_token();
645 let expires_at = Utc::now() + Duration::hours(hours);
646 let invitation = db::synckit::create_invitation(
647 &db,
648 group_id,
649 sync_user.user_id,
650 &crate::crypto::sha256_hex(&token),
651 expires_at,
652 )
653 .await?;
654
655 tracing::info!(%group_id, invitation_id = %invitation.id, "issued group invite link");
656
657 Ok(Json(CreateInvitationResponse {
658 id: invitation.id,
659 token,
660 expires_at: invitation.expires_at,
661 }))
662 }
663
664 /// A fresh invite token: 32 bytes of randomness, hex.
665 ///
666 /// Hex rather than base64 so the token survives being pasted through anything
667 /// that mangles `+/=`, which a link handed between humans routinely is.
668 fn generate_invite_token() -> String {
669 use rand::RngExt;
670 let mut rng = rand::rng();
671 let bytes: [u8; 32] = rng.random();
672 hex::encode(bytes)
673 }
674
675 /// List a group's invitations. Admin only.
676 ///
677 /// The admin's confirmation queue: an accepted invitation shows the invitee's
678 /// email and public key so the client can render a fingerprint to check against
679 /// what the invitee reads out over some other channel.
680 #[utoipa::path(get, path = "/api/v1/sync/groups/{id}/invitations", tag = "SyncKit",
681 params(("id" = String, Path, description = "Group ID")),
682 responses(
683 (status = 200, description = "Invitations, newest first", body = Vec<InvitationResponse>),
684 (status = 403, description = "Not the group admin"),
685 ),
686 security(("bearer" = [])),
687 )]
688 #[tracing::instrument(skip_all, name = "synckit::list_group_invitations")]
689 pub(super) async fn list_invitations(
690 State(db): State<PgPool>,
691 sync_user: SyncUser,
692 Path(group_id): Path<SyncGroupId>,
693 ) -> Result<impl IntoResponse> {
694 require_group(&db, sync_user.app_id, group_id).await?;
695 require_admin(&db, group_id, sync_user.user_id).await?;
696
697 let invitations = db::synckit::list_invitations(&db, group_id).await?;
698 let response: Vec<InvitationResponse> = invitations
699 .into_iter()
700 .map(InvitationResponse::from)
701 .collect();
702 Ok(Json(response))
703 }
704
705 /// Confirm an accepted invitation and seal the grant. Admin only.
706 ///
707 /// This is the step the link deliberately does not remove. The admin has, by
708 /// this point, compared the invitee's key fingerprint against what the invitee
709 /// told them over a channel the server does not control; without that check a
710 /// server able to substitute a public key at acceptance would receive a grant to
711 /// the group key. Possession of a link gets someone into this queue and no
712 /// further.
713 ///
714 /// The grant is sealed to the public key **stored on the invitation**, not to one
715 /// the admin re-supplies, so the key that was confirmed is the key that is used.
716 #[utoipa::path(post, path = "/api/v1/sync/groups/{id}/invitations/{invitation_id}/confirm", tag = "SyncKit",
717 params(
718 ("id" = String, Path, description = "Group ID"),
719 ("invitation_id" = String, Path, description = "Invitation ID"),
720 ),
721 request_body = ConfirmInvitationRequest,
722 responses(
723 (status = 204, description = "Member added"),
724 (status = 400, description = "Invitation is not awaiting confirmation"),
725 (status = 403, description = "Not the group admin"),
726 ),
727 security(("bearer" = [])),
728 )]
729 #[tracing::instrument(skip_all, name = "synckit::confirm_group_invitation")]
730 pub(super) async fn confirm_invitation(
731 State(db): State<PgPool>,
732 sync_user: SyncUser,
733 Path((group_id, invitation_id)): Path<(SyncGroupId, SyncGroupInvitationId)>,
734 Json(req): Json<ConfirmInvitationRequest>,
735 ) -> Result<impl IntoResponse> {
736 let group = require_group(&db, sync_user.app_id, group_id).await?;
737 require_admin(&db, group_id, sync_user.user_id).await?;
738
739 if req.sealed_gck.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES {
740 return Err(AppError::BadRequest(
741 "Sealed key exceeds size limit".to_string(),
742 ));
743 }
744 let role = req.role.as_deref().unwrap_or("member");
745 if role != "member" && role != "admin" {
746 return Err(AppError::BadRequest(
747 "role must be 'member' or 'admin'".to_string(),
748 ));
749 }
750
751 let invitation = db::synckit::get_invitation(&db, invitation_id)
752 .await?
753 .filter(|i| i.group_id == group_id)
754 .ok_or(AppError::NotFound)?;
755
756 if super::invitation_state(&invitation) != "accepted" {
757 return Err(AppError::BadRequest(
758 "That invitation is not awaiting confirmation".to_string(),
759 ));
760 }
761 // An accepted invitation always carries both, but reading them out of Options
762 // is where that invariant gets stated rather than assumed.
763 let (Some(invitee_user_id), Some(invitee_pubkey)) = (
764 invitation.invitee_user_id,
765 invitation.invitee_pubkey.as_deref(),
766 ) else {
767 return Err(AppError::BadRequest(
768 "That invitation has no accepted key".to_string(),
769 ));
770 };
771
772 db::synckit::add_or_update_member(
773 &db,
774 group_id,
775 invitee_user_id,
776 role,
777 &req.sealed_gck,
778 group.gck_version,
779 invitee_pubkey,
780 )
781 .await?;
782
783 // Close the invitation after the grant lands. The other order would mark it
784 // spent and then fail to add the member, leaving a token that opens nothing
785 // and an invitee with no way back in but a fresh link.
786 if !db::synckit::redeem_invitation(&db, group_id, invitation_id).await? {
787 tracing::warn!(
788 %group_id, %invitation_id,
789 "member added but invitation was already closed; concurrent confirm"
790 );
791 }
792
793 tracing::info!(%group_id, %invitation_id, "confirmed invitation and sealed grant");
794 Ok(StatusCode::NO_CONTENT)
795 }
796
797 /// Cancel an invitation. Admin only.
798 ///
799 /// Works on an accepted invitation as well as an outstanding one, because the
800 /// case that matters is an admin who looked at a fingerprint and did not
801 /// recognise it.
802 #[utoipa::path(delete, path = "/api/v1/sync/groups/{id}/invitations/{invitation_id}", tag = "SyncKit",
803 params(
804 ("id" = String, Path, description = "Group ID"),
805 ("invitation_id" = String, Path, description = "Invitation ID"),
806 ),
807 responses(
808 (status = 204, description = "Invitation revoked"),
809 (status = 403, description = "Not the group admin"),
810 (status = 404, description = "No such open invitation"),
811 ),
812 security(("bearer" = [])),
813 )]
814 #[tracing::instrument(skip_all, name = "synckit::revoke_group_invitation")]
815 pub(super) async fn revoke_invitation(
816 State(db): State<PgPool>,
817 sync_user: SyncUser,
818 Path((group_id, invitation_id)): Path<(SyncGroupId, SyncGroupInvitationId)>,
819 ) -> Result<impl IntoResponse> {
820 require_group(&db, sync_user.app_id, group_id).await?;
821 require_admin(&db, group_id, sync_user.user_id).await?;
822
823 if db::synckit::revoke_invitation(&db, group_id, invitation_id).await? {
824 Ok(StatusCode::NO_CONTENT)
825 } else {
826 Err(AppError::NotFound)
827 }
828 }
829
830 /// Show what an invite link leads to, before accepting it.
831 ///
832 /// Authenticated, but not gated on membership: the caller is by definition not a
833 /// member yet. It answers which group, from whom, and whether the link is still
834 /// good. Nothing else is exposed, because anyone holding the link can read it.
835 #[utoipa::path(get, path = "/api/v1/sync/invitations/{token}", tag = "SyncKit",
836 params(("token" = String, Path, description = "Invite token")),
837 responses(
838 (status = 200, description = "What the link leads to", body = InvitationPreviewResponse),
839 (status = 404, description = "No such invitation"),
840 ),
841 security(("bearer" = [])),
842 )]
843 #[tracing::instrument(skip_all, name = "synckit::preview_invitation")]
844 pub(super) async fn preview_invitation(
845 State(db): State<PgPool>,
846 _sync_user: SyncUser,
847 Path(token): Path<String>,
848 ) -> Result<impl IntoResponse> {
849 let invitation = db::synckit::get_invitation_by_token(&db, &crate::crypto::sha256_hex(&token))
850 .await?
851 .ok_or(AppError::NotFound)?;
852
853 // The group is read without an app scope because the token, not the caller,
854 // established which group is meant; the token is unguessable and names
855 // exactly one.
856 let group = db::synckit::get_group_by_id(&db, invitation.group_id)
857 .await?
858 .ok_or(AppError::NotFound)?;
859 let inviter_email = db::users::get_user_by_id(&db, invitation.inviter_user_id)
860 .await?
861 .map(|u| u.email.to_string())
862 .unwrap_or_default();
863
864 let state = super::invitation_state(&invitation);
865 Ok(Json(InvitationPreviewResponse {
866 group_name: group.name,
867 inviter_email,
868 redeemable: state == "pending",
869 state: state.to_string(),
870 expires_at: invitation.expires_at,
871 }))
872 }
873
874 /// Accept an invitation by posting your identity public key against its token.
875 ///
876 /// This grants nothing. It records the key the admin will seal the group key to
877 /// once they have confirmed its fingerprint, which is why an invitee is not a
878 /// member when this returns and their client should say so.
879 ///
880 /// One-shot: the underlying update carries every liveness condition in its
881 /// predicate, so two devices racing the same link produce one acceptance.
882 #[utoipa::path(post, path = "/api/v1/sync/invitations/accept", tag = "SyncKit",
883 request_body = AcceptInvitationRequest,
884 responses(
885 (status = 204, description = "Accepted; awaiting the admin's confirmation"),
886 (status = 400, description = "Link is expired, revoked, or already used"),
887 (status = 409, description = "Already a member, or already awaiting confirmation"),
888 ),
889 security(("bearer" = [])),
890 )]
891 #[tracing::instrument(skip_all, name = "synckit::accept_invitation")]
892 pub(super) async fn accept_invitation(
893 State(db): State<PgPool>,
894 sync_user: SyncUser,
895 Json(req): Json<AcceptInvitationRequest>,
896 ) -> Result<impl IntoResponse> {
897 if req.invitee_pubkey.is_empty()
898 || req.invitee_pubkey.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES
899 {
900 return Err(AppError::BadRequest("Invalid public key".to_string()));
901 }
902
903 let token_hash = crate::crypto::sha256_hex(&req.token);
904
905 // Read first, only to tell the invitee why a link that plainly exists does
906 // not work. The read is not the gate; the conditional update below is, so a
907 // token going stale between the two changes the message and not the outcome.
908 let existing = db::synckit::get_invitation_by_token(&db, &token_hash)
909 .await?
910 .ok_or(AppError::NotFound)?;
911
912 if db::synckit::is_group_member(&db, existing.group_id, sync_user.user_id).await? {
913 return Err(AppError::Conflict(
914 "You are already a member of that group".to_string(),
915 ));
916 }
917
918 let accepted =
919 db::synckit::accept_invitation(&db, &token_hash, sync_user.user_id, &req.invitee_pubkey)
920 .await?;
921
922 match accepted {
923 Some(invitation) => {
924 tracing::info!(
925 group_id = %invitation.group_id,
926 invitation_id = %invitation.id,
927 "invitation accepted; awaiting admin confirmation"
928 );
929 Ok(StatusCode::NO_CONTENT)
930 }
931 None => Err(AppError::BadRequest(format!(
932 "That invite link is {}",
933 super::invitation_state(&existing)
934 ))),
935 }
936 }
937