refactor: remove invitation system

The invitation flow is redundant — admin create user already generates
a token and shows a login link. Invitations add complexity without
value until email integration exists.

Removed:
- InvitationRecord struct and 4 UserStore trait methods
- invitations table from V14 migration (postgres + both libsql schemas)
- PostgreSQL Store methods (create/get/accept/list invitations)
- libSQL UserStore invitation methods + row_to_invitation helper
- invitations.rs handler file (212 lines)
- /api/invitations routes (create, list, accept)
- test_invitation_lifecycle test

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
2026-03-25 00:18:50 -07:00
co-authored by Claude Opus 4.6
parent 34883aaa6c
commit e83249d0e0
9 changed files with 4 additions and 633 deletions
+1 -201
View File
@@ -7,7 +7,7 @@ use uuid::Uuid;
use super::{fmt_opt_ts, fmt_ts, get_opt_text, get_opt_ts, get_text, get_ts, opt_text};
use crate::db::libsql::LibSqlBackend;
use crate::db::{ApiTokenRecord, DatabaseError, InvitationRecord, UserRecord, UserStore};
use crate::db::{ApiTokenRecord, DatabaseError, UserRecord, UserStore};
fn row_to_user(row: &libsql::Row) -> Result<UserRecord, DatabaseError> {
let metadata_str = get_text(row, 9);
@@ -44,23 +44,6 @@ fn row_to_api_token(row: &libsql::Row) -> Result<ApiTokenRecord, DatabaseError>
})
}
fn row_to_invitation(row: &libsql::Row) -> Result<InvitationRecord, DatabaseError> {
let id_str = get_text(row, 0);
let id: Uuid = id_str
.parse()
.map_err(|e| DatabaseError::Serialization(format!("invalid UUID: {e}")))?;
Ok(InvitationRecord {
id,
email: get_opt_text(row, 1),
invited_by: get_text(row, 2),
status: get_text(row, 3),
expires_at: get_ts(row, 4),
accepted_at: get_opt_ts(row, 5),
accepted_by: get_opt_text(row, 6),
created_at: get_ts(row, 7),
})
}
#[async_trait]
impl UserStore for LibSqlBackend {
async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError> {
@@ -388,119 +371,6 @@ impl UserStore for LibSqlBackend {
Ok(())
}
async fn create_invitation(
&self,
invitation: &InvitationRecord,
invite_hash: &[u8; 32],
) -> Result<(), DatabaseError> {
let conn = self.connect().await?;
conn.execute(
r#"
INSERT INTO invitations (id, email, invite_token_hash, invited_by, status, expires_at, accepted_at, accepted_by, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
"#,
params![
invitation.id.to_string(),
opt_text(invitation.email.as_deref()),
libsql::Value::Blob(invite_hash.to_vec()),
invitation.invited_by.as_str(),
invitation.status.as_str(),
fmt_ts(&invitation.expires_at),
fmt_opt_ts(&invitation.accepted_at),
opt_text(invitation.accepted_by.as_deref()),
fmt_ts(&invitation.created_at),
],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(())
}
async fn get_invitation_by_hash(
&self,
invite_hash: &[u8; 32],
) -> Result<Option<InvitationRecord>, DatabaseError> {
let conn = self.connect().await?;
let mut rows = conn
.query(
r#"
SELECT id, email, invited_by, status, expires_at, accepted_at, accepted_by, created_at
FROM invitations
WHERE invite_token_hash = ?1
AND status = 'pending'
AND expires_at > strftime('%Y-%m-%dT%H:%M:%S', 'now')
"#,
params![libsql::Value::Blob(invite_hash.to_vec())],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
match rows
.next()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?
{
Some(row) => Ok(Some(row_to_invitation(&row)?)),
None => Ok(None),
}
}
async fn accept_invitation(&self, id: Uuid, accepted_by: &str) -> Result<(), DatabaseError> {
let conn = self.connect().await?;
let now = fmt_ts(&Utc::now());
conn.execute(
r#"
UPDATE invitations SET status = 'accepted', accepted_at = ?2, accepted_by = ?3
WHERE id = ?1
"#,
params![id.to_string(), now, accepted_by],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(())
}
async fn list_invitations(
&self,
invited_by: Option<&str>,
) -> Result<Vec<InvitationRecord>, DatabaseError> {
let conn = self.connect().await?;
let mut invitations = Vec::new();
let mut rows = if let Some(invited_by) = invited_by {
conn.query(
r#"
SELECT id, email, invited_by, status, expires_at, accepted_at, accepted_by, created_at
FROM invitations WHERE invited_by = ?1
ORDER BY created_at DESC
"#,
params![invited_by],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?
} else {
conn.query(
r#"
SELECT id, email, invited_by, status, expires_at, accepted_at, accepted_by, created_at
FROM invitations
ORDER BY created_at DESC
"#,
(),
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?
};
while let Some(row) = rows
.next()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?
{
invitations.push(row_to_invitation(&row)?);
}
Ok(invitations)
}
async fn has_any_users(&self) -> Result<bool, DatabaseError> {
let conn = self.connect().await?;
let mut rows = conn
@@ -694,76 +564,6 @@ mod tests {
assert!(db.revoke_api_token(record.id, "alice").await.unwrap());
}
#[tokio::test]
async fn test_invitation_lifecycle() {
let (db, _dir) = setup().await;
db.create_user(&test_user("alice")).await.unwrap();
// Create invitation
let invite_hash = hash("invite-token-xyz");
let invitation = InvitationRecord {
id: Uuid::new_v4(),
email: Some("[email protected]".to_string()),
invited_by: "alice".to_string(),
status: "pending".to_string(),
expires_at: Utc::now() + chrono::Duration::days(7),
accepted_at: None,
accepted_by: None,
created_at: Utc::now(),
};
db.create_invitation(&invitation, &invite_hash)
.await
.unwrap();
// Look up by hash
let found = db
.get_invitation_by_hash(&invite_hash)
.await
.unwrap()
.unwrap();
assert_eq!(found.id, invitation.id);
assert_eq!(found.status, "pending");
// List invitations
let list = db.list_invitations(Some("alice")).await.unwrap();
assert_eq!(list.len(), 1);
// Accept
db.create_user(&UserRecord {
id: "newuser".to_string(),
email: Some("[email protected]".to_string()),
display_name: "New User".to_string(),
status: "active".to_string(),
role: "member".to_string(),
created_at: Utc::now(),
updated_at: Utc::now(),
last_login_at: None,
created_by: Some("alice".to_string()),
metadata: serde_json::json!({}),
})
.await
.unwrap();
db.accept_invitation(invitation.id, "newuser")
.await
.unwrap();
// After acceptance, the invitation should no longer be found via hash
// lookup (which filters for status='pending')
assert!(
db.get_invitation_by_hash(&invite_hash)
.await
.unwrap()
.is_none(),
"Accepted invitation should not be returned by pending-only lookup"
);
// Verify via list that it was accepted
let all = db.list_invitations(Some("alice")).await.unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].status, "accepted");
assert_eq!(all[0].accepted_by, Some("newuser".to_string()));
}
#[tokio::test]
async fn test_record_login_and_token_usage() {
let (db, _dir) = setup().await;
-24
View File
@@ -609,18 +609,6 @@ CREATE TABLE IF NOT EXISTS api_tokens (
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
CREATE TABLE IF NOT EXISTS invitations (
id TEXT PRIMARY KEY,
email TEXT,
invite_token_hash BLOB NOT NULL,
invited_by TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
expires_at TEXT NOT NULL,
accepted_at TEXT,
accepted_by TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
"#;
/// Incremental migrations applied after the base schema.
@@ -797,18 +785,6 @@ CREATE TABLE IF NOT EXISTS api_tokens (
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
CREATE TABLE IF NOT EXISTS invitations (
id TEXT PRIMARY KEY,
email TEXT,
invite_token_hash BLOB NOT NULL,
invited_by TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
expires_at TEXT NOT NULL,
accepted_at TEXT,
accepted_by TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
"#,
),
];
-34
View File
@@ -346,20 +346,6 @@ pub struct ApiTokenRecord {
pub revoked_at: Option<DateTime<Utc>>,
}
/// A pending invitation to create an account.
#[derive(Debug, Clone)]
pub struct InvitationRecord {
pub id: Uuid,
pub email: Option<String>,
pub invited_by: String,
/// `pending`, `accepted`, `expired`, or `revoked`.
pub status: String,
pub expires_at: DateTime<Utc>,
pub accepted_at: Option<DateTime<Utc>>,
pub accepted_by: Option<String>,
pub created_at: DateTime<Utc>,
}
// ==================== Sub-traits ====================
//
// Each sub-trait groups related persistence methods. The `Database` supertrait
@@ -860,26 +846,6 @@ pub trait UserStore: Send + Sync {
/// Update `last_used_at` for a token.
async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError>;
// ---- Invitations ----
/// Create a new invitation.
async fn create_invitation(
&self,
invitation: &InvitationRecord,
invite_hash: &[u8; 32],
) -> Result<(), DatabaseError>;
/// Look up a pending invitation by its hashed token.
async fn get_invitation_by_hash(
&self,
invite_hash: &[u8; 32],
) -> Result<Option<InvitationRecord>, DatabaseError>;
/// Accept an invitation (sets status=accepted, accepted_by, accepted_at).
async fn accept_invitation(&self, id: Uuid, accepted_by: &str) -> Result<(), DatabaseError>;
/// List invitations, optionally filtered by inviter.
async fn list_invitations(
&self,
invited_by: Option<&str>,
) -> Result<Vec<InvitationRecord>, DatabaseError>;
/// Check whether any user records exist (for first-run bootstrap detection).
async fn has_any_users(&self) -> Result<bool, DatabaseError>;
}
+2 -28
View File
@@ -16,8 +16,8 @@ use crate::agent::routine::{Routine, RoutineRun, RunStatus};
use crate::config::DatabaseConfig;
use crate::context::{ActionRecord, JobContext, JobState};
use crate::db::{
ApiTokenRecord, ConversationStore, Database, InvitationRecord, JobStore, RoutineStore,
SandboxStore, SettingsStore, ToolFailureStore, UserRecord, UserStore, WorkspaceStore,
ApiTokenRecord, ConversationStore, Database, JobStore, RoutineStore, SandboxStore,
SettingsStore, ToolFailureStore, UserRecord, UserStore, WorkspaceStore,
};
use crate::error::{DatabaseError, WorkspaceError};
use crate::history::{
@@ -858,32 +858,6 @@ impl UserStore for PgBackend {
self.store.record_token_usage(token_id).await
}
async fn create_invitation(
&self,
invitation: &InvitationRecord,
invite_hash: &[u8; 32],
) -> Result<(), DatabaseError> {
self.store.create_invitation(invitation, invite_hash).await
}
async fn get_invitation_by_hash(
&self,
invite_hash: &[u8; 32],
) -> Result<Option<InvitationRecord>, DatabaseError> {
self.store.get_invitation_by_hash(invite_hash).await
}
async fn accept_invitation(&self, id: Uuid, accepted_by: &str) -> Result<(), DatabaseError> {
self.store.accept_invitation(id, accepted_by).await
}
async fn list_invitations(
&self,
invited_by: Option<&str>,
) -> Result<Vec<InvitationRecord>, DatabaseError> {
self.store.list_invitations(invited_by).await
}
async fn has_any_users(&self) -> Result<bool, DatabaseError> {
self.store.has_any_users().await
}