Skip to main content

max / makenotwork

6.4 KB · 225 lines History Blame Raw
1 //! SyncKit developer apps: create/lookup a registered app, hash its API key
2 //! (SHA-256; the raw key is never stored), and resolve an app by key or id.
3
4 use sqlx::PgPool;
5
6 use crate::db::models::DbSyncApp;
7 use crate::db::{ItemId, ProjectId, SyncAppId, UserId};
8 use crate::error::Result;
9
10 // ── Sync Apps ──
11
12 /// Compute the SHA-256 hash of an API key (hex-encoded).
13 pub fn hash_api_key(api_key: &str) -> String {
14 use sha2::Digest;
15 let hash = sha2::Sha256::digest(api_key.as_bytes());
16 hex::encode(hash)
17 }
18
19 /// Create a new sync app. Stores the hashed API key and prefix.
20 ///
21 /// Creates the app row AND its `sync_app_usage_current` row in one transaction.
22 /// Every billing/blob path (`confirm_developer_blob`, key claim/release, egress)
23 /// does `SELECT ... FOR UPDATE` on that usage row and errors if it's missing, so
24 /// an app without one is unusable. Migration 117 seeded the row for apps that
25 /// existed then; this keeps every app created since in the same state.
26 #[tracing::instrument(skip_all)]
27 pub async fn create_sync_app(
28 pool: &PgPool,
29 creator_id: UserId,
30 name: &str,
31 api_key: &str,
32 project_id: Option<ProjectId>,
33 item_id: Option<ItemId>,
34 ) -> Result<DbSyncApp> {
35 let key_hash = hash_api_key(api_key);
36 let key_prefix = &api_key[..8.min(api_key.len())];
37
38 let mut tx = pool.begin().await?;
39 let app = sqlx::query_as::<_, DbSyncApp>(
40 r"
41 INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, project_id, item_id)
42 VALUES ($1, $2, $3, $4, $5, $6)
43 RETURNING *
44 ",
45 )
46 .bind(creator_id)
47 .bind(name)
48 .bind(&key_hash)
49 .bind(key_prefix)
50 .bind(project_id)
51 .bind(item_id)
52 .fetch_one(&mut *tx)
53 .await?;
54
55 sqlx::query(
56 "INSERT INTO sync_app_usage_current (app_id) VALUES ($1) ON CONFLICT (app_id) DO NOTHING",
57 )
58 .bind(app.id)
59 .execute(&mut *tx)
60 .await?;
61
62 tx.commit().await?;
63 Ok(app)
64 }
65
66 /// Update the project/item link for a sync app.
67 #[tracing::instrument(skip_all)]
68 pub async fn update_sync_app_link(
69 pool: &PgPool,
70 app_id: SyncAppId,
71 project_id: Option<ProjectId>,
72 item_id: Option<ItemId>,
73 ) -> Result<DbSyncApp> {
74 let app = sqlx::query_as::<_, DbSyncApp>(
75 r"
76 UPDATE sync_apps SET project_id = $2, item_id = $3
77 WHERE id = $1
78 RETURNING *
79 ",
80 )
81 .bind(app_id)
82 .bind(project_id)
83 .bind(item_id)
84 .fetch_one(pool)
85 .await?;
86
87 Ok(app)
88 }
89
90 /// Get a sync app by API key (only if active). Hashes the input before lookup.
91 #[tracing::instrument(skip_all)]
92 pub async fn get_sync_app_by_api_key(pool: &PgPool, api_key: &str) -> Result<Option<DbSyncApp>> {
93 let key_hash = hash_api_key(api_key);
94 let app = sqlx::query_as::<_, DbSyncApp>(
95 "SELECT * FROM sync_apps WHERE api_key_hash = $1 AND is_active = true",
96 )
97 .bind(&key_hash)
98 .fetch_optional(pool)
99 .await?;
100
101 Ok(app)
102 }
103
104 #[tracing::instrument(skip_all)]
105 pub async fn get_sync_app_by_id(pool: &PgPool, id: SyncAppId) -> Result<Option<DbSyncApp>> {
106 let app = sqlx::query_as::<_, DbSyncApp>("SELECT * FROM sync_apps WHERE id = $1")
107 .bind(id)
108 .fetch_optional(pool)
109 .await?;
110
111 Ok(app)
112 }
113
114 /// List all sync apps for a creator.
115 #[tracing::instrument(skip_all)]
116 pub async fn get_sync_apps_by_creator(pool: &PgPool, creator_id: UserId) -> Result<Vec<DbSyncApp>> {
117 let apps = sqlx::query_as::<_, DbSyncApp>(
118 "SELECT * FROM sync_apps WHERE creator_id = $1 ORDER BY created_at DESC LIMIT 100",
119 )
120 .bind(creator_id)
121 .fetch_all(pool)
122 .await?;
123
124 Ok(apps)
125 }
126
127 /// Get all sync apps linked to a specific project.
128 pub async fn get_sync_apps_by_project(
129 pool: &PgPool,
130 project_id: ProjectId,
131 ) -> Result<Vec<DbSyncApp>> {
132 let apps = sqlx::query_as::<_, DbSyncApp>(
133 "SELECT * FROM sync_apps WHERE project_id = $1 ORDER BY created_at DESC LIMIT 100",
134 )
135 .bind(project_id)
136 .fetch_all(pool)
137 .await?;
138
139 Ok(apps)
140 }
141
142 /// Regenerate an API key for a sync app. Stores the hashed key and prefix.
143 #[tracing::instrument(skip_all)]
144 pub async fn regenerate_sync_app_key(
145 pool: &PgPool,
146 app_id: SyncAppId,
147 new_api_key: &str,
148 ) -> Result<DbSyncApp> {
149 let key_hash = hash_api_key(new_api_key);
150 let key_prefix = &new_api_key[..8.min(new_api_key.len())];
151 let app = sqlx::query_as::<_, DbSyncApp>(
152 r"
153 UPDATE sync_apps SET api_key_hash = $2, api_key_prefix = $3
154 WHERE id = $1
155 RETURNING *
156 ",
157 )
158 .bind(app_id)
159 .bind(&key_hash)
160 .bind(key_prefix)
161 .fetch_one(pool)
162 .await?;
163
164 Ok(app)
165 }
166
167 /// Get a sync app by its keys-endpoint secret (only if active).
168 ///
169 /// Deliberately separate from [`get_sync_app_by_api_key`]: the api_key ships
170 /// inside every client binary, so it may not authenticate the server-to-server
171 /// `/api/sync/keys/*` routes. An app that has never generated a secret has a
172 /// NULL `keys_secret_hash` and matches nothing here, which is the intent --
173 /// those routes stay closed until the developer opts in.
174 #[tracing::instrument(skip_all)]
175 pub async fn get_sync_app_by_keys_secret(pool: &PgPool, secret: &str) -> Result<Option<DbSyncApp>> {
176 let secret_hash = hash_api_key(secret);
177 let app = sqlx::query_as::<_, DbSyncApp>(
178 "SELECT * FROM sync_apps WHERE keys_secret_hash = $1 AND is_active = true",
179 )
180 .bind(&secret_hash)
181 .fetch_optional(pool)
182 .await?;
183
184 Ok(app)
185 }
186
187 /// Set (or rotate) the keys-endpoint secret for a sync app.
188 ///
189 /// Rotation is immediate and unversioned: the previous secret stops working
190 /// the moment this returns, same as `regenerate_sync_app_key`.
191 #[tracing::instrument(skip_all)]
192 pub async fn set_sync_app_keys_secret(
193 pool: &PgPool,
194 app_id: SyncAppId,
195 new_secret: &str,
196 ) -> Result<DbSyncApp> {
197 let secret_hash = hash_api_key(new_secret);
198 let secret_prefix = &new_secret[..8.min(new_secret.len())];
199 let app = sqlx::query_as::<_, DbSyncApp>(
200 r"
201 UPDATE sync_apps SET keys_secret_hash = $2, keys_secret_prefix = $3
202 WHERE id = $1
203 RETURNING *
204 ",
205 )
206 .bind(app_id)
207 .bind(&secret_hash)
208 .bind(secret_prefix)
209 .fetch_one(pool)
210 .await?;
211
212 Ok(app)
213 }
214
215 /// Delete a sync app (cascades to devices and log entries).
216 #[tracing::instrument(skip_all)]
217 pub async fn delete_sync_app(pool: &PgPool, app_id: SyncAppId) -> Result<()> {
218 sqlx::query("DELETE FROM sync_apps WHERE id = $1")
219 .bind(app_id)
220 .execute(pool)
221 .await?;
222
223 Ok(())
224 }
225