Skip to content
Closed
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
228 changes: 226 additions & 2 deletions apps/maple-research/frontend/bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/maple-research/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"yaml": "^2.8.3"
},
"dependencies": {
"@mapleai/sdk": "4.0.1",
"@mapleai/sdk": "file:../../../sdk",
"@opensecret/react-v1": "npm:@opensecret/react@3.4.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState } from "react";
import { Link, useBlocker } from "@tanstack/react-router";
import { useOpenSecret } from "@mapleai/sdk";
import { AlertCircle, Calendar, KeyRound, Loader2, Plus, Trash2 } from "lucide-react";
import { AlertCircle, Calendar, Clock, KeyRound, Loader2, Plus, Trash2 } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { useSettingsNavigationLock } from "@/contexts/SettingsNavigationLockContext";
Expand Down Expand Up @@ -102,6 +102,12 @@ export function ApiKeysSettings() {
<Calendar className="h-3 w-3" />
Created {formatDate(apiKey.created_at)}
</p>
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{apiKey.last_used_at
? `Last used ${formatDate(apiKey.last_used_at)}`
: "Never used"}
</p>
</div>
{!isConfirmingDelete && (
<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useOpenSecret } from "@mapleai/sdk";
export type ApiKeySummary = {
name: string;
created_at: string;
last_used_at: string | null;
};

export function useApiKeys() {
Expand Down
6 changes: 6 additions & 0 deletions sdk/rust/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,10 @@ pub struct ConfirmAccountDeletionRequest {
pub struct ApiKey {
pub name: String,
pub created_at: DateTime<Utc>,
/// RFC3339 timestamp of the last request this key authenticated, or None
/// if never used. Defaults to None for compatibility with older backends.
#[serde(default)]
pub last_used_at: Option<DateTime<Utc>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand All @@ -512,6 +516,8 @@ pub struct ApiKeyCreateResponse {
pub key: String, // UUID format with dashes, only returned on creation
pub name: String,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub last_used_at: Option<DateTime<Utc>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
2 changes: 2 additions & 0 deletions sdk/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1484,6 +1484,8 @@ export type DocumentUploadInitResponse = {
export type ApiKey = {
name: string;
created_at: string;
/** RFC3339 timestamp of the last request this key authenticated, or null if never used */
last_used_at: string | null;
};

export type ApiKeyCreateResponse = ApiKey & {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
ALTER TABLE user_api_keys DROP COLUMN last_used_at;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Track when each user API key last authenticated a request.
-- Nullable: keys never used remain NULL so the UI can show "never used".
ALTER TABLE user_api_keys ADD COLUMN last_used_at TIMESTAMP WITH TIME ZONE;
7 changes: 7 additions & 0 deletions services/opensecret/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,7 @@ pub trait DBConnection {
fn get_user_api_key_by_id(&self, id: i32) -> Result<Option<UserApiKey>, DBError>;
fn get_user_api_key_by_hash(&self, key_hash: &str) -> Result<Option<UserApiKey>, DBError>;
fn get_user_by_api_key_hash(&self, key_hash: &str) -> Result<Option<User>, DBError>;
fn touch_user_api_key_last_used(&self, key_hash: &str) -> Result<bool, DBError>;
fn get_all_user_api_keys_for_user(&self, user_id: Uuid) -> Result<Vec<UserApiKey>, DBError>;
fn delete_user_api_key(&self, id: i32, user_id: Uuid) -> Result<(), DBError>;
fn delete_user_api_key_by_name(&self, name: &str, user_id: Uuid) -> Result<(), DBError>;
Expand Down Expand Up @@ -2086,6 +2087,12 @@ impl DBConnection for PostgresConnection {
.map_err(DBError::from)
}

fn touch_user_api_key_last_used(&self, key_hash: &str) -> Result<bool, DBError> {
let conn = &mut self.db.get().map_err(|_| DBError::ConnectionError)?;
let updated = UserApiKey::touch_last_used(conn, key_hash).map_err(DBError::from)?;
Ok(updated > 0)
}

fn get_all_user_api_keys_for_user(&self, user_id: Uuid) -> Result<Vec<UserApiKey>, DBError> {
let conn = &mut self.db.get().map_err(|_| DBError::ConnectionError)?;
UserApiKey::get_all_for_user(conn, user_id).map_err(DBError::from)
Expand Down
1 change: 1 addition & 0 deletions services/opensecret/src/models/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ diesel::table! {
name -> Text,
created_at -> Timestamptz,
updated_at -> Timestamptz,
last_used_at -> Nullable<Timestamptz>,
}
}

Expand Down
25 changes: 24 additions & 1 deletion services/opensecret/src/models/user_api_keys.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::models::schema::user_api_keys;
use chrono::{DateTime, Utc};
use chrono::{DateTime, Duration, Utc};
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use thiserror::Error;
Expand All @@ -26,6 +26,7 @@ pub struct UserApiKey {
pub name: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
}

impl std::fmt::Debug for UserApiKey {
Expand All @@ -37,6 +38,7 @@ impl std::fmt::Debug for UserApiKey {
.field("name", &self.name)
.field("created_at", &self.created_at)
.field("updated_at", &self.updated_at)
.field("last_used_at", &self.last_used_at)
.finish()
}
}
Expand Down Expand Up @@ -147,4 +149,25 @@ impl UserApiKey {
Ok(())
}
}

/// Records that this key just authenticated a request.
///
/// The write is throttled: it only lands when `last_used_at` is NULL or
/// older than one minute, so high-frequency authenticated requests do not
/// amplify database writes. Returns whether a row was updated.
pub fn touch_last_used(
conn: &mut PgConnection,
key_hash: &str,
) -> Result<usize, UserApiKeyError> {
let stale_before = Utc::now() - Duration::minutes(1);
diesel::update(user_api_keys::table.filter(user_api_keys::key_hash.eq(key_hash)))
.set(user_api_keys::last_used_at.eq(Utc::now()))
.filter(
user_api_keys::last_used_at
.is_null()
.or(user_api_keys::last_used_at.lt(stale_before)),
)
.execute(conn)
.map_err(UserApiKeyError::DatabaseError)
}
}
6 changes: 6 additions & 0 deletions services/opensecret/src/web/openai_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ pub async fn validate_openai_auth(
let key_hash = format!("{:x}", hasher.finalize());
match data.db.get_user_by_api_key_hash(&key_hash) {
Ok(Some(user)) => {
// Best-effort usage tracking: record that this key
// authenticated a request, but never fail auth if the
// timestamp cannot be written.
if let Err(e) = data.db.touch_user_api_key_last_used(&key_hash) {
tracing::warn!("Failed to update API key last-used timestamp: {:?}", e);
}
req.extensions_mut().insert(user);
req.extensions_mut().insert(AuthMethod::ApiKey);
return next.run(req).await;
Expand Down
5 changes: 5 additions & 0 deletions services/opensecret/src/web/protected_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ pub struct CreateApiKeyResponse {
pub key: String, // UUID format with dashes - only returned on creation
pub name: String,
pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
}

impl std::fmt::Debug for CreateApiKeyResponse {
Expand All @@ -472,6 +473,7 @@ impl std::fmt::Debug for CreateApiKeyResponse {
.field("key", &"[redacted]")
.field("name", &self.name)
.field("created_at", &self.created_at)
.field("last_used_at", &self.last_used_at)
.finish()
}
}
Expand All @@ -480,6 +482,7 @@ impl std::fmt::Debug for CreateApiKeyResponse {
pub struct ApiKeyInfo {
pub name: String,
pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -1267,6 +1270,7 @@ pub async fn create_api_key(
key: api_key_uuid.to_string(), // Return the actual UUID to the user (only time it's shown)
name: api_key_record.name,
created_at: api_key_record.created_at,
last_used_at: api_key_record.last_used_at,
};

info!("Created API key '{}' for user {}", response.name, user.uuid);
Expand All @@ -1293,6 +1297,7 @@ pub async fn list_api_keys(
.map(|key| ApiKeyInfo {
name: key.name,
created_at: key.created_at,
last_used_at: key.last_used_at,
})
.collect();

Expand Down