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
10 changes: 10 additions & 0 deletions 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 Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ hypr-agc = { path = "crates/agc", package = "agc" }
hypr-am = { path = "crates/am", package = "am" }
hypr-am2 = { path = "crates/am2", package = "am2" }
hypr-analytics = { path = "crates/analytics", package = "analytics" }
hypr-api-env = { path = "crates/api-env", package = "api-env" }
hypr-api-subscription = { path = "crates/api-subscription", package = "api-subscription" }
hypr-apple-note = { path = "crates/apple-note", package = "apple-note" }
hypr-askama-utils = { path = "crates/askama-utils", package = "askama-utils" }
Expand Down
2 changes: 2 additions & 0 deletions apps/ai/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ edition = "2024"

[dependencies]
hypr-analytics = { workspace = true }
hypr-api-env = { workspace = true }
hypr-api-subscription = { workspace = true }
hypr-llm-proxy = { workspace = true }
hypr-supabase-auth = { workspace = true }
hypr-transcribe-proxy = { workspace = true }
Expand Down
157 changes: 157 additions & 0 deletions apps/ai/openapi.gen.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
{
"openapi": "3.1.0",
"info": {
"title": "Hyprnote AI API",
"description": "AI services API for speech-to-text transcription, LLM chat completions, and subscription management",
"license": {
"name": ""
},
"version": "1.0.0"
},
"paths": {
"/subscription/can-start-trial": {
"get": {
"tags": [
"subscription"
],
"operationId": "can_start_trial",
"responses": {
"200": {
"description": "Check successful",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CanStartTrialResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"500": {
"description": "Internal server error"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/subscription/start-trial": {
"post": {
"tags": [
"subscription"
],
"operationId": "start_trial",
"parameters": [
{
"name": "interval",
"in": "query",
"required": false,
"schema": {
"$ref": "#/components/schemas/Interval"
},
"example": "monthly"
}
],
"responses": {
"200": {
"description": "Trial started successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StartTrialResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"500": {
"description": "Internal server error"
}
},
"security": [
{
"bearer_auth": []
}
]
}
}
},
"components": {
"schemas": {
"CanStartTrialResponse": {
"type": "object",
"required": [
"canStartTrial"
],
"properties": {
"canStartTrial": {
"type": "boolean",
"example": true
}
}
},
"Interval": {
"type": "string",
"enum": [
"monthly",
"yearly"
]
},
"StartTrialResponse": {
"type": "object",
"required": [
"started"
],
"properties": {
"started": {
"type": "boolean",
"example": true
}
}
}
},
"securitySchemes": {
"bearer_auth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": "Supabase JWT token"
},
"device_fingerprint": {
"type": "apiKey",
"in": "header",
"name": "x-device-fingerprint",
"description": "Optional device fingerprint for analytics"
}
}
},
"tags": [
{
"name": "stt",
"description": "Speech-to-text transcription endpoints"
},
{
"name": "llm",
"description": "LLM chat completions endpoints"
},
{
"name": "subscription",
"description": "Subscription and trial management"
},
{
"name": "transcribe",
"description": "Speech-to-text transcription proxy"
},
{
"name": "llm",
"description": "LLM chat completions proxy"
}
]
}
67 changes: 50 additions & 17 deletions apps/ai/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use axum::{
middleware::Next,
response::{IntoResponse, Response},
};
use hypr_supabase_auth::{Error as SupabaseAuthError, SupabaseAuth};
use hypr_supabase_auth::{Claims, Error as SupabaseAuthError, SupabaseAuth};

const PRO_ENTITLEMENT: &str = "hyprnote_pro";
pub const DEVICE_FINGERPRINT_HEADER: &str = "x-device-fingerprint";
Expand Down Expand Up @@ -53,30 +53,29 @@ impl IntoResponse for AuthError {
}
}

pub async fn require_pro(
State(state): State<AuthState>,
mut request: Request,
next: Next,
) -> Result<Response, AuthError> {
let auth_header = request
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok())
.ok_or(SupabaseAuthError::MissingAuthHeader)?;
struct AuthResult {
token: String,
claims: Claims,
}

async fn setup_auth(state: &AuthState, request: &mut Request) -> Result<AuthResult, AuthError> {
let device_fingerprint = request
.headers()
.get(DEVICE_FINGERPRINT_HEADER)
.and_then(|h| h.to_str().ok())
.map(String::from);

let auth_header = request
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok())
.ok_or(SupabaseAuthError::MissingAuthHeader)?;

let token =
SupabaseAuth::extract_token(auth_header).ok_or(SupabaseAuthError::InvalidAuthHeader)?;
let token = token.to_string();

let claims = state
.inner
.require_entitlement(token, PRO_ENTITLEMENT)
.await?;
let claims = state.inner.verify_token(&token).await?;

sentry::configure_scope(|scope| {
scope.set_user(Some(sentry::User {
Expand Down Expand Up @@ -107,10 +106,44 @@ pub async fn require_pro(
.insert(hypr_analytics::DeviceFingerprint(fingerprint));
}

let user_id = claims.sub.clone();
request
.extensions_mut()
.insert(hypr_analytics::AuthenticatedUserId(user_id));
.insert(hypr_analytics::AuthenticatedUserId(claims.sub.clone()));

Ok(AuthResult { token, claims })
}

pub async fn require_pro(
State(state): State<AuthState>,
mut request: Request,
next: Next,
) -> Result<Response, AuthError> {
let auth = setup_auth(&state, &mut request).await?;

if !auth
.claims
.entitlements
.contains(&PRO_ENTITLEMENT.to_string())
{
return Err(SupabaseAuthError::MissingEntitlement(PRO_ENTITLEMENT.to_string()).into());
}

Ok(next.run(request).await)
}

pub async fn require_auth(
State(state): State<AuthState>,
mut request: Request,
next: Next,
) -> Result<Response, AuthError> {
let auth = setup_auth(&state, &mut request).await?;

request
.extensions_mut()
.insert(hypr_api_subscription::AuthContext {
token: auth.token,
claims: auth.claims,
});

Ok(next.run(request).await)
}
5 changes: 4 additions & 1 deletion apps/ai/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ pub struct Env {
pub sentry_dsn: Option<String>,
#[serde(default, deserialize_with = "filter_empty")]
pub posthog_api_key: Option<String>,
pub supabase_url: String,

#[serde(flatten)]
pub supabase: hypr_api_env::SupabaseEnv,
#[serde(flatten)]
pub stripe: hypr_api_subscription::StripeEnv,
#[serde(flatten)]
pub llm: hypr_llm_proxy::Env,
#[serde(flatten)]
Expand Down
23 changes: 20 additions & 3 deletions apps/ai/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,23 @@ fn app() -> Router {
let llm_config =
hypr_llm_proxy::LlmProxyConfig::new(&env.llm).with_analytics(analytics.clone());
let stt_config = hypr_transcribe_proxy::SttProxyConfig::new(&env.stt).with_analytics(analytics);
let auth_state = AuthState::new(&env.supabase_url);
let auth_state = AuthState::new(&env.supabase.supabase_url);

let protected_routes = Router::new()
let subscription_config =
hypr_api_subscription::SubscriptionConfig::new(&env.supabase, &env.stripe);
let subscription_state = hypr_api_subscription::AppState::new(subscription_config);

let auth_routes = Router::new()
.nest(
"/subscription",
hypr_api_subscription::router(subscription_state),
)
.route_layer(middleware::from_fn_with_state(
auth_state.clone(),
auth::require_auth,
));

let pro_routes = Router::new()
.merge(hypr_transcribe_proxy::listen_router(stt_config.clone()))
.merge(hypr_llm_proxy::chat_completions_router(llm_config.clone()))
.nest("/stt", hypr_transcribe_proxy::router(stt_config))
Expand All @@ -52,7 +66,8 @@ fn app() -> Router {
Router::new()
.route("/health", axum::routing::get(|| async { "ok" }))
.route("/openapi.json", axum::routing::get(openapi_json))
.merge(protected_routes)
.merge(auth_routes)
.merge(pro_routes)
.layer(
CorsLayer::new()
.allow_origin(cors::Any)
Expand Down Expand Up @@ -145,6 +160,8 @@ fn app() -> Router {
}

fn main() -> std::io::Result<()> {
openapi::write_json();

let env = env();

let _guard = sentry::init(sentry::ClientOptions {
Expand Down
14 changes: 12 additions & 2 deletions apps/ai/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ use utoipa::{Modify, OpenApi};
info(
title = "Hyprnote AI API",
version = "1.0.0",
description = "AI services API for speech-to-text transcription and LLM chat completions"
description = "AI services API for speech-to-text transcription, LLM chat completions, and subscription management"
),
tags(
(name = "stt", description = "Speech-to-text transcription endpoints"),
(name = "llm", description = "LLM chat completions endpoints")
(name = "llm", description = "LLM chat completions endpoints"),
(name = "subscription", description = "Subscription and trial management")
),
modifiers(&SecurityAddon)
)]
Expand All @@ -21,13 +22,22 @@ pub fn openapi() -> utoipa::openapi::OpenApi {

let stt_doc = hypr_transcribe_proxy::openapi();
let llm_doc = hypr_llm_proxy::openapi();
let subscription_doc = hypr_api_subscription::openapi();

doc.merge(stt_doc);
doc.merge(llm_doc);
doc.merge(subscription_doc);

doc
}

pub fn write_json() {
let doc = openapi();
let json = serde_json::to_string_pretty(&doc).expect("Failed to serialize OpenAPI spec");
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("openapi.gen.json");
std::fs::write(&path, json).expect("Failed to write openapi.gen.json");
}

struct SecurityAddon;

impl Modify for SecurityAddon {
Expand Down
Loading
Loading