Skip to main content

max / makenotwork

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