Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 58 additions & 9 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ tokio = { version = "1", features = ["process", "sync", "time", "fs"] }
log = "0.4"
walkdir = "2"
rusqlite = { version = "0.31", features = ["bundled"] }
keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] }

[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.26"
Expand Down
94 changes: 93 additions & 1 deletion src-tauri/src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,99 @@
// We use the SQL plugin from the frontend for most DB operations,
// but this command provides UUID generation for auth records.
// but this module also provides keyring-backed credential storage
// for the GitHub access token and UUID generation for auth records.

use std::path::Path;

use keyring::Entry;
use rusqlite::Connection;

const KEYRING_SERVICE: &str = "dev.sustn.app";
const KEYRING_USER: &str = "github_access_token";

fn token_entry() -> Result<Entry, String> {
Entry::new(KEYRING_SERVICE, KEYRING_USER).map_err(|e| format!("keyring error: {e}"))
}

/// Migrate an existing GitHub access token from the SQLite `auth` table
/// into the OS credential store. Must run **before** the SQL migration that
/// runs `ALTER TABLE auth DROP COLUMN github_access_token` (migration 13 in
/// `migrations.rs`), otherwise the token is lost.
///
/// This is intentionally lenient: if the DB doesn't exist, the column is
/// already gone, or the table is empty, it simply returns Ok(()).
pub fn migrate_token_to_keyring(db_path: &Path) {
if !db_path.exists() {
return;
}

let conn = match Connection::open(db_path) {
Ok(c) => c,
Err(e) => {
eprintln!("[auth] migrate_token_to_keyring — failed to open DB: {e}");
return;
}
};

// Check whether the column still exists (idempotent).
let has_column: bool = conn
.prepare("SELECT github_access_token FROM auth LIMIT 0")
.is_ok();

if !has_column {
return; // Column already dropped — nothing to migrate.
}

let token: Option<String> = conn
.query_row(
"SELECT github_access_token FROM auth LIMIT 1",
[],
|row| row.get(0),
)
.ok();

if let Some(ref token) = token {
if !token.is_empty() {
match token_entry().and_then(|e| {
e.set_password(token)
.map_err(|e| format!("failed to store token: {e}"))
}) {
Ok(()) => {
println!("[auth] migrated GitHub token to OS credential store");
}
Err(e) => {
eprintln!("[auth] migrate_token_to_keyring — keyring error: {e}");
// Don't proceed — keep the token in the DB so we can retry.
return;
}
}
}
}
}

#[tauri::command]
pub fn generate_auth_id() -> String {
uuid::Uuid::new_v4().to_string()
}

#[tauri::command]
pub fn set_github_token(token: String) -> Result<(), String> {
token_entry()?.set_password(&token).map_err(|e| format!("failed to store token: {e}"))
}

#[tauri::command]
pub fn get_github_token() -> Result<Option<String>, String> {
match token_entry()?.get_password() {
Ok(token) => Ok(Some(token)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(format!("failed to retrieve token: {e}")),
}
}

#[tauri::command]
pub fn clear_github_token() -> Result<(), String> {
match token_entry()?.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()), // already gone — not an error
Err(e) => Err(format!("failed to clear token: {e}")),
}
}
10 changes: 10 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ const DB_URL: &str = "sqlite:sustn.db";

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Migrate any existing GitHub token from SQLite → OS credential store
// before the SQL plugin applies migration 13 (which drops the column).
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here - migration 13 is vague

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback!

if let Some(data_dir) = dirs::data_dir() {
let db_path = data_dir.join("app.sustn.desktop").join("sustn.db");
auth::migrate_token_to_keyring(&db_path);
}

let migrations = migrations::migrations();
let engine_state = engine::EngineState::new();

Expand Down Expand Up @@ -73,6 +80,9 @@ pub fn run() {
command::greet,
command::open_in_app,
auth::generate_auth_id,
auth::set_github_token,
auth::get_github_token,
auth::clear_github_token,
preflight::check_git_installed,
preflight::check_claude_installed,
preflight::check_claude_authenticated,
Expand Down
8 changes: 8 additions & 0 deletions src-tauri/src/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,5 +251,13 @@ pub fn migrations() -> Vec<Migration> {
"#,
kind: MigrationKind::Up,
},
Migration {
version: 13,
description: "move github_access_token to OS credential store",
sql: r#"
ALTER TABLE auth DROP COLUMN github_access_token;
"#,
kind: MigrationKind::Up,
},
]
}
20 changes: 13 additions & 7 deletions src/core/db/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ interface AuthRow {
github_username: string;
github_avatar_url: string | null;
github_email: string | null;
github_access_token: string;
created_at: string;
updated_at: string;
}
Expand All @@ -26,22 +25,26 @@ async function getDb() {
return await Database.load(config.dbUrl);
}

function rowToRecord(row: AuthRow): AuthRecord {
function rowToRecord(row: AuthRow, accessToken: string): AuthRecord {
return {
id: row.id,
githubId: row.github_id,
username: row.github_username,
avatarUrl: row.github_avatar_url ?? undefined,
email: row.github_email ?? undefined,
accessToken: row.github_access_token,
accessToken,
};
}

export async function getAuth(): Promise<AuthRecord | undefined> {
const db = await getDb();
const rows = await db.select<AuthRow[]>("SELECT * FROM auth LIMIT 1");
if (rows.length === 0) return undefined;
return rowToRecord(rows[0]);

const token = await invoke<string | null>("get_github_token");
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback!

if (!token) return undefined;
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we really wanna be returning undefined?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback!


return rowToRecord(rows[0], token);
}

export async function saveAuth(params: {
Expand All @@ -54,24 +57,27 @@ export async function saveAuth(params: {
const db = await getDb();
const id = await invoke<string>("generate_auth_id");

// Store the token in the OS credential store
await invoke("set_github_token", { token: params.accessToken });

// Delete any existing auth record (single-user app)
await db.execute("DELETE FROM auth");

await db.execute(
`INSERT INTO auth (id, github_id, github_username, github_avatar_url, github_email, github_access_token)
VALUES ($1, $2, $3, $4, $5, $6)`,
`INSERT INTO auth (id, github_id, github_username, github_avatar_url, github_email)
VALUES ($1, $2, $3, $4, $5)`,
[
id,
params.githubId,
params.username,
params.avatarUrl ?? null,
params.email ?? null,
params.accessToken,
],
);
}

export async function clearAuth(): Promise<void> {
const db = await getDb();
await db.execute("DELETE FROM auth");
await invoke("clear_github_token");
}