Skip to main content

max / makenotwork

synckit-server: bill group writes to the admin's slot (Groups phase 2, p2-billing) Group push now enforces the paid-write gate against the group *admin's* entitlement, not the pushing member's. So a member with no subscription of their own can still write to a group whose admin pays -- the group bills to the admin's slot (Groups billing decision). Group pull stays open, as personal pull does. First-party (internal) apps require the admin's active subscription; non-internal apps pass as before. Group blob-storage attribution to the admin is forward-looking: there are no group blob endpoints yet, so nothing to meter until they land (p3/p4); the changelog itself is not a byte-metered dimension. SSE push notification to group members is separately deferred to p3. - routes/synckit/groups::group_push gates on internal_write_allowed(app, group.admin_user_id) and returns 402 { "reason": "no_subscription" }. - tests/workflows/synckit_groups_billing: HTTP tests -- unsubscribed admin push refused (402), subscribed admin push ok, a no-subscription member writes on the admin's slot (200), non-member refused (403). Green against local postgres. Design: wiki synckit-groups-design. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 18:58 UTC
Commit: be18913acc2a527848772aa094597bf69cebc8cb
Parent: 730ec79
3 files changed, +178 insertions, -6 deletions
@@ -14,8 +14,9 @@ use axum::{
14 14 Json,
15 15 extract::{Path, State},
16 16 http::StatusCode,
17 - response::IntoResponse,
17 + response::{IntoResponse, Response},
18 18 };
19 + use serde_json::json;
19 20 use sqlx::PgPool;
20 21
21 22 use crate::{
@@ -272,11 +273,23 @@ pub(super) async fn group_push(
272 273 sync_user: SyncUser,
273 274 Path(group_id): Path<SyncGroupId>,
274 275 Json(req): Json<PushRequest>,
275 - ) -> Result<impl IntoResponse> {
276 - require_group(&db, sync_user.app_id, group_id).await?;
276 + ) -> Result<Response> {
277 + let group = require_group(&db, sync_user.app_id, group_id).await?;
277 278 require_member(&db, group_id, sync_user.user_id).await?;
278 - // NOTE: the paid-write gate and SSE push notification (present on personal
279 - // push) are attached in p2-billing; group writes are open in this slice.
279 +
280 + // Group writes bill to the admin's slot (Groups billing decision): the paid
281 + // gate is checked against the *admin's* entitlement, not the pushing member's,
282 + // so a member with no subscription of their own can still contribute to a
283 + // group whose admin pays. Reads (group_pull) stay open, as personal pull does.
284 + if !db::synckit::internal_write_allowed(&db, sync_user.app_id, group.admin_user_id).await? {
285 + return Ok((
286 + StatusCode::PAYMENT_REQUIRED,
287 + Json(json!({ "reason": "no_subscription" })),
288 + )
289 + .into_response());
290 + }
291 + // NOTE: SSE push notification to group members is deferred to p3; members'
292 + // devices pick up group changes on their next scheduler tick meanwhile.
280 293
281 294 if req.changes.is_empty() {
282 295 return Err(AppError::BadRequest("No changes provided".to_string()));
@@ -331,7 +344,7 @@ pub(super) async fn group_push(
331 344 )
332 345 .await?;
333 346
334 - Ok(Json(PushResponse { cursor }))
347 + Ok(Json(PushResponse { cursor }).into_response())
335 348 }
336 349
337 350 /// Pull a group's changes after a cursor. Members only.
@@ -105,6 +105,7 @@ mod synckit;
105 105 mod synckit_adversarial;
106 106 mod synckit_billing;
107 107 mod synckit_blob_multipart;
108 + mod synckit_groups_billing;
108 109 mod synckit_paid_sync;
109 110 mod synckit_per_key_storage;
110 111 mod synckit_security;
@@ -0,0 +1,158 @@
1 + //! HTTP tests: a group's writes bill to the *admin's* slot.
2 + //!
3 + //! Group push is gated on the admin's entitlement, not the pushing member's, so a
4 + //! member with no subscription of their own can still contribute to a group whose
5 + //! admin pays (the Groups billing decision, 2026-07-23). Reuses the paid-sync
6 + //! harness helpers. Design: wiki synckit-groups-design.
7 +
8 + use serde_json::json;
9 +
10 + use super::synckit_paid_sync::{
11 + auth_as, create_internal_app, harness_with_blobs, seed_subscription,
12 + };
13 + use crate::harness::TestHarness;
14 +
15 + const GIB: i64 = 1024 * 1024 * 1024;
16 +
17 + async fn create_group(h: &mut TestHarness, name: &str) -> String {
18 + let resp = h
19 + .client
20 + .post_json(
21 + "/api/sync/groups",
22 + &json!({ "name": name, "admin_sealed_gck": "sealed_admin" }).to_string(),
23 + )
24 + .await;
25 + assert_eq!(resp.status, 200, "create group: {}", resp.text);
26 + let body: serde_json::Value = resp.json();
27 + body["id"].as_str().expect("group id").to_string()
28 + }
29 +
30 + async fn register_device(h: &mut TestHarness, name: &str) -> String {
31 + let resp = h
32 + .client
33 + .post_json(
34 + "/api/sync/devices",
35 + &json!({ "device_name": name, "platform": "macos" }).to_string(),
36 + )
37 + .await;
38 + assert_eq!(resp.status, 200, "register device: {}", resp.text);
39 + let body: serde_json::Value = resp.json();
40 + body["id"].as_str().expect("device id").to_string()
41 + }
42 +
43 + fn push_body(device_id: &str) -> String {
44 + json!({
45 + "device_id": device_id,
46 + "batch_id": uuid::Uuid::new_v4(),
47 + "changes": [{
48 + "table": "tasks",
49 + "op": "INSERT",
50 + "row_id": "r1",
51 + "timestamp": "2026-01-01T00:00:00Z",
52 + "data": { "v": 1 }
53 + }]
54 + })
55 + .to_string()
56 + }
57 +
58 + #[tokio::test]
59 + async fn group_push_gated_on_admin_subscription() {
60 + let (mut h, _blobs) = harness_with_blobs().await;
61 + let admin = h
62 + .signup("gb_admin", "gb_admin@example.com", "Password1!")
63 + .await;
64 + let (app, _key) = create_internal_app(&h.db, admin).await;
65 + auth_as(&mut h, admin, app, "admin-key");
66 +
67 + let group_id = create_group(&mut h, "Team").await;
68 + let admin_device = register_device(&mut h, "admin-dev").await;
69 +
70 + // No subscription yet: the admin's own group push is refused (402).
71 + let resp = h
72 + .client
73 + .post_json(
74 + &format!("/api/sync/groups/{group_id}/push"),
75 + &push_body(&admin_device),
76 + )
77 + .await;
78 + assert_eq!(resp.status, 402, "unsubscribed admin push: {}", resp.text);
79 + let body: serde_json::Value = resp.json();
80 + assert_eq!(body["reason"], "no_subscription");
81 +
82 + // Give the admin an active subscription: group push now succeeds.
83 + seed_subscription(&h.db, admin, app, "active", 10 * GIB).await;
84 + let resp = h
85 + .client
86 + .post_json(
87 + &format!("/api/sync/groups/{group_id}/push"),
88 + &push_body(&admin_device),
89 + )
90 + .await;
91 + assert_eq!(resp.status, 200, "subscribed admin push: {}", resp.text);
92 + }
93 +
94 + #[tokio::test]
95 + async fn member_without_own_subscription_writes_on_admin_slot() {
96 + let (mut h, _blobs) = harness_with_blobs().await;
97 + let admin = h
98 + .signup("gb2_admin", "gb2_admin@example.com", "Password1!")
99 + .await;
100 + let (app, _key) = create_internal_app(&h.db, admin).await;
101 + auth_as(&mut h, admin, app, "admin-key");
102 + seed_subscription(&h.db, admin, app, "active", 10 * GIB).await;
103 + let group_id = create_group(&mut h, "Team").await;
104 +
105 + // A verified member with NO subscription of their own.
106 + let bob = h
107 + .signup("gb2_bob", "gb2_bob@example.com", "Password1!")
108 + .await;
109 + sqlx::query("UPDATE users SET email_verified = true WHERE id = $1")
110 + .bind(bob)
111 + .execute(&h.db)
112 + .await
113 + .expect("verify bob");
114 + let resp = h
115 + .client
116 + .post_json(
117 + &format!("/api/sync/groups/{group_id}/members"),
118 + &json!({ "member_email": "gb2_bob@example.com", "sealed_gck": "sealed_bob" })
119 + .to_string(),
120 + )
121 + .await;
122 + assert_eq!(resp.status, 204, "add member: {}", resp.text);
123 +
124 + // Bob has no subscription, but the group's admin does: his push succeeds.
125 + auth_as(&mut h, bob, app, "bob-key");
126 + let bob_device = register_device(&mut h, "bob-dev").await;
127 + let resp = h
128 + .client
129 + .post_json(
130 + &format!("/api/sync/groups/{group_id}/push"),
131 + &push_body(&bob_device),
132 + )
133 + .await;
134 + assert_eq!(
135 + resp.status, 200,
136 + "member writes on admin's slot: {}",
137 + resp.text
138 + );
139 +
140 + // A verified non-member is refused before the billing gate (403).
141 + let carol = h
142 + .signup("gb2_carol", "gb2_carol@example.com", "Password1!")
143 + .await;
144 + auth_as(&mut h, carol, app, "carol-key");
145 + let carol_device = register_device(&mut h, "carol-dev").await;
146 + let resp = h
147 + .client
148 + .post_json(
149 + &format!("/api/sync/groups/{group_id}/push"),
150 + &push_body(&carol_device),
151 + )
152 + .await;
153 + assert_eq!(
154 + resp.status, 403,
155 + "non-member push must be forbidden: {}",
156 + resp.text
157 + );
158 + }