//! SyncKit developer apps: create/lookup a registered app, hash its API key //! (SHA-256; the raw key is never stored), and resolve an app by key or id. use sqlx::PgPool; use crate::db::models::DbSyncApp; use crate::db::{ItemId, ProjectId, SyncAppId, UserId}; use crate::error::Result; // ── Sync Apps ── /// Compute the SHA-256 hash of an API key (hex-encoded). pub fn hash_api_key(api_key: &str) -> String { use sha2::Digest; let hash = sha2::Sha256::digest(api_key.as_bytes()); hex::encode(hash) } /// Create a new sync app. Stores the hashed API key and prefix. /// /// Creates the app row AND its `sync_app_usage_current` row in one transaction. /// Every billing/blob path (`confirm_developer_blob`, key claim/release, egress) /// does `SELECT ... FOR UPDATE` on that usage row and errors if it's missing, so /// an app without one is unusable. Migration 117 seeded the row for apps that /// existed then; this keeps every app created since in the same state. #[tracing::instrument(skip_all)] pub async fn create_sync_app( pool: &PgPool, creator_id: UserId, name: &str, api_key: &str, project_id: Option, item_id: Option, ) -> Result { let key_hash = hash_api_key(api_key); let key_prefix = &api_key[..8.min(api_key.len())]; let mut tx = pool.begin().await?; let app = sqlx::query_as::<_, DbSyncApp>( r" INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, project_id, item_id) VALUES ($1, $2, $3, $4, $5, $6) RETURNING * ", ) .bind(creator_id) .bind(name) .bind(&key_hash) .bind(key_prefix) .bind(project_id) .bind(item_id) .fetch_one(&mut *tx) .await?; sqlx::query( "INSERT INTO sync_app_usage_current (app_id) VALUES ($1) ON CONFLICT (app_id) DO NOTHING", ) .bind(app.id) .execute(&mut *tx) .await?; tx.commit().await?; Ok(app) } /// Update the project/item link for a sync app. #[tracing::instrument(skip_all)] pub async fn update_sync_app_link( pool: &PgPool, app_id: SyncAppId, project_id: Option, item_id: Option, ) -> Result { let app = sqlx::query_as::<_, DbSyncApp>( r" UPDATE sync_apps SET project_id = $2, item_id = $3 WHERE id = $1 RETURNING * ", ) .bind(app_id) .bind(project_id) .bind(item_id) .fetch_one(pool) .await?; Ok(app) } /// Get a sync app by API key (only if active). Hashes the input before lookup. #[tracing::instrument(skip_all)] pub async fn get_sync_app_by_api_key(pool: &PgPool, api_key: &str) -> Result> { let key_hash = hash_api_key(api_key); let app = sqlx::query_as::<_, DbSyncApp>( "SELECT * FROM sync_apps WHERE api_key_hash = $1 AND is_active = true", ) .bind(&key_hash) .fetch_optional(pool) .await?; Ok(app) } #[tracing::instrument(skip_all)] pub async fn get_sync_app_by_id(pool: &PgPool, id: SyncAppId) -> Result> { let app = sqlx::query_as::<_, DbSyncApp>("SELECT * FROM sync_apps WHERE id = $1") .bind(id) .fetch_optional(pool) .await?; Ok(app) } /// List all sync apps for a creator. #[tracing::instrument(skip_all)] pub async fn get_sync_apps_by_creator(pool: &PgPool, creator_id: UserId) -> Result> { let apps = sqlx::query_as::<_, DbSyncApp>( "SELECT * FROM sync_apps WHERE creator_id = $1 ORDER BY created_at DESC LIMIT 100", ) .bind(creator_id) .fetch_all(pool) .await?; Ok(apps) } /// Get all sync apps linked to a specific project. pub async fn get_sync_apps_by_project( pool: &PgPool, project_id: ProjectId, ) -> Result> { let apps = sqlx::query_as::<_, DbSyncApp>( "SELECT * FROM sync_apps WHERE project_id = $1 ORDER BY created_at DESC LIMIT 100", ) .bind(project_id) .fetch_all(pool) .await?; Ok(apps) } /// Regenerate an API key for a sync app. Stores the hashed key and prefix. #[tracing::instrument(skip_all)] pub async fn regenerate_sync_app_key( pool: &PgPool, app_id: SyncAppId, new_api_key: &str, ) -> Result { let key_hash = hash_api_key(new_api_key); let key_prefix = &new_api_key[..8.min(new_api_key.len())]; let app = sqlx::query_as::<_, DbSyncApp>( r" UPDATE sync_apps SET api_key_hash = $2, api_key_prefix = $3 WHERE id = $1 RETURNING * ", ) .bind(app_id) .bind(&key_hash) .bind(key_prefix) .fetch_one(pool) .await?; Ok(app) } /// Get a sync app by its keys-endpoint secret (only if active). /// /// Deliberately separate from [`get_sync_app_by_api_key`]: the api_key ships /// inside every client binary, so it may not authenticate the server-to-server /// `/api/sync/keys/*` routes. An app that has never generated a secret has a /// NULL `keys_secret_hash` and matches nothing here, which is the intent -- /// those routes stay closed until the developer opts in. #[tracing::instrument(skip_all)] pub async fn get_sync_app_by_keys_secret(pool: &PgPool, secret: &str) -> Result> { let secret_hash = hash_api_key(secret); let app = sqlx::query_as::<_, DbSyncApp>( "SELECT * FROM sync_apps WHERE keys_secret_hash = $1 AND is_active = true", ) .bind(&secret_hash) .fetch_optional(pool) .await?; Ok(app) } /// Set (or rotate) the keys-endpoint secret for a sync app. /// /// Rotation is immediate and unversioned: the previous secret stops working /// the moment this returns, same as `regenerate_sync_app_key`. #[tracing::instrument(skip_all)] pub async fn set_sync_app_keys_secret( pool: &PgPool, app_id: SyncAppId, new_secret: &str, ) -> Result { let secret_hash = hash_api_key(new_secret); let secret_prefix = &new_secret[..8.min(new_secret.len())]; let app = sqlx::query_as::<_, DbSyncApp>( r" UPDATE sync_apps SET keys_secret_hash = $2, keys_secret_prefix = $3 WHERE id = $1 RETURNING * ", ) .bind(app_id) .bind(&secret_hash) .bind(secret_prefix) .fetch_one(pool) .await?; Ok(app) } /// Delete a sync app (cascades to devices and log entries). #[tracing::instrument(skip_all)] pub async fn delete_sync_app(pool: &PgPool, app_id: SyncAppId) -> Result<()> { sqlx::query("DELETE FROM sync_apps WHERE id = $1") .bind(app_id) .execute(pool) .await?; Ok(()) }