From 8742d3e2f087c9877144606596779c9280048155 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:54:09 +0800 Subject: [PATCH 1/5] feat(api): retire stateful chat surfaces --- README.md | 21 +- crates/api/src/openapi.rs | 82 ++- crates/api/src/routes/api.rs | 500 ++++++++++++----- crates/api/tests/README.md | 163 +----- crates/api/tests/conversations_tests.rs | 404 +++----------- crates/api/tests/files_tests.rs | 515 ++---------------- crates/api/tests/response_author_tests.rs | 490 ----------------- .../api/tests/responses_permissions_tests.rs | 22 +- crates/api/tests/responses_stateless_tests.rs | 108 ++++ 9 files changed, 676 insertions(+), 1629 deletions(-) delete mode 100644 crates/api/tests/response_author_tests.rs create mode 100644 crates/api/tests/responses_stateless_tests.rs diff --git a/README.md b/README.md index b9c219be..fa516726 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@ [![Security Audit](https://github.com/nearai/chat-api/actions/workflows/security-audit.yml/badge.svg)](https://github.com/nearai/chat-api/actions/workflows/security-audit.yml) -A Rust backend service that proxies requests to **NEAR AI Cloud API** (using OpenAI-compatible API format) while tracking user conversations in PostgreSQL. Provides OAuth authentication (Google/GitHub), user session management, and serves a frontend as static files. Designed to run in a Trusted Execution Environment (TEE) for enhanced security and privacy. +A Rust backend service that proxies OpenAI-compatible inference requests to **NEAR AI Cloud API**. It provides OAuth authentication (Google/GitHub), user session management, and serves a frontend as static files. Designed to run in a Trusted Execution Environment (TEE) for enhanced security and privacy. ## Features - 🔒 **TEE Execution**: Runs in a Trusted Execution Environment with cryptographic attestation - 🤖 **OpenAI-Compatible API**: Drop-in replacement for OpenAI API endpoints (proxies to NEAR AI Cloud API) - 🔐 **OAuth Authentication**: Google and GitHub OAuth support -- 💬 **Conversation Tracking**: Persistent conversation management in PostgreSQL +- 🧠 **Stateless Responses**: `/v1/responses` always forwards requests with `store: false` - 📊 **User Management**: Session management, user settings, and analytics - ⚡ **Streaming**: Real-time SSE streaming for AI responses @@ -20,7 +20,7 @@ A Rust backend service that proxies requests to **NEAR AI Cloud API** (using Ope ``` crates/ ├── api/ # Axum HTTP server, routes, middleware, OpenAPI docs (utoipa) -├── services/ # Business logic: auth, conversation, response proxy, user management +├── services/ # Business logic: auth, response proxy, user management ├── database/ # PostgreSQL (tokio-postgres, deadpool), migrations, repositories └── config/ # Environment-based configuration structs ``` @@ -29,14 +29,14 @@ crates/ - **Repository Pattern**: Database access through trait-based repositories (`PostgresUserRepository`, etc.) - **Service Layer**: Business logic in `services` crate, injected into `AppState` -- **NEAR AI Cloud API Proxy**: All `/v1/*` routes forward to NEAR AI Cloud API with auth; conversation endpoints (`/v1/conversations/*`) track IDs in PostgreSQL +- **NEAR AI Cloud API Proxy**: OpenAI-compatible inference routes forward to NEAR AI Cloud API with auth; Responses requests are stateless - **Patroni Support**: Optional cluster discovery for HA PostgreSQL via `DATABASE_PRIMARY_APP_ID` ### Request Flow 1. Request → Auth middleware (validates session token) → Route handler -2. Conversation operations → Forward to NEAR AI Cloud API → Parse response → Track in DB -3. Generic `/v1/{*path}` → Forward to NEAR AI Cloud API (pass-through) +2. `/v1/responses` → validate stateless fields → forward to NEAR AI Cloud API with `store: false` +3. Usage, subscription, rate-limit, and attestation-related proxy behavior remain local to Chat API ## Development @@ -83,7 +83,7 @@ docker compose down # Stop services ```bash cargo test --features test # All tests cargo test --test admin_tests --features test # Admin tests only -cargo test --test e2e_api_tests --features test -- --ignored --nocapture # E2E tests (real API calls) +cargo test --test responses_permissions_tests --features test # Stateless Responses boundary tests ``` ### Code Quality @@ -184,13 +184,14 @@ OpenAPI docs available at `/docs`. **Key endpoints**: - `/v1/auth/*` - OAuth authentication -- `/v1/conversations/*` - Conversation management -- `/v1/responses` - OpenAI-compatible Responses API (proxied to NEAR AI Cloud API) +- `/v1/responses` - OpenAI-compatible, stateless Responses API (proxied to NEAR AI Cloud API) - `/v1/attestation/report` - TEE attestation reports - `/v1/users/*` - User management - `/v1/admin/*` - Admin operations -**Note**: All requests are proxied to **NEAR AI Cloud API** (with OpenAI compatible endpoints). Set `OPENAI_BASE_URL` to your NEAR AI Cloud API endpoint. +**Stateless migration**: `/v1/conversations/**`, `/v1/files/**`, `/v1/share-groups/**`, and `/v1/shared-with-me` are retired. After their historical authentication boundary, they return `410 Gone`. Build multi-turn context in the next request rather than referring to a stored conversation or response. `/v1/responses` rejects stateful fields such as `conversation`, `previous_response_id`, `store: true`, file input, background mode, and continuation-dependent tools with a local `400` response. + +**Note**: OpenAI-compatible inference requests are proxied to **NEAR AI Cloud API**. Set `OPENAI_BASE_URL` to your NEAR AI Cloud API endpoint. ## Security & Privacy diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index 268d8e8b..d3ba5eaf 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -6,7 +6,7 @@ use utoipa::OpenApi; #[openapi( info( title = "NEAR AI Chat API", - description = "A comprehensive chat API for Private Chat.", + description = "An OpenAI-compatible, stateless inference proxy for NEAR AI Cloud API.", version = "1.0.0", contact(name = "NEAR AI Team", email = "support@near.ai"), license(name = "MIT",) @@ -25,34 +25,6 @@ use utoipa::OpenApi; crate::routes::users::get_user_status, crate::routes::users::delete_current_user, crate::routes::users::get_my_usage, - // Conversation endpoints - crate::routes::api::create_conversation, - crate::routes::api::list_conversations, - crate::routes::api::get_conversation, - crate::routes::api::update_conversation, - crate::routes::api::delete_conversation, - crate::routes::api::create_conversation_share, - crate::routes::api::list_conversation_shares, - crate::routes::api::delete_conversation_share, - crate::routes::api::create_conversation_items, - crate::routes::api::list_conversation_items, - crate::routes::api::pin_conversation, - crate::routes::api::unpin_conversation, - crate::routes::api::archive_conversation, - crate::routes::api::unarchive_conversation, - crate::routes::api::clone_conversation, - // Share group endpoints - crate::routes::api::create_share_group, - crate::routes::api::list_share_groups, - crate::routes::api::update_share_group, - crate::routes::api::delete_share_group, - crate::routes::api::list_shared_with_me, - // File endpoints - crate::routes::api::upload_file, - crate::routes::api::list_files, - crate::routes::api::get_file, - crate::routes::api::delete_file, - crate::routes::api::get_file_content, // Proxy endpoints crate::routes::api::proxy_responses, crate::routes::api::proxy_chat_completions, @@ -175,23 +147,7 @@ use utoipa::OpenApi; // Admin usage models (UserUsageResponse shared with /users/me/usage) crate::models::UserUsageResponse, crate::routes::admin::TopUsageResponse, - // Conversation share models crate::routes::api::ErrorResponse, - crate::routes::api::ShareRecipientPayload, - crate::routes::api::ShareTargetPayload, - crate::routes::api::CreateConversationShareRequest, - crate::routes::api::ConversationShareResponse, - crate::routes::api::OwnerInfo, - crate::routes::api::ConversationSharesListResponse, - // Share group models - crate::routes::api::CreateShareGroupRequest, - crate::routes::api::UpdateShareGroupRequest, - crate::routes::api::ShareGroupResponse, - crate::routes::api::SharedConversationInfo, - // File models - crate::models::FileListResponse, - crate::models::FileGetResponse, - crate::routes::api::ListFilesParams, // Credits models crate::routes::credits::CreateCreditCheckoutRequest, crate::routes::credits::CreateCreditCheckoutResponse, @@ -267,9 +223,6 @@ use utoipa::OpenApi; (name = "Health", description = "Health check and service status endpoints"), (name = "Auth", description = "OAuth authentication endpoints"), (name = "Users", description = "User profile management endpoints"), - (name = "Conversations", description = "Conversation management endpoints (supports optional authentication for public sharing)"), - (name = "Share Groups", description = "Share group management endpoints"), - (name = "Files", description = "File management endpoints"), (name = "Proxy", description = "Proxy endpoints for OpenAI-compatible APIs"), (name = "Credits", description = "Credit purchase and balance endpoints"), (name = "Subscriptions", description = "Subscription management endpoints"), @@ -300,3 +253,36 @@ impl utoipa::Modify for SecurityAddon { } } } + +#[cfg(test)] +mod tests { + use super::ApiDoc; + use utoipa::OpenApi; + + #[test] + fn omits_retired_stateful_api_paths() { + let spec = serde_json::to_value(ApiDoc::openapi()).expect("OpenAPI serialization"); + + for path in [ + "/v1/conversations", + "/v1/conversations/{conversation_id}", + "/v1/conversations/{conversation_id}/items", + "/v1/conversations/{conversation_id}/shares", + "/v1/conversations/{conversation_id}/shares/{share_id}", + "/v1/conversations/{conversation_id}/pin", + "/v1/conversations/{conversation_id}/archive", + "/v1/conversations/{conversation_id}/clone", + "/v1/files", + "/v1/files/{file_id}", + "/v1/files/{file_id}/content", + "/v1/share-groups", + "/v1/share-groups/{group_id}", + "/v1/shared-with-me", + ] { + assert!( + spec["paths"].get(path).is_none(), + "retired stateful path {path} must not be in OpenAPI" + ); + } + } +} diff --git a/crates/api/src/routes/api.rs b/crates/api/src/routes/api.rs index 1c1733c8..b77590a2 100644 --- a/crates/api/src/routes/api.rs +++ b/crates/api/src/routes/api.rs @@ -1,3 +1,8 @@ +// #379 changes the public surface first. The dormant stateful handlers below +// are intentionally retained for the #381 cleanup follow-up, so suppress their +// temporary dead-code warnings without changing the rollback boundary. +#![allow(dead_code)] + use crate::consts::{ LIST_FILES_LIMIT_MAX, MAX_DECOMPRESSED_RESPONSE_BODY_SIZE, MAX_REQUEST_BODY_SIZE, MAX_RESPONSE_BODY_SIZE, @@ -12,7 +17,7 @@ use axum::{ extract::{Extension, Path, Request, State}, http::{HeaderMap, Method, StatusCode}, response::{IntoResponse, Response}, - routing::{delete, get, patch, post}, + routing::{any, delete, get, patch, post}, Json, Router, }; use bytes::Bytes; @@ -92,42 +97,47 @@ mod openapi_errors { use openapi_errors::*; use openapi_tags::*; -/// Create router for conversation read routes that work with optional authentication -/// These routes can be accessed by both authenticated users and unauthenticated users -/// (for publicly shared conversations) +/// Create retirement routes for legacy public conversation reads. +/// +/// These paths historically supported optional authentication for public shares. +/// Keep that boundary while returning the same migration response as the +/// authenticated stateful routes. pub fn create_optional_auth_router() -> Router { Router::new() - .route("/v1/conversations/{conversation_id}", get(get_conversation)) + .route( + "/v1/conversations/{conversation_id}", + get(retired_stateful_api), + ) .route( "/v1/conversations/{conversation_id}/items", - get(list_conversation_items), + get(retired_stateful_api), ) } /// Create the unified API router with all v1 proxy and API routes. /// /// Route groups and their middleware: -/// - Chat completions, images, responses: dual auth + subscription + rate limited +/// - Chat completions and images: dual auth + subscription + rate limited +/// - Responses: dual auth + subscription + rate limited, always no-store /// - Model list, models, signature: dual auth only (not rate limited) -/// - Conversations, share groups, files: session auth only +/// - Retired conversations, share groups, files: their existing auth boundary pub fn create_api_router( rate_limit_state: crate::middleware::RateLimitState, dual_auth_state: crate::middleware::DualAuthState, auth_state: crate::middleware::AuthState, subscription_state: crate::middleware::SubscriptionState, ) -> Router { - // Dual auth + subscription + rate limited: chat completions, images, responses + // Dual auth + subscription + rate limited: chat completions and images let llm_proxy_router = Router::new() .route("/v1/chat/completions", post(proxy_chat_completions)) .route("/v1/images/generations", post(proxy_image_generations)) .route("/v1/images/edits", post(proxy_image_edits)) - .route("/v1/responses", post(proxy_responses)) .layer(axum::middleware::from_fn_with_state( rate_limit_state.clone(), crate::middleware::rate_limit_middleware, )) .layer(axum::middleware::from_fn_with_state( - subscription_state, + subscription_state.clone(), crate::middleware::subscription_middleware, )) .layer(axum::middleware::from_fn_with_state( @@ -135,6 +145,24 @@ pub fn create_api_router( crate::middleware::dual_auth_middleware, )); + // Keep every Responses result out of shared caches, including failures + // returned by authentication, subscription, or rate-limit middleware. + let responses_proxy_router = Router::new() + .route("/v1/responses", post(proxy_responses)) + .layer(axum::middleware::from_fn_with_state( + rate_limit_state, + crate::middleware::rate_limit_middleware, + )) + .layer(axum::middleware::from_fn_with_state( + subscription_state, + crate::middleware::subscription_middleware, + )) + .layer(axum::middleware::from_fn_with_state( + dual_auth_state.clone(), + crate::middleware::dual_auth_middleware, + )) + .layer(axum::middleware::map_response(force_no_store_response)); + // Dual auth only (not rate limited): model list, models, signature let models_proxy_router = Router::new() .route("/v1/model/list", get(proxy_model_list)) @@ -154,56 +182,96 @@ pub fn create_api_router( crate::middleware::dual_auth_middleware, )); - // Session auth only: conversations, share groups, files + // Retired stateful API surfaces retain their existing session-auth boundary. + // Their handlers deliberately do not access the legacy services, database, + // or Cloud API; they return a consistent migration response instead. let conversations_router = Router::new() .route( "/v1/conversations", - post(create_conversation).get(list_conversations), + post(retired_stateful_api) + .get(retired_stateful_api) + .fallback(retired_stateful_api), ) .route( "/v1/conversations/{conversation_id}", - post(update_conversation).delete(delete_conversation), + post(retired_stateful_api) + .delete(retired_stateful_api) + .fallback(retired_stateful_api), ) .route( "/v1/conversations/{conversation_id}/shares", - post(create_conversation_share).get(list_conversation_shares), + post(retired_stateful_api) + .get(retired_stateful_api) + .fallback(retired_stateful_api), ) .route( "/v1/conversations/{conversation_id}/shares/{share_id}", - delete(delete_conversation_share), + delete(retired_stateful_api).fallback(retired_stateful_api), ) .route( "/v1/conversations/{conversation_id}/items", - post(create_conversation_items), + post(retired_stateful_api).fallback(retired_stateful_api), ) .route( "/v1/conversations/{conversation_id}/pin", - post(pin_conversation).delete(unpin_conversation), + post(retired_stateful_api) + .delete(retired_stateful_api) + .fallback(retired_stateful_api), ) .route( "/v1/conversations/{conversation_id}/archive", - post(archive_conversation).delete(unarchive_conversation), + post(retired_stateful_api) + .delete(retired_stateful_api) + .fallback(retired_stateful_api), ) .route( "/v1/conversations/{conversation_id}/clone", - post(clone_conversation), - ); + post(retired_stateful_api).fallback(retired_stateful_api), + ) + // Keep the retirement behavior stable for unversioned legacy children + // too. The known public GET paths use create_optional_auth_router. + .route("/v1/conversations/", any(retired_stateful_api)) + .route("/v1/conversations/{*path}", any(retired_stateful_api)); let share_groups_router = Router::new() .route( "/v1/share-groups", - post(create_share_group).get(list_share_groups), + post(retired_stateful_api) + .get(retired_stateful_api) + .fallback(retired_stateful_api), ) .route( "/v1/share-groups/{group_id}", - patch(update_share_group).delete(delete_share_group), + patch(retired_stateful_api) + .delete(retired_stateful_api) + .fallback(retired_stateful_api), ) - .route("/v1/shared-with-me", get(list_shared_with_me)); + .route("/v1/share-groups/", any(retired_stateful_api)) + .route("/v1/share-groups/{*path}", any(retired_stateful_api)) + .route( + "/v1/shared-with-me", + get(retired_stateful_api).fallback(retired_stateful_api), + ); let files_router = Router::new() - .route("/v1/files", post(upload_file).get(list_files)) - .route("/v1/files/{file_id}", get(get_file).delete(delete_file)) - .route("/v1/files/{file_id}/content", get(get_file_content)); + .route( + "/v1/files", + post(retired_stateful_api) + .get(retired_stateful_api) + .fallback(retired_stateful_api), + ) + .route( + "/v1/files/{file_id}", + get(retired_stateful_api) + .delete(retired_stateful_api) + .fallback(retired_stateful_api), + ) + .route( + "/v1/files/{file_id}/content", + get(retired_stateful_api).fallback(retired_stateful_api), + ) + .route("/v1/files/", any(retired_stateful_api)) + .route("/v1/files/{*path}", any(retired_stateful_api)); let session_auth_routes = Router::new() .merge(conversations_router) @@ -216,6 +284,7 @@ pub fn create_api_router( Router::new() .merge(llm_proxy_router) + .merge(responses_proxy_router) .merge(models_proxy_router) .merge(mcp_router) .merge(session_auth_routes) @@ -235,6 +304,169 @@ pub struct ErrorResponse { pub error: String, } +/// Message returned by every retired stateful API route. +/// +/// The stateful surfaces are intentionally still registered so clients receive +/// a clear migration signal rather than a proxy error from Cloud API. +pub const STATEFUL_API_RETIRED_MESSAGE: &str = + "This stateful API has been retired. Use /v1/responses with store: false and include all context in each request."; + +fn with_no_store_cache_control(mut response: Response) -> Response { + response.headers_mut().insert( + http::header::CACHE_CONTROL, + HeaderValue::from_static("no-store"), + ); + response +} + +/// Apply no-store to every `/v1/responses` result, including middleware and +/// extractor rejections that do not enter `proxy_responses` itself. +async fn force_no_store_response(response: Response) -> Response { + with_no_store_cache_control(response) +} + +/// Return the stable migration response for legacy conversation, file, and +/// sharing routes. The route remains behind its existing authentication layer. +async fn retired_stateful_api() -> Response { + with_no_store_cache_control( + ( + StatusCode::GONE, + Json(ErrorResponse { + error: STATEFUL_API_RETIRED_MESSAGE.to_string(), + }), + ) + .into_response(), + ) +} + +fn stateless_responses_bad_request(error: impl Into) -> Response { + with_no_store_cache_control( + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: error.into(), + }), + ) + .into_response(), + ) +} + +/// Reject response fields that require Cloud API to retain conversation, file, +/// or response state. Keep the messages aligned with Cloud API so callers get +/// a stable 400 locally instead of a version-dependent upstream error. +fn validate_stateless_response_body(body: &serde_json::Value) -> Result<(), &'static str> { + let Some(request) = body.as_object() else { + // Leave malformed root values to Cloud API's normal request parsing. + return Ok(()); + }; + + if request.get("store").and_then(serde_json::Value::as_bool) == Some(true) { + return Err("The Responses API only supports store: false."); + } + + if request + .get("conversation") + .is_some_and(|value| !value.is_null()) + { + return Err("The stateless Responses API does not support conversation."); + } + + if request + .get("previous_response_id") + .is_some_and(|value| !value.is_null()) + { + return Err("The stateless Responses API does not support previous_response_id."); + } + + if request + .get("background") + .and_then(serde_json::Value::as_bool) + == Some(true) + { + return Err("The stateless Responses API does not support background."); + } + + if let Some(input_items) = request.get("input").and_then(serde_json::Value::as_array) { + for item in input_items { + match item.get("type").and_then(serde_json::Value::as_str) { + Some("mcp_approval_response") => { + return Err( + "The stateless Responses API does not support MCP approval continuation.", + ); + } + Some("function_call_output") => { + return Err( + "The stateless Responses API does not support function continuation.", + ); + } + _ => {} + } + + if item + .get("content") + .and_then(serde_json::Value::as_array) + .is_some_and(|parts| { + parts.iter().any(|part| { + part.get("type").and_then(serde_json::Value::as_str) == Some("input_file") + }) + }) + { + return Err("The stateless Responses API does not support input_file."); + } + } + } + + if let Some(tools) = request.get("tools").and_then(serde_json::Value::as_array) { + for tool in tools { + match tool.get("type").and_then(serde_json::Value::as_str) { + Some("file_search") => { + return Err("The stateless Responses API does not support file_search."); + } + Some("function") => { + return Err( + "The stateless Responses API does not support function tools because they require continuation.", + ); + } + Some("code_interpreter") => { + return Err( + "The stateless Responses API does not support code_interpreter because it requires continuation.", + ); + } + Some("computer") => { + return Err( + "The stateless Responses API does not support computer because it requires continuation.", + ); + } + Some("mcp") + if tool + .get("require_approval") + .and_then(serde_json::Value::as_str) + != Some("never") => + { + return Err( + "The stateless Responses API does not support MCP tools that require approval.", + ); + } + _ => {} + } + } + } + + Ok(()) +} + +/// Normalize a valid JSON request at the proxy boundary. This makes the +/// no-store contract explicit even when a client omits `store`. +fn normalize_stateless_response_body(body: &mut serde_json::Value) -> Result<(), &'static str> { + validate_stateless_response_body(body)?; + + if let Some(request) = body.as_object_mut() { + request.insert("store".to_string(), serde_json::Value::Bool(false)); + } + + Ok(()) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct InvalidProxyPathSegment; @@ -2278,7 +2510,7 @@ async fn get_file_content( .await } -/// Proxy responses endpoint - forwards to OpenAI with model settings and author metadata injection +/// Proxy a single, stateless Responses request to Cloud API. #[utoipa::path( post, path = "/v1/responses", @@ -2353,16 +2585,12 @@ async fn proxy_responses( } } - // If a conversation ID is provided, this is a write operation on an existing conversation. - // Enforce that the caller has write access (owner OR shared with write permission). + // Reject every request feature that would make Cloud API retain state + // before it can be forwarded. This avoids exposing version-dependent + // upstream validation and makes the stateless contract explicit here. if let Some(ref body) = body_json { - if let Some(conversation_id) = body - .get("conversation") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { - validate_user_conversation(&state, &user, conversation_id, SharePermission::Write) - .await?; + if let Err(error) = validate_stateless_response_body(body) { + return Err(stateless_responses_bad_request(error)); } } @@ -2389,11 +2617,12 @@ async fn proxy_responses( } } - // Fetch user profile to inject author metadata into messages - let user_profile = state.user_service.get_user_profile(user.user_id).await.ok(); - - // Modify request body to inject system prompt and/or author metadata + // Modify the request only for the existing model-level system prompt and + // to make its no-store contract explicit. Do not inject author metadata: + // it previously forced `store: true` and depended on conversation state. let modified_body_bytes = if let Some(mut body) = body_json { + normalize_stateless_response_body(&mut body).map_err(stateless_responses_bad_request)?; + // Inject model-level system prompt if present if let Some(system_prompt) = model_system_prompt.as_ref() { let new_instructions = match body.get("instructions").and_then(|v| v.as_str()) { @@ -2405,33 +2634,6 @@ async fn proxy_responses( body["instructions"] = serde_json::Value::String(new_instructions); } - // Inject author metadata with user info - // This allows shared conversations to show who sent each message. - // Author tracking is handled by cloud-api. - if let Some(profile) = user_profile { - let mut metadata = body - .get("metadata") - .and_then(|m| m.as_object()) - .cloned() - .unwrap_or_default(); - - metadata.insert( - "author_id".to_string(), - serde_json::Value::String(user.user_id.to_string()), - ); - if let Some(name) = profile.user.name.as_ref() { - metadata.insert( - "author_name".to_string(), - serde_json::Value::String(name.clone()), - ); - } - - body["metadata"] = serde_json::Value::Object(metadata); - - // OpenAI requires `store: true` when `metadata` is present for some models. - body["store"] = serde_json::Value::Bool(true); - } - match serde_json::to_vec(&body) { Ok(serialized) => Bytes::from(serialized), Err(_) => { @@ -2454,19 +2656,6 @@ async fn proxy_responses( .expect("usize to string conversion always produces valid HeaderValue"); headers.insert(CONTENT_LENGTH, content_length); - // Track conversation from the request - tracing::debug!("POST to /responses detected, attempting to track conversation"); - if let Err(e) = - track_conversation_from_request(&state, user.user_id, &modified_body_bytes).await - { - tracing::error!( - "Failed to track conversation for user {} from /responses: {}", - user.user_id, - e - ); - // Don't fail the request if conversation tracking fails - } - tracing::debug!( "Forwarding POST /v1/responses to OpenAI for user_id={}", user.user_id @@ -2599,7 +2788,9 @@ async fn proxy_responses( Body::from(bytes) }; - build_response(proxy_response.status, proxy_response.headers, response_body).await + let response = + build_response(proxy_response.status, proxy_response.headers, response_body).await?; + Ok(with_no_store_cache_control(response)) } /// Ensure that if the authenticated user logged in with NEAR (has a NEAR-linked account), @@ -4738,7 +4929,7 @@ async fn build_response(status: u16, headers: HeaderMap, body: Body) -> Result Result anyhow::Result<()> { - tracing::debug!( - "Attempting to track conversation from /responses request for user_id={}", - user_id - ); - - // Parse the request body to extract conversation_id if present - #[derive(Deserialize)] - struct ResponseRequest { - conversation: Option, - } - - if let Ok(req) = serde_json::from_slice::(body) { - if let Some(conversation_id) = req.conversation { - tracing::info!( - "Found conversation_id={} in /responses request for user_id={}, tracking...", - conversation_id, - user_id - ); - - state - .conversation_service - .track_conversation(&conversation_id, user_id) - .await?; - - tracing::info!( - "Successfully tracked conversation {} from /responses for user_id={}", - conversation_id, - user_id - ); - } else { - tracing::debug!( - "No conversation_id found in /responses request body for user_id={}", - user_id - ); - } - } else { - tracing::debug!( - "Failed to parse /responses request body for user_id={}", - user_id - ); - } - - Ok(()) -} - async fn validate_user_conversation( state: &crate::state::AppState, user: &AuthenticatedUser, @@ -5429,7 +5569,10 @@ async fn collect_stream_to_bytes( #[cfg(test)] mod tests { - use super::{decompress_if_encoded, ensure_stream_usage_options, validate_proxy_path_segment}; + use super::{ + decompress_if_encoded, ensure_stream_usage_options, normalize_stateless_response_body, + validate_proxy_path_segment, validate_stateless_response_body, + }; use bytes::Bytes; use flate2::{write::DeflateEncoder, write::GzEncoder, write::ZlibEncoder, Compression}; use http::{HeaderMap, HeaderValue}; @@ -5508,6 +5651,93 @@ mod tests { } } + #[test] + fn stateless_responses_normalizes_store_without_author_metadata() { + let mut body = json!({ + "model": "test-model", + "input": "hello", + "metadata": { "client_key": "client_value" } + }); + + normalize_stateless_response_body(&mut body).expect("stateless request should be valid"); + + assert_eq!(body["store"], json!(false)); + assert_eq!(body["metadata"], json!({ "client_key": "client_value" })); + assert!(body["metadata"].get("author_id").is_none()); + assert!(body["metadata"].get("author_name").is_none()); + } + + #[test] + fn stateless_responses_rejects_every_cloud_stateful_feature() { + let cases = [ + ( + json!({ "store": true }), + "The Responses API only supports store: false.", + ), + ( + json!({ "conversation": "conv_legacy" }), + "The stateless Responses API does not support conversation.", + ), + ( + json!({ "previous_response_id": "resp_legacy" }), + "The stateless Responses API does not support previous_response_id.", + ), + ( + json!({ "background": true }), + "The stateless Responses API does not support background.", + ), + ( + json!({ "input": [{ "type": "function_call_output" }] }), + "The stateless Responses API does not support function continuation.", + ), + ( + json!({ "input": [{ "type": "mcp_approval_response" }] }), + "The stateless Responses API does not support MCP approval continuation.", + ), + ( + json!({ + "input": [{ + "content": [{ "type": "input_file", "file_id": "file_legacy" }] + }] + }), + "The stateless Responses API does not support input_file.", + ), + ( + json!({ "tools": [{ "type": "file_search" }] }), + "The stateless Responses API does not support file_search.", + ), + ( + json!({ "tools": [{ "type": "function" }] }), + "The stateless Responses API does not support function tools because they require continuation.", + ), + ( + json!({ "tools": [{ "type": "code_interpreter" }] }), + "The stateless Responses API does not support code_interpreter because it requires continuation.", + ), + ( + json!({ "tools": [{ "type": "computer" }] }), + "The stateless Responses API does not support computer because it requires continuation.", + ), + ( + json!({ "tools": [{ "type": "mcp" }] }), + "The stateless Responses API does not support MCP tools that require approval.", + ), + ]; + + for (body, expected) in cases { + assert_eq!( + validate_stateless_response_body(&body), + Err(expected), + "stateful body should be rejected: {body}" + ); + } + + assert!(validate_stateless_response_body(&json!({ + "tools": [{ "type": "mcp", "require_approval": "never" }] + })) + .is_ok()); + } + #[test] fn streaming_chat_requests_enable_usage_options() { let mut body = json!({ diff --git a/crates/api/tests/README.md b/crates/api/tests/README.md index 7c3dfbe0..5aeb28ca 100644 --- a/crates/api/tests/README.md +++ b/crates/api/tests/README.md @@ -1,153 +1,30 @@ -# End-to-End API Tests +# API Tests -## Overview +These integration tests exercise Chat API's authentication, billing, proxy, and +stateless Responses boundaries. They use the `test` feature to enable the +mock-login endpoint and require a PostgreSQL test database. -This directory contains comprehensive end-to-end tests for the conversation tracking functionality. The tests verify that the API correctly: +## Stateless API coverage -1. Tracks conversation IDs per user in the local database -2. Fetches conversation details from OpenAI API on demand -3. Maintains proper access control for conversations -4. Handles edge cases like empty lists and response-triggered tracking +- `responses_permissions_tests` verifies that stateful Responses fields fail + locally with a stable `400` response. +- `conversations_tests` and `files_tests` verify that the retired stateful API + surfaces return the same `410 Gone` migration response after their historical + authentication boundary, without reaching Cloud API or local state services. -## Architecture Being Tested +The retired paths are intentionally absent from OpenAPI. They are still mounted +at their historical authentication boundaries so clients receive a clear +migration response instead of a proxy failure. -The conversation management system works as follows: - -``` -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ Client │────────>│ Chat API │────────>│ Database │ -│ │ │ │ │ │ -│ │ │ - Track │ │ - User IDs │ -│ │ │ Conv IDs │ │ - Conv IDs │ -└─────────────┘ └─────────────┘ └─────────────┘ - │ - │ Fetch Details - ▼ - ┌─────────────┐ - │ OpenAI API │ - │ │ - │ - Titles │ - │ - Messages │ - │ - Metadata │ - └─────────────┘ -``` - -## Test Cases - -### 1. `test_conversation_workflow` -Tests the complete conversation lifecycle: -- Creates a conversation via OpenAI -- Adds multiple responses to the conversation -- Lists conversations and verifies details are fetched from OpenAI -- Confirms that conversation tracking works end-to-end - -### 2. `test_conversation_access_control` -Verifies access control mechanisms: -- Creates a conversation for a user -- Attempts to access it as the owner (should succeed) -- Validates proper authorization checks - -### 3. `test_empty_conversation_list` -Tests edge case handling: -- Lists conversations when there may be zero or many -- Ensures the endpoint handles all cases gracefully - -### 4. `test_conversation_tracking_on_response_creation` -Tests automatic conversation tracking: -- Creates a conversation -- Adds a response (which triggers automatic tracking) -- Verifies the conversation appears in the user's list -- Confirms details are fetched from OpenAI - -## Running the Tests - -### Prerequisites - -1. **Database**: Ensure PostgreSQL is running with the correct schema -2. **Environment Variables**: Set up your `.env` file with: - ``` - OPENAI_API_KEY=your_api_key_here - DATABASE_HOST=localhost - DATABASE_PORT=5432 - DATABASE_NAME=chat_api - DATABASE_USER=postgres - DATABASE_PASSWORD=your_password - ``` - -3. **Test Feature Flag**: Most tests require the `test` feature flag to enable the mock-login endpoint - -### Run All Tests +## Running tests ```bash -# Run all tests (including admin tests) cargo test --features test - -# Run all E2E tests (they make real OpenAI API calls) -cargo test --test e2e_api_tests --features test -- --ignored --nocapture - -# Run admin tests -cargo test --test admin_tests --features test - -# Run a specific test -cargo test --test e2e_api_tests test_conversation_workflow --features test -- --ignored --nocapture +cargo test --test responses_permissions_tests --features test +cargo test --test conversations_tests --features test +cargo test --test files_tests --features test ``` -**Important**: The `--features test` flag is required for most tests because it enables the `/v1/auth/mock-login` endpoint used for test authentication without requiring real OAuth providers. - -### Test Output - -The tests provide detailed output showing: -- Step-by-step progress -- API request/response status codes -- Conversation IDs and details -- Success/failure indicators (✓/✗) - -Example output: -``` -=== Test: Conversation Workflow === -1. Creating a conversation via OpenAI... - Status: 200 - ✓ Conversation created successfully - Conversation ID: conv_abc123... - -2. Adding first response to the conversation... - Status: 200 - ✓ First response created successfully - Response ID: resp_xyz789... - -... - -4. Listing conversations (should fetch details from OpenAI)... - Found 5 total conversations - ✓ Found our conversation in the list! - ID: conv_abc123... - Created: 2025-11-12T10:30:00Z - Updated: 2025-11-12T10:35:00Z - ✓ Conversation details fetched from OpenAI - -=== Test Complete === -✅ Test passed: Created conversation, added responses, and listed conversations with OpenAI details -``` - -## Notes - -- Tests are marked with `#[ignore]` because they make real API calls to OpenAI -- Each test is independent and can be run separately -- Tests use the actual database and OpenAI API (not mocks) -- The session token must be valid and exist in your database - -## Troubleshooting - -**Test fails with "Session not found"** -- Check that `SESSION_TOKEN` constant matches a valid session in your database -- Ensure the session hasn't expired - -**Test fails with "OpenAI API error"** -- Verify your `OPENAI_API_KEY` is valid -- Check your OpenAI account has available credits - -**Test fails with "Database error"** -- Ensure PostgreSQL is running -- Run migrations: `cargo run --bin migrate` or start the API server once -- Check database connection settings in `.env` - +Set the database configuration in `.env` before running integration tests. No +real Cloud API credentials are required for the retirement or local validation +tests. diff --git a/crates/api/tests/conversations_tests.rs b/crates/api/tests/conversations_tests.rs index 2675c070..eda71e4e 100644 --- a/crates/api/tests/conversations_tests.rs +++ b/crates/api/tests/conversations_tests.rs @@ -1,353 +1,79 @@ mod common; -use common::create_test_server; -use serde_json::json; - -const SESSION_TOKEN: &str = "sess_7770c53028d8400a9c69600d800ab86e"; - -#[tokio::test] -#[ignore] // This makes real OpenAI API calls - run with: cargo test -- --ignored --nocapture -async fn test_conversation_workflow() { - let server = create_test_server().await; - - println!("\n=== Test: Conversation Workflow ==="); - - // Step 1: Create a conversation using OpenAI's API - println!("1. Creating a conversation via OpenAI..."); - let create_conv_body = json!({ - "metadata": {"test": "e2e"} - }); - - let response = server - .post("/v1/conversations") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .json(&create_conv_body) - .await; - - let status = response.status_code(); - println!(" Status: {status}"); - - let conversation_id = if status.is_success() { - let body: serde_json::Value = response.json(); - println!(" ✓ Conversation created successfully"); - let conv_id = body - .get("id") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .expect("Conversation should have an ID"); - println!(" Conversation ID: {conv_id}"); - conv_id - } else { - let error_text = response.text(); - println!(" ✗ Failed: {error_text}"); - panic!("Failed to create conversation"); - }; - - // Step 2: Add first response to the conversation - println!("\n2. Adding first response to the conversation..."); - let request_body = json!({ - "conversation": conversation_id, - "model": "gpt-4o", - "input": [ - { - "type": "message", - "role": "user", - "content": "Say hello!" - } - ] - }); - - let response = server - .post("/v1/responses") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .json(&request_body) - .await; - - let status = response.status_code(); - println!(" Status: {status}"); - - if status.is_success() { - let body: serde_json::Value = response.json(); - println!(" ✓ First response created successfully"); - println!( - " Response ID: {}", - body.get("id").unwrap_or(&json!("N/A")) - ); - } else { - let error_text = response.text(); - println!(" ✗ Failed: {error_text}"); - panic!("Failed to create first response"); - }; - - // Step 3: Add second response to the same conversation - println!("\n3. Adding second response to the conversation..."); - let request_body = json!({ - "conversation": conversation_id, - "model": "gpt-4o", - "input": [ - { - "type": "message", - "role": "user", - "content": "Tell me a joke!" - } - ] - }); - - let response = server - .post("/v1/responses") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .json(&request_body) - .await; - - let status = response.status_code(); - println!(" Status: {status}"); - - if status.is_success() { - let body: serde_json::Value = response.json(); - println!(" ✓ Second response created successfully"); - println!( - " Response ID: {}", - body.get("id").unwrap_or(&json!("N/A")) - ); - } else { - let error_text = response.text(); - println!(" ✗ Failed: {error_text}"); - panic!("Failed to create second response"); - }; - - // Step 4: List conversations (fetches from OpenAI with details) - println!("\n4. Listing conversations (should fetch details from OpenAI)..."); - let response = server - .get("/v1/conversations") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .await; - - assert_eq!(response.status_code(), 200, "Should list conversations"); - - let conversations: Vec = response.json(); - println!(" Found {} total conversations", conversations.len()); - - // Find our conversation - let our_conv = conversations.iter().find(|c| { - c.get("id") - .and_then(|v| v.as_str()) - .map(|id| id == conversation_id) - .unwrap_or(false) - }); - - if let Some(conv) = our_conv { - println!(" ✓ Found our conversation in the list!"); - println!(" ID: {}", conv.get("id").unwrap_or(&json!("N/A"))); - - // Verify that we got OpenAI conversation details (not just ID) - if conv.get("created_at").is_some() { - println!(" Created: {}", conv.get("created_at").unwrap()); - } - if conv.get("updated_at").is_some() { - println!(" Updated: {}", conv.get("updated_at").unwrap()); - } - if conv.get("metadata").is_some() { - println!(" Metadata: {:?}", conv.get("metadata").unwrap()); - } - - println!(" ✓ Conversation details fetched from OpenAI"); - } else { - println!(" ✗ Our conversation not found in list"); - panic!("Conversation tracking is not working properly"); - } - - println!("\n=== Test Complete ==="); - println!("✅ Test passed: Created conversation, added responses, and listed conversations with OpenAI details\n"); +use api::routes::api::STATEFUL_API_RETIRED_MESSAGE; +use axum_test::TestResponse; +use common::{create_test_server, mock_login}; +use http::{HeaderName, HeaderValue, Method, StatusCode}; +use serde_json::Value; + +fn bearer(token: &str) -> (HeaderName, HeaderValue) { + ( + HeaderName::from_static("authorization"), + HeaderValue::from_str(&format!("Bearer {token}")).expect("test token header"), + ) } -#[tokio::test] -#[ignore] // This makes real OpenAI API calls - run with: cargo test -- --ignored --nocapture -async fn test_conversation_access_control() { - let server = create_test_server().await; - - println!("\n=== Test: Conversation Access Control ==="); - - // Step 1: Create a conversation - println!("1. Creating a conversation..."); - let create_conv_body = json!({ - "metadata": {"test": "access_control"} - }); - - let response = server - .post("/v1/conversations") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .json(&create_conv_body) - .await; - - assert!( - response.status_code().is_success(), - "Should create conversation" +fn assert_retired(response: TestResponse) { + assert_eq!(response.status_code(), StatusCode::GONE); + assert_eq!( + response + .headers() + .get(http::header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store") + ); + let body: Value = response.json(); + assert_eq!( + body.get("error").and_then(Value::as_str), + Some(STATEFUL_API_RETIRED_MESSAGE) ); - - let body: serde_json::Value = response.json(); - let conversation_id = body - .get("id") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .expect("Conversation should have an ID"); - println!(" ✓ Conversation created: {conversation_id}"); - - // Step 2: Try to access with the same user (should succeed) - println!("\n2. Accessing conversation as owner..."); - let response = server - .get(&format!("/v1/conversations/{}", conversation_id)) - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .await; - - // Note: This will go through the proxy handler since we don't have a specific GET route - // In a real implementation, you'd want to add a specific route that uses the service - println!(" Status: {}", response.status_code()); - - // For now, we're testing through the proxy which should work - if response.status_code().is_success() { - println!(" ✓ Successfully accessed conversation"); - } - - println!("\n=== Test Complete ==="); - println!("✅ Test passed: Access control working correctly\n"); -} - -#[tokio::test] -#[ignore] // This makes real OpenAI API calls - run with: cargo test -- --ignored --nocapture -async fn test_empty_conversation_list() { - let server = create_test_server().await; - - println!("\n=== Test: Empty Conversation List ==="); - - // List conversations (may or may not be empty depending on previous tests) - println!("1. Listing conversations..."); - let response = server - .get("/v1/conversations") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .await; - - assert_eq!(response.status_code(), 200, "Should list conversations"); - - let conversations: Vec = response.json(); - println!(" Found {} conversations", conversations.len()); - println!(" ✓ List endpoint works even with zero or many conversations"); - - println!("\n=== Test Complete ==="); - println!("✅ Test passed: Can list conversations successfully\n"); } #[tokio::test] -#[ignore] // This makes real OpenAI API calls - run with: cargo test -- --ignored --nocapture -async fn test_conversation_tracking_on_response_creation() { +async fn retired_conversation_routes_return_the_migration_response() { let server = create_test_server().await; + let token = mock_login(&server, "retired-conversations@example.com").await; - println!("\n=== Test: Conversation Tracking on Response Creation ==="); - - // Step 1: Create a conversation - println!("1. Creating a conversation..."); - let create_conv_body = json!({ - "metadata": {"test": "response_tracking"} - }); - - let response = server - .post("/v1/conversations") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .json(&create_conv_body) - .await; - - assert!( - response.status_code().is_success(), - "Should create conversation" - ); - - let body: serde_json::Value = response.json(); - let conversation_id = body - .get("id") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .expect("Conversation should have an ID"); - println!(" ✓ Conversation created: {conversation_id}"); - - // Step 2: Add response (this should trigger conversation tracking) - println!("\n2. Adding response to track conversation..."); - let request_body = json!({ - "conversation": conversation_id, - "model": "gpt-4o", - "input": [ - { - "type": "message", - "role": "user", - "content": "Test message" - } - ] - }); + // Publicly shared conversation reads remain optional-auth, but never expose + // the legacy resource now that the API is retired. + assert_retired(server.get("/v1/conversations/conv_legacy").await); + assert_retired(server.get("/v1/conversations/conv_legacy/items").await); - let response = server - .post("/v1/responses") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .json(&request_body) - .await; - - assert!( - response.status_code().is_success(), - "Should create response" - ); - println!(" ✓ Response created"); - - // Step 3: Verify conversation is now tracked in our database - println!("\n3. Verifying conversation is tracked..."); - let response = server - .get("/v1/conversations") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .await; - - assert_eq!(response.status_code(), 200); - - let conversations: Vec = response.json(); - let found = conversations.iter().any(|c| { - c.get("id") - .and_then(|v| v.as_str()) - .map(|id| id == conversation_id) - .unwrap_or(false) - }); - - assert!( - found, - "Conversation should be tracked after response creation" + // Mutating conversation routes retain the old session-auth boundary. + assert_eq!( + server.post("/v1/conversations").await.status_code(), + StatusCode::UNAUTHORIZED ); - println!(" ✓ Conversation is tracked in database"); - println!(" ✓ Details fetched from OpenAI successfully"); - println!("\n=== Test Complete ==="); - println!("✅ Test passed: Conversation tracking on response creation works correctly\n"); + let auth = bearer(&token); + for (method, path) in [ + // Every method that was previously supported by the session-auth API. + (Method::POST, "/v1/conversations"), + (Method::GET, "/v1/conversations"), + (Method::POST, "/v1/conversations/conv_legacy"), + (Method::DELETE, "/v1/conversations/conv_legacy"), + (Method::POST, "/v1/conversations/conv_legacy/items"), + (Method::POST, "/v1/conversations/conv_legacy/shares"), + (Method::GET, "/v1/conversations/conv_legacy/shares"), + ( + Method::DELETE, + "/v1/conversations/conv_legacy/shares/share_legacy", + ), + (Method::POST, "/v1/conversations/conv_legacy/pin"), + (Method::DELETE, "/v1/conversations/conv_legacy/pin"), + (Method::POST, "/v1/conversations/conv_legacy/archive"), + (Method::DELETE, "/v1/conversations/conv_legacy/archive"), + (Method::POST, "/v1/conversations/conv_legacy/clone"), + // Exact known routes and unknown descendants also use the migration + // response for methods that were never part of the old contract. + (Method::PATCH, "/v1/conversations/conv_legacy"), + (Method::PATCH, "/v1/conversations/conv_legacy/unknown-child"), + ] { + assert_retired( + server + .method(method, path) + .add_header(auth.0.clone(), auth.1.clone()) + .await, + ); + } } diff --git a/crates/api/tests/files_tests.rs b/crates/api/tests/files_tests.rs index 035b6a8e..76584f97 100644 --- a/crates/api/tests/files_tests.rs +++ b/crates/api/tests/files_tests.rs @@ -1,474 +1,71 @@ mod common; -use api::FileListResponse; -use bytes::Bytes; -use common::create_test_server; -use serde_json::json; - -const SESSION_TOKEN: &str = "sess_7770c53028d8400a9c69600d800ab86e"; - -/// Helper function to create multipart/form-data body for file upload -fn create_multipart_body( - file_content: &[u8], - filename: &str, - purpose: &str, - content_type: Option<&str>, -) -> Vec { - let boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW"; - let mut body = Vec::new(); - - // Add file field - body.extend_from_slice( - format!( - "--{}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n", - boundary, filename - ) - .as_bytes(), - ); - if let Some(ct) = content_type { - body.extend_from_slice(format!("Content-Type: {}\r\n", ct).as_bytes()); - } - body.extend_from_slice(b"\r\n"); - body.extend_from_slice(file_content); - body.extend_from_slice(b"\r\n"); - - // Add purpose field - body.extend_from_slice( - format!( - "--{}\r\nContent-Disposition: form-data; name=\"purpose\"\r\n\r\n{}\r\n", - boundary, purpose - ) - .as_bytes(), - ); - - // Close boundary - body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes()); - - body +use api::routes::api::STATEFUL_API_RETIRED_MESSAGE; +use axum_test::TestResponse; +use common::{create_test_server, mock_login}; +use http::{HeaderName, HeaderValue, Method, StatusCode}; +use serde_json::Value; + +fn bearer(token: &str) -> (HeaderName, HeaderValue) { + ( + HeaderName::from_static("authorization"), + HeaderValue::from_str(&format!("Bearer {token}")).expect("test token header"), + ) } -#[tokio::test] -#[ignore] // This makes real OpenAI API calls - run with: cargo test -- --ignored --nocapture -async fn test_file_upload_workflow() { - let server = create_test_server().await; - - println!("\n=== Test: File Upload Workflow ==="); - - // Step 1: Upload a file - println!("1. Uploading a file..."); - let file_content = b"Hello, this is a test file content!"; - let filename = "test.txt"; - let purpose = "assistants"; - let content_type = "text/plain"; - - let multipart_body = create_multipart_body(file_content, filename, purpose, Some(content_type)); - - let response = server - .post("/v1/files") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .add_header( - http::HeaderName::from_static("content-type"), - http::HeaderValue::from_static( - "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW", - ), - ) - .bytes(Bytes::from(multipart_body)) - .await; - - let status = response.status_code(); - println!(" Status: {status}"); - - let file_id = if status.is_success() { - let body: serde_json::Value = response.json(); - println!(" ✓ File uploaded successfully"); - let id = body - .get("id") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .expect("File should have an ID"); - println!(" File ID: {id}"); - id - } else { - let error_text = response.text(); - println!(" ✗ Failed: {error_text}"); - panic!("Failed to upload file"); - }; - - // Step 2: Get file details - println!("\n2. Getting file details..."); - let response = server - .get(&format!("/v1/files/{}", file_id)) - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .await; - - assert_eq!(response.status_code(), 200, "Should get file details"); - let file: serde_json::Value = response.json(); - println!(" ✓ File details retrieved successfully"); - println!(" File: {}", serde_json::to_string_pretty(&file).unwrap()); - - // Step 3: Get file content - println!("\n3. Getting file content..."); - let response = server - .get(&format!("/v1/files/{}/content", file_id)) - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .await; - - assert_eq!(response.status_code(), 200, "Should get file content"); - let content = response.text(); - println!(" ✓ File content retrieved successfully"); - println!(" Content length: {} bytes", content.len()); - - // Step 4: Delete file - println!("\n4. Deleting file..."); - let response = server - .delete(&format!("/v1/files/{}", file_id)) - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .await; - - assert_eq!(response.status_code(), 200, "Should delete file"); - let delete_response: serde_json::Value = response.json(); - println!(" ✓ File deleted successfully"); +fn assert_retired(response: TestResponse) { + assert_eq!(response.status_code(), StatusCode::GONE); assert_eq!( - delete_response.get("deleted"), - Some(&json!(true)), - "Delete response should indicate success" + response + .headers() + .get(http::header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store") + ); + let body: Value = response.json(); + assert_eq!( + body.get("error").and_then(Value::as_str), + Some(STATEFUL_API_RETIRED_MESSAGE) ); - - println!("\n=== Test Complete ==="); - println!("✅ Test passed: File upload, list, get, get content, and delete workflow\n"); -} - -#[tokio::test] -#[ignore] // This makes real OpenAI API calls - run with: cargo test -- --ignored --nocapture -async fn test_file_not_found() { - let server = create_test_server().await; - - println!("\n=== Test: File Not Found ==="); - - // Try to get a non-existent file - println!("1. Getting non-existent file..."); - let fake_id = "file-00000000-0000-0000-0000-000000000000"; - let response = server - .get(&format!("/v1/files/{}", fake_id)) - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .await; - - println!(" Status: {}", response.status_code()); - // Should return 404, but might return different status if proxied - - // Try to delete a non-existent file - println!("\n2. Deleting non-existent file..."); - let response = server - .delete(&format!("/v1/files/{}", fake_id)) - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .await; - - println!(" Status: {}", response.status_code()); - // Should return 404, but might return different status if proxied - - println!("\n=== Test Complete ==="); } #[tokio::test] -#[ignore] // This makes real OpenAI API calls - run with: cargo test -- --ignored --nocapture -async fn test_file_list_pagination() { +async fn retired_file_and_sharing_routes_return_the_migration_response() { let server = create_test_server().await; + let token = mock_login(&server, "retired-files@example.com").await; + let auth = bearer(&token); - println!("\n=== Test: File List Pagination ==="); - - // Step 1: Upload multiple files to test pagination - println!("1. Uploading multiple files..."); - let mut uploaded_file_ids = Vec::new(); - - for i in 0..5 { - let file_content = format!("Test file content {}", i).into_bytes(); - let filename = format!("test_{}.txt", i); - let purpose = "assistants"; - let content_type = "text/plain"; - - let multipart_body = - create_multipart_body(&file_content, &filename, purpose, Some(content_type)); - - let response = server - .post("/v1/files") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .add_header( - http::HeaderName::from_static("content-type"), - http::HeaderValue::from_static( - "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW", - ), - ) - .bytes(Bytes::from(multipart_body)) - .await; - - assert!(response.status_code().is_success()); - - let body: serde_json::Value = response.json(); - if let Some(id) = body.get("id").and_then(|v| v.as_str()) { - uploaded_file_ids.push(id.to_string()); - println!(" ✓ Uploaded file {}: {}", i, id); - // Small delay to ensure different created_at timestamps - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - } - } - + // Files and sharing routes remain session-auth only, even though they no + // longer call their stateful services after authentication succeeds. assert_eq!( - uploaded_file_ids.len(), - 5, - "Ensure files uploaded for pagination test" - ); - println!(" Total files uploaded: {}", uploaded_file_ids.len()); - - // Step 2: Test basic pagination with limit - println!("\n2. Testing pagination with limit parameter..."); - let response = server - .get("/v1/files?limit=2") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .await; - - assert_eq!(response.status_code(), 200, "Should list files with limit"); - let list_response: FileListResponse = response.json(); - - assert_eq!( - list_response.object, "list", - "Response object should be 'list'" - ); - assert_eq!( - list_response.data.len(), - 2, - "Should return exactly 2 files when limit=2" - ); - assert!( - list_response.first_id.is_some(), - "Should have first_id when files are returned" - ); - assert!( - list_response.last_id.is_some(), - "Should have last_id when files are returned" - ); - println!(" ✓ Limit pagination works correctly"); - println!(" Files returned: {}", list_response.data.len()); - println!(" Has more: {}", list_response.has_more); - println!(" First ID: {:?}", list_response.first_id); - println!(" Last ID: {:?}", list_response.last_id); - - // Step 3: Test cursor-based pagination with 'after' parameter - if let Some(last_id) = &list_response.last_id { - println!("\n3. Testing cursor-based pagination with 'after' parameter..."); - let response = server - .get(&format!("/v1/files?limit=2&after={}", last_id)) - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .await; - - assert_eq!( - response.status_code(), - 200, - "Should list files with after cursor" + server.get("/v1/files").await.status_code(), + StatusCode::UNAUTHORIZED + ); + + for (method, path) in [ + // Every method that was previously supported by the session-auth API. + (Method::POST, "/v1/files"), + (Method::GET, "/v1/files"), + (Method::GET, "/v1/files/file_legacy"), + (Method::DELETE, "/v1/files/file_legacy"), + (Method::GET, "/v1/files/file_legacy/content"), + (Method::POST, "/v1/share-groups"), + (Method::GET, "/v1/share-groups"), + (Method::PATCH, "/v1/share-groups/group_legacy"), + (Method::DELETE, "/v1/share-groups/group_legacy"), + (Method::GET, "/v1/shared-with-me"), + // Exact known routes and unknown descendants also use the migration + // response for methods that were never part of the old contract. + (Method::PATCH, "/v1/files/file_legacy"), + (Method::PATCH, "/v1/files/file_legacy/unknown-child"), + (Method::PATCH, "/v1/share-groups/group_legacy/unknown-child"), + (Method::POST, "/v1/shared-with-me"), + ] { + assert_retired( + server + .method(method, path) + .add_header(auth.0.clone(), auth.1.clone()) + .await, ); - let next_page: FileListResponse = response.json(); - - assert_eq!(next_page.object, "list", "Response object should be 'list'"); - println!(" ✓ Cursor pagination works correctly"); - println!(" Files in next page: {}", next_page.data.len()); - println!(" Has more: {}", next_page.has_more); - - // Verify that the files in the next page are different from the first page - let first_page_ids: Vec = list_response - .data - .iter() - .map(|f| f.file.id.clone()) - .collect(); - let next_page_ids: Vec = next_page.data.iter().map(|f| f.file.id.clone()).collect(); - - for id in &next_page_ids { - assert!( - !first_page_ids.contains(id), - "Next page should not contain files from first page" - ); - } - println!(" ✓ Verified no duplicate files between pages"); } - - // Step 4: Test ascending order - println!("\n4. Testing ascending order..."); - let response = server - .get("/v1/files?limit=3&order=asc") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .await; - - assert_eq!( - response.status_code(), - 200, - "Should list files in ascending order" - ); - let asc_response: FileListResponse = response.json(); - - assert_eq!( - asc_response.object, "list", - "Response object should be 'list'" - ); - println!(" ✓ Ascending order works correctly"); - println!(" Files returned: {}", asc_response.data.len()); - - // Step 5: Test descending order (default) - println!("\n5. Testing descending order (default)..."); - let response = server - .get("/v1/files?limit=3&order=desc") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .await; - - assert_eq!( - response.status_code(), - 200, - "Should list files in descending order" - ); - let desc_response: FileListResponse = response.json(); - - assert_eq!( - desc_response.object, "list", - "Response object should be 'list'" - ); - println!(" ✓ Descending order works correctly"); - println!(" Files returned: {}", desc_response.data.len()); - - // Step 6: Test invalid order parameter - println!("\n6. Testing invalid order parameter..."); - let response = server - .get("/v1/files?order=invalid") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .await; - - assert_eq!( - response.status_code(), - 400, - "Should return 400 for invalid order parameter" - ); - println!(" ✓ Invalid order parameter correctly rejected"); - - // Step 7: Test limit boundaries - println!("\n7. Testing limit boundaries..."); - - // Test limit=1 (minimum) - let response = server - .get("/v1/files?limit=1") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .await; - assert_eq!(response.status_code(), 200, "Should accept limit=1"); - let min_limit_response: FileListResponse = response.json(); - assert_eq!(min_limit_response.data.len(), 1, "Should return 1 file"); - println!(" ✓ Minimum limit (1) works correctly"); - - // Test limit=10000 (maximum) - let response = server - .get("/v1/files?limit=10000") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .await; - assert_eq!(response.status_code(), 200, "Should accept limit=10000"); - println!(" ✓ Maximum limit (10000) works correctly"); - - // Test limit beyond maximum (should return 400) - let response = server - .get("/v1/files?limit=20000") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .await; - assert_eq!( - response.status_code(), - 400, - "Should reject limit beyond maximum with 400" - ); - println!(" ✓ Limit upper bound validation works correctly"); - - // Step 8: Test has_more flag - println!("\n8. Testing has_more flag..."); - let response = server - .get("/v1/files?limit=10000") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .await; - assert_eq!(response.status_code(), 200); - let all_files_response: FileListResponse = response.json(); - - // If we have more than 10000 files, has_more should be true - // Otherwise, it should be false - println!(" Total files: {}", all_files_response.data.len()); - println!(" Has more: {}", all_files_response.has_more); - println!(" ✓ has_more flag works correctly"); - - // Step 9: Test empty result - println!("\n9. Testing edge cases..."); - - // Test with a non-existent file as cursor - let response = server - .get("/v1/files?after=file-nonexistent-id-12345&limit=10") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {SESSION_TOKEN}")).unwrap(), - ) - .await; - assert_eq!( - response.status_code(), - 200, - "Should handle non-existent cursor gracefully" - ); - let empty_response: FileListResponse = response.json(); - assert_eq!( - empty_response.data.len(), - 0, - "Should return empty list for non-existent cursor" - ); - assert!( - !empty_response.has_more, - "Should have has_more=false for empty result" - ); - println!(" ✓ Non-existent cursor handled correctly"); - - println!("\n=== Test Complete ==="); - println!("✅ All pagination tests passed\n"); } diff --git a/crates/api/tests/response_author_tests.rs b/crates/api/tests/response_author_tests.rs deleted file mode 100644 index 00ce62d9..00000000 --- a/crates/api/tests/response_author_tests.rs +++ /dev/null @@ -1,490 +0,0 @@ -mod common; - -use common::{create_test_server, mock_login}; -use serde_json::json; - -/// Test 1: Author metadata stored when creating response via /v1/responses -#[tokio::test] -#[ignore] // This makes real OpenAI API calls - run with: cargo test --test response_author_tests --features test -- --ignored --nocapture -async fn test_response_author_stored_on_create() { - let server = create_test_server().await; - let token = mock_login(&server, "author@test.com").await; - - println!("\n=== Test: Response Author Stored on Create ==="); - - // Step 1: Create a conversation - println!("1. Creating a conversation..."); - let create_conv_body = json!({ - "metadata": {"test": "author_tracking"} - }); - - let response = server - .post("/v1/conversations") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), - ) - .json(&create_conv_body) - .await; - - assert!( - response.status_code().is_success(), - "Should create conversation" - ); - - let body: serde_json::Value = response.json(); - let conversation_id = body - .get("id") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .expect("Conversation should have an ID"); - println!(" ✓ Conversation created: {conversation_id}"); - - // Step 2: Create a response - println!("\n2. Creating a response..."); - let request_body = json!({ - "conversation": conversation_id, - "model": "gpt-4o", - "input": [ - { - "type": "message", - "role": "user", - "content": "Hello, world!" - } - ] - }); - - let response = server - .post("/v1/responses") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), - ) - .json(&request_body) - .await; - - assert!( - response.status_code().is_success(), - "Should create response" - ); - println!(" ✓ Response created"); - - // Step 3: List conversation items and verify author metadata - println!("\n3. Listing conversation items to verify author metadata..."); - let response = server - .get(&format!("/v1/conversations/{conversation_id}/items")) - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), - ) - .await; - - assert!(response.status_code().is_success(), "Should list items"); - - let items: serde_json::Value = response.json(); - let data = items.get("data").and_then(|d| d.as_array()); - - if let Some(data_arr) = data { - println!(" Found {} items", data_arr.len()); - - // Look for an item with author metadata - let has_author = data_arr.iter().any(|item| { - item.get("metadata") - .and_then(|m| m.get("author_id")) - .is_some() - }); - - if has_author { - println!(" ✓ Author metadata found in items"); - } else { - println!( - " Note: Author metadata may be in response_authors table but not yet injected" - ); - } - } - - println!("\n=== Test Complete ==="); - println!("✅ Test passed: Response author tracking on create\n"); -} - -/// Test 2: Author metadata injected when listing conversation items -#[tokio::test] -#[ignore] // This makes real OpenAI API calls - run with: cargo test --test response_author_tests --features test -- --ignored --nocapture -async fn test_author_metadata_injected_on_list() { - let server = create_test_server().await; - let token = mock_login(&server, "user@test.com").await; - - println!("\n=== Test: Author Metadata Injected on List ==="); - - // Step 1: Create a conversation - println!("1. Creating a conversation..."); - let create_conv_body = json!({ - "metadata": {"test": "metadata_injection"} - }); - - let response = server - .post("/v1/conversations") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), - ) - .json(&create_conv_body) - .await; - - assert!( - response.status_code().is_success(), - "Should create conversation" - ); - - let body: serde_json::Value = response.json(); - let conversation_id = body - .get("id") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .expect("Conversation should have an ID"); - println!(" ✓ Conversation created: {conversation_id}"); - - // Step 2: Add a response - println!("\n2. Adding a response..."); - let request_body = json!({ - "conversation": conversation_id, - "model": "gpt-4o", - "input": [ - { - "type": "message", - "role": "user", - "content": "Test message for metadata injection" - } - ] - }); - - let response = server - .post("/v1/responses") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), - ) - .json(&request_body) - .await; - - assert!( - response.status_code().is_success(), - "Should create response" - ); - println!(" ✓ Response created"); - - // Step 3: List items and check for author metadata - println!("\n3. Listing items and verifying author metadata..."); - let response = server - .get(&format!("/v1/conversations/{conversation_id}/items")) - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), - ) - .await; - - assert_eq!(response.status_code(), 200, "Should list items"); - - let items: serde_json::Value = response.json(); - let data = items.get("data").and_then(|d| d.as_array()); - - if let Some(data_arr) = data { - for item in data_arr { - if let Some(response_id) = item.get("response_id").and_then(|v| v.as_str()) { - let metadata = item.get("metadata"); - let author_id = metadata.and_then(|m| m.get("author_id")); - let author_name = metadata.and_then(|m| m.get("author_name")); - - println!(" Item response_id: {}", response_id); - println!(" Author ID: {:?}", author_id); - println!(" Author Name: {:?}", author_name); - } - } - } - - println!("\n=== Test Complete ==="); - println!("✅ Test passed: Author metadata injection on list\n"); -} - -/// Test 3: Shared conversation shows correct author for each user's messages -#[tokio::test] -#[ignore] // This makes real OpenAI API calls - run with: cargo test --test response_author_tests --features test -- --ignored --nocapture -async fn test_shared_conversation_author_attribution() { - let server = create_test_server().await; - - println!("\n=== Test: Shared Conversation Author Attribution ==="); - - // Create owner and shared user - let owner_token = mock_login(&server, "owner@test.com").await; - let shared_token = mock_login(&server, "shared@test.com").await; - - // Step 1: Owner creates conversation - println!("\n1. Owner creating a conversation..."); - let create_conv_body = json!({ - "metadata": {"test": "shared_author"} - }); - - let response = server - .post("/v1/conversations") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {owner_token}")).unwrap(), - ) - .json(&create_conv_body) - .await; - - assert!( - response.status_code().is_success(), - "Should create conversation" - ); - - let body: serde_json::Value = response.json(); - let conversation_id = body - .get("id") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .expect("Conversation should have an ID"); - println!(" ✓ Owner created conversation: {conversation_id}"); - - // Step 2: Owner adds first message - println!("\n2. Owner sending first message..."); - let request_body = json!({ - "conversation": conversation_id, - "model": "gpt-4o", - "input": [ - { - "type": "message", - "role": "user", - "content": "Hello from owner!" - } - ] - }); - - let response = server - .post("/v1/responses") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {owner_token}")).unwrap(), - ) - .json(&request_body) - .await; - - assert!( - response.status_code().is_success(), - "Owner should create response" - ); - println!(" ✓ Owner's message created"); - - // Step 3: Owner shares with shared user - println!("\n3. Owner sharing conversation with shared user..."); - let share_body = json!({ - "permission": "write", - "target": { - "mode": "direct", - "recipients": [ - { - "kind": "email", - "value": "shared@test.com" - } - ] - } - }); - - let response = server - .post(&format!("/v1/conversations/{conversation_id}/shares")) - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {owner_token}")).unwrap(), - ) - .json(&share_body) - .await; - - assert!( - response.status_code().is_success(), - "Should share conversation" - ); - println!(" ✓ Conversation shared"); - - // Step 4: Shared user sends a message - println!("\n4. Shared user sending message..."); - let request_body = json!({ - "conversation": conversation_id, - "model": "gpt-4o", - "input": [ - { - "type": "message", - "role": "user", - "content": "Hello from shared user!" - } - ] - }); - - let response = server - .post("/v1/responses") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {shared_token}")).unwrap(), - ) - .json(&request_body) - .await; - - assert!( - response.status_code().is_success(), - "Shared user should create response" - ); - println!(" ✓ Shared user's message created"); - - // Step 5: Owner lists items - should see both authors correctly - println!("\n5. Owner listing items to verify author attribution..."); - let response = server - .get(&format!("/v1/conversations/{conversation_id}/items")) - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {owner_token}")).unwrap(), - ) - .await; - - assert_eq!(response.status_code(), 200, "Should list items"); - - let items: serde_json::Value = response.json(); - let data = items.get("data").and_then(|d| d.as_array()); - - if let Some(data_arr) = data { - println!(" Found {} items", data_arr.len()); - - let mut owner_messages = 0; - let mut shared_messages = 0; - - for item in data_arr { - if let Some(metadata) = item.get("metadata") { - let author_name = metadata - .get("author_name") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - - println!(" Item author: {}", author_name); - - if author_name.contains("owner") { - owner_messages += 1; - } else if author_name.contains("shared") { - shared_messages += 1; - } - } - } - - println!("\n Owner messages: {}", owner_messages); - println!(" Shared user messages: {}", shared_messages); - - assert!(owner_messages > 0, "Should have owner's messages"); - assert!(shared_messages > 0, "Should have shared user's messages"); - println!(" ✓ Both users' messages correctly attributed"); - } - - println!("\n=== Test Complete ==="); - println!("✅ Test passed: Shared conversation author attribution\n"); -} - -/// Test 4: Author metadata in create_conversation_items endpoint -#[tokio::test] -#[ignore] // This makes real OpenAI API calls - run with: cargo test --test response_author_tests --features test -- --ignored --nocapture -async fn test_create_items_stores_author() { - let server = create_test_server().await; - let token = mock_login(&server, "items_author@test.com").await; - - println!("\n=== Test: Create Conversation Items Stores Author ==="); - - // Step 1: Create a conversation - println!("1. Creating a conversation..."); - let create_conv_body = json!({ - "metadata": {"test": "items_author"} - }); - - let response = server - .post("/v1/conversations") - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), - ) - .json(&create_conv_body) - .await; - - assert!( - response.status_code().is_success(), - "Should create conversation" - ); - - let body: serde_json::Value = response.json(); - let conversation_id = body - .get("id") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .expect("Conversation should have an ID"); - println!(" ✓ Conversation created: {conversation_id}"); - - // Step 2: Create item using POST /v1/conversations/{id}/items - println!("\n2. Creating item via conversation items endpoint..."); - let item_body = json!({ - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello via items endpoint!" - } - ] - }); - - let response = server - .post(&format!("/v1/conversations/{conversation_id}/items")) - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), - ) - .json(&item_body) - .await; - - // Note: This might fail if OpenAI doesn't support this exact format - // but we're testing that author metadata is injected into the request - println!(" Status: {}", response.status_code()); - - if response.status_code().is_success() { - println!(" ✓ Item created"); - - // Check if the response contains response_id - let body: serde_json::Value = response.json(); - if let Some(response_id) = body.get("response_id").and_then(|v| v.as_str()) { - println!(" Response ID: {}", response_id); - } - } - - // Step 3: List items to verify author metadata - println!("\n3. Listing items to verify author metadata..."); - let response = server - .get(&format!("/v1/conversations/{conversation_id}/items")) - .add_header( - http::HeaderName::from_static("authorization"), - http::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), - ) - .await; - - assert!(response.status_code().is_success(), "Should list items"); - - let items: serde_json::Value = response.json(); - println!(" Items response received"); - - if let Some(data) = items.get("data").and_then(|d| d.as_array()) { - for item in data { - let metadata = item.get("metadata"); - let author_name = metadata - .and_then(|m| m.get("author_name")) - .and_then(|v| v.as_str()); - - if let Some(name) = author_name { - println!(" Found author: {}", name); - } - } - } - - println!("\n=== Test Complete ==="); - println!("✅ Test passed: Create items stores author\n"); -} diff --git a/crates/api/tests/responses_permissions_tests.rs b/crates/api/tests/responses_permissions_tests.rs index 77d12cb7..0261162f 100644 --- a/crates/api/tests/responses_permissions_tests.rs +++ b/crates/api/tests/responses_permissions_tests.rs @@ -4,12 +4,12 @@ use common::{create_test_server, mock_login}; use serde_json::json; #[tokio::test] -async fn responses_requires_write_access_when_conversation_is_provided() { +async fn responses_rejects_conversation_state_locally() { let server = create_test_server().await; let token = mock_login(&server, "no-write@test.com").await; - // Use a conversation id the user does not own and is not shared with. - // The handler should reject with 403 BEFORE attempting any OpenAI call. + // The stateless proxy must reject a conversation reference before it can + // invoke Cloud API or the legacy conversation-access service. let request_body = json!({ "conversation": "conv_no_write_access", "model": "gpt-4o", @@ -33,7 +33,19 @@ async fn responses_requires_write_access_when_conversation_is_provided() { assert_eq!( response.status_code(), - 403, - "Should require write access for existing conversations" + 400, + "Stateful conversation requests must fail locally" + ); + assert_eq!( + response + .headers() + .get(http::header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store") + ); + let body: serde_json::Value = response.json(); + assert_eq!( + body.get("error").and_then(|value| value.as_str()), + Some("The stateless Responses API does not support conversation.") ); } diff --git a/crates/api/tests/responses_stateless_tests.rs b/crates/api/tests/responses_stateless_tests.rs new file mode 100644 index 00000000..88e92728 --- /dev/null +++ b/crates/api/tests/responses_stateless_tests.rs @@ -0,0 +1,108 @@ +mod common; + +use common::{ + create_test_server_and_db, insert_test_subscription, mock_login, set_subscription_plans, + TestServerConfig, +}; +use http::{HeaderName, HeaderValue}; +use serde_json::json; +use uuid::Uuid; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn bearer(token: &str) -> (HeaderName, HeaderValue) { + ( + HeaderName::from_static("authorization"), + HeaderValue::from_str(&format!("Bearer {token}")).expect("test token header"), + ) +} + +#[tokio::test] +async fn responses_forwards_store_false_without_author_metadata() { + let mock_upstream = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/responses")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "resp_stateless_test", + "object": "response", + "model": "gpt-test", + "output": [], + "usage": { + "input_tokens": 1, + "output_tokens": 1, + "total_tokens": 2 + } + }))) + .expect(1) + .mount(&mock_upstream) + .await; + + let (server, db) = create_test_server_and_db(TestServerConfig { + proxy_base_url: Some(mock_upstream.uri()), + ..Default::default() + }) + .await; + + set_subscription_plans( + &server, + json!({ + "basic": { + "providers": { "stripe": { "price_id": "price_test_basic" } }, + "monthly_credits": { "max": 1_000_000_000 } + } + }), + ) + .await; + + let email = format!("stateless-responses-{}@example.com", Uuid::new_v4()); + let token = mock_login(&server, &email).await; + insert_test_subscription(&server, &db, &email, false).await; + + let auth = bearer(&token); + let response = server + .post("/v1/responses") + .add_header(auth.0, auth.1) + .json(&json!({ + "model": "gpt-test", + "metadata": { "client_key": "client_value" }, + "input": "hello" + })) + .await; + + assert_eq!(response.status_code(), 200); + assert_eq!( + response + .headers() + .get(http::header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store") + ); + + let requests = mock_upstream + .received_requests() + .await + .expect("mock upstream should record the request"); + assert_eq!(requests.len(), 1); + let forwarded: serde_json::Value = + serde_json::from_slice(&requests[0].body).expect("forwarded body should be JSON"); + + assert_eq!(forwarded.get("store"), Some(&json!(false))); + assert_eq!( + forwarded.get("metadata"), + Some(&json!({ "client_key": "client_value" })) + ); + assert!( + forwarded + .get("metadata") + .and_then(|metadata| metadata.get("author_id")) + .is_none(), + "Chat API must not inject stateful author metadata" + ); + assert!( + forwarded + .get("metadata") + .and_then(|metadata| metadata.get("author_name")) + .is_none(), + "Chat API must not inject stateful author metadata" + ); +} From 34e902450278d5f1d2ace41797e10b49e085a802 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:23:58 +0800 Subject: [PATCH 2/5] fix(api): harden retired stateful boundaries --- crates/api/src/routes/api.rs | 433 ++++++++++++------ crates/api/src/routes/mod.rs | 9 +- crates/api/tests/conversations_tests.rs | 10 + crates/api/tests/files_tests.rs | 10 + crates/api/tests/responses_stateless_tests.rs | 107 ++++- 5 files changed, 433 insertions(+), 136 deletions(-) diff --git a/crates/api/src/routes/api.rs b/crates/api/src/routes/api.rs index b77590a2..8d3a7d0a 100644 --- a/crates/api/src/routes/api.rs +++ b/crates/api/src/routes/api.rs @@ -1,8 +1,3 @@ -// #379 changes the public surface first. The dormant stateful handlers below -// are intentionally retained for the #381 cleanup follow-up, so suppress their -// temporary dead-code warnings without changing the rollback boundary. -#![allow(dead_code)] - use crate::consts::{ LIST_FILES_LIMIT_MAX, MAX_DECOMPRESSED_RESPONSE_BODY_SIZE, MAX_REQUEST_BODY_SIZE, MAX_RESPONSE_BODY_SIZE, @@ -24,7 +19,10 @@ use bytes::Bytes; use chrono::{Duration, Utc}; use flate2::read::{DeflateDecoder, GzDecoder, ZlibDecoder}; use futures::stream; -use http::{header::CONTENT_LENGTH, HeaderValue}; +use http::{ + header::{CONTENT_ENCODING, CONTENT_LENGTH}, + HeaderValue, +}; use multer::Multipart; use near_api::{Account, AccountId, NetworkConfig}; use serde::{Deserialize, Serialize}; @@ -102,7 +100,10 @@ use openapi_tags::*; /// These paths historically supported optional authentication for public shares. /// Keep that boundary while returning the same migration response as the /// authenticated stateful routes. -pub fn create_optional_auth_router() -> Router { +pub fn create_optional_auth_router() -> Router +where + S: Clone + Send + Sync + 'static, +{ Router::new() .route( "/v1/conversations/{conversation_id}", @@ -114,6 +115,115 @@ pub fn create_optional_auth_router() -> Router { ) } +/// Create the retired stateful surfaces without their authentication layer. +/// +/// Conversation GET routes intentionally omit their handlers here: they merge +/// with `create_optional_auth_router` at the application boundary so public +/// shared reads retain their historical optional-auth behavior. Every other +/// request is protected by the caller's session-auth layer. +fn create_retired_stateful_router() -> Router +where + S: Clone + Send + Sync + 'static, +{ + // The nested router's fallback is stored separately by Axum, which lets it + // catch unknown descendants without conflicting with `{conversation_id}`. + let conversations_router = Router::new() + .route( + "/", + post(retired_stateful_api) + .get(retired_stateful_api) + .fallback(retired_stateful_api), + ) + .route( + "/{conversation_id}", + post(retired_stateful_api) + .delete(retired_stateful_api) + .fallback(retired_stateful_api), + ) + .route( + "/{conversation_id}/shares", + post(retired_stateful_api) + .get(retired_stateful_api) + .fallback(retired_stateful_api), + ) + .route( + "/{conversation_id}/shares/{share_id}", + delete(retired_stateful_api).fallback(retired_stateful_api), + ) + .route( + "/{conversation_id}/items", + post(retired_stateful_api).fallback(retired_stateful_api), + ) + .route( + "/{conversation_id}/pin", + post(retired_stateful_api) + .delete(retired_stateful_api) + .fallback(retired_stateful_api), + ) + .route( + "/{conversation_id}/archive", + post(retired_stateful_api) + .delete(retired_stateful_api) + .fallback(retired_stateful_api), + ) + .route( + "/{conversation_id}/clone", + post(retired_stateful_api).fallback(retired_stateful_api), + ) + .fallback(retired_stateful_api); + + let share_groups_router = Router::new() + .route( + "/", + post(retired_stateful_api) + .get(retired_stateful_api) + .fallback(retired_stateful_api), + ) + .route( + "/{group_id}", + patch(retired_stateful_api) + .delete(retired_stateful_api) + .fallback(retired_stateful_api), + ) + .fallback(retired_stateful_api); + + let files_router = Router::new() + .route( + "/", + post(retired_stateful_api) + .get(retired_stateful_api) + .fallback(retired_stateful_api), + ) + .route( + "/{file_id}", + get(retired_stateful_api) + .delete(retired_stateful_api) + .fallback(retired_stateful_api), + ) + .route( + "/{file_id}/content", + get(retired_stateful_api).fallback(retired_stateful_api), + ) + .fallback(retired_stateful_api); + + let shared_with_me_router = Router::new() + .route( + "/", + get(retired_stateful_api).fallback(retired_stateful_api), + ) + .fallback(retired_stateful_api); + + Router::new() + .nest("/v1/conversations", conversations_router) + .route("/v1/conversations/", any(retired_stateful_api)) + .nest("/v1/share-groups", share_groups_router) + .route("/v1/share-groups/", any(retired_stateful_api)) + .nest("/v1/files", files_router) + .route("/v1/files/", any(retired_stateful_api)) + .nest("/v1/shared-with-me", shared_with_me_router) + .route("/v1/shared-with-me/", any(retired_stateful_api)) +} + /// Create the unified API router with all v1 proxy and API routes. /// /// Route groups and their middleware: @@ -182,105 +292,12 @@ pub fn create_api_router( crate::middleware::dual_auth_middleware, )); - // Retired stateful API surfaces retain their existing session-auth boundary. - // Their handlers deliberately do not access the legacy services, database, - // or Cloud API; they return a consistent migration response instead. - let conversations_router = Router::new() - .route( - "/v1/conversations", - post(retired_stateful_api) - .get(retired_stateful_api) - .fallback(retired_stateful_api), - ) - .route( - "/v1/conversations/{conversation_id}", - post(retired_stateful_api) - .delete(retired_stateful_api) - .fallback(retired_stateful_api), - ) - .route( - "/v1/conversations/{conversation_id}/shares", - post(retired_stateful_api) - .get(retired_stateful_api) - .fallback(retired_stateful_api), - ) - .route( - "/v1/conversations/{conversation_id}/shares/{share_id}", - delete(retired_stateful_api).fallback(retired_stateful_api), - ) - .route( - "/v1/conversations/{conversation_id}/items", - post(retired_stateful_api).fallback(retired_stateful_api), - ) - .route( - "/v1/conversations/{conversation_id}/pin", - post(retired_stateful_api) - .delete(retired_stateful_api) - .fallback(retired_stateful_api), - ) - .route( - "/v1/conversations/{conversation_id}/archive", - post(retired_stateful_api) - .delete(retired_stateful_api) - .fallback(retired_stateful_api), - ) - .route( - "/v1/conversations/{conversation_id}/clone", - post(retired_stateful_api).fallback(retired_stateful_api), - ) - // Keep the retirement behavior stable for unversioned legacy children - // too. The known public GET paths use create_optional_auth_router. - .route("/v1/conversations/", any(retired_stateful_api)) - .route("/v1/conversations/{*path}", any(retired_stateful_api)); - - let share_groups_router = Router::new() - .route( - "/v1/share-groups", - post(retired_stateful_api) - .get(retired_stateful_api) - .fallback(retired_stateful_api), - ) - .route( - "/v1/share-groups/{group_id}", - patch(retired_stateful_api) - .delete(retired_stateful_api) - .fallback(retired_stateful_api), - ) - .route("/v1/share-groups/", any(retired_stateful_api)) - .route("/v1/share-groups/{*path}", any(retired_stateful_api)) - .route( - "/v1/shared-with-me", - get(retired_stateful_api).fallback(retired_stateful_api), - ); - - let files_router = Router::new() - .route( - "/v1/files", - post(retired_stateful_api) - .get(retired_stateful_api) - .fallback(retired_stateful_api), - ) - .route( - "/v1/files/{file_id}", - get(retired_stateful_api) - .delete(retired_stateful_api) - .fallback(retired_stateful_api), - ) - .route( - "/v1/files/{file_id}/content", - get(retired_stateful_api).fallback(retired_stateful_api), - ) - .route("/v1/files/", any(retired_stateful_api)) - .route("/v1/files/{*path}", any(retired_stateful_api)); - - let session_auth_routes = Router::new() - .merge(conversations_router) - .merge(share_groups_router) - .merge(files_router) - .layer(axum::middleware::from_fn_with_state( - auth_state, - crate::middleware::auth_middleware, - )); + // Retired stateful routes retain their existing session-auth boundary. The + // handlers deliberately do not access legacy services, the database, or + // Cloud API; they only return a consistent migration response. + let session_auth_routes = create_retired_stateful_router::().layer( + axum::middleware::from_fn_with_state(auth_state, crate::middleware::auth_middleware), + ); Router::new() .merge(llm_proxy_router) @@ -291,6 +308,7 @@ pub fn create_api_router( } /// Type of resource to track in the response +#[allow(dead_code)] // Retained only until #381 removes the retired stateful implementation. enum TrackableResource { /// New conversation - records metrics Conversation, @@ -319,6 +337,19 @@ fn with_no_store_cache_control(mut response: Response) -> Response { response } +/// Accept only the HTTP no-op `identity` content coding. The request body is +/// inspected before it is normalized, so accepting a compressed representation +/// would let stateful fields evade the local stateless validation. +fn has_only_identity_content_encoding(headers: &HeaderMap) -> bool { + headers.get_all(CONTENT_ENCODING).iter().all(|value| { + value.to_str().is_ok_and(|value| { + value + .split(',') + .all(|coding| coding.trim().eq_ignore_ascii_case("identity")) + }) + }) +} + /// Apply no-store to every `/v1/responses` result, including middleware and /// extractor rejections that do not enter `proxy_responses` itself. async fn force_no_store_response(response: Response) -> Response { @@ -355,10 +386,9 @@ fn stateless_responses_bad_request(error: impl Into) -> Response { /// or response state. Keep the messages aligned with Cloud API so callers get /// a stable 400 locally instead of a version-dependent upstream error. fn validate_stateless_response_body(body: &serde_json::Value) -> Result<(), &'static str> { - let Some(request) = body.as_object() else { - // Leave malformed root values to Cloud API's normal request parsing. - return Ok(()); - }; + let request = body + .as_object() + .ok_or("The stateless Responses API requires a JSON object body.")?; if request.get("store").and_then(serde_json::Value::as_bool) == Some(true) { return Err("The Responses API only supports store: false."); @@ -458,11 +488,10 @@ fn validate_stateless_response_body(body: &serde_json::Value) -> Result<(), &'st /// Normalize a valid JSON request at the proxy boundary. This makes the /// no-store contract explicit even when a client omits `store`. fn normalize_stateless_response_body(body: &mut serde_json::Value) -> Result<(), &'static str> { - validate_stateless_response_body(body)?; - - if let Some(request) = body.as_object_mut() { - request.insert("store".to_string(), serde_json::Value::Bool(false)); - } + let request = body + .as_object_mut() + .ok_or("The stateless Responses API requires a JSON object body.")?; + request.insert("store".to_string(), serde_json::Value::Bool(false)); Ok(()) } @@ -604,6 +633,7 @@ impl From for ShareRecipientPayload { } } +#[allow(dead_code)] fn to_share_response( share: services::conversation::ports::ConversationShare, ) -> ConversationShareResponse { @@ -620,6 +650,7 @@ fn to_share_response( } } +#[allow(dead_code)] fn to_share_group_response(group: services::conversation::ports::ShareGroup) -> ShareGroupResponse { ShareGroupResponse { id: group.id, @@ -654,6 +685,7 @@ pub struct ValidatedListFilesParams { impl ListFilesParams { /// Validate query parameters and return normalized values (with defaults applied) + #[allow(dead_code)] fn validate(self) -> Result)> { // Apply default values let limit = self.limit.unwrap_or(LIST_FILES_LIMIT_MAX); @@ -704,6 +736,7 @@ impl ListFilesParams { ("session_token" = []) ) )] +#[allow(dead_code)] async fn create_conversation( State(state): State, Extension(user): Extension, @@ -805,6 +838,7 @@ async fn create_conversation( ("session_token" = []) ) )] +#[allow(dead_code)] async fn update_conversation( State(state): State, Extension(user): Extension, @@ -891,6 +925,7 @@ async fn update_conversation( ("session_token" = []) ) )] +#[allow(dead_code)] async fn list_conversations( State(state): State, Extension(user): Extension, @@ -952,6 +987,7 @@ async fn list_conversations( ("session_token" = []) // Optional - session token for authenticated access ) )] +#[allow(dead_code)] async fn get_conversation( State(state): State, Extension(user): Extension>, @@ -998,6 +1034,7 @@ async fn get_conversation( ("session_token" = []) ) )] +#[allow(dead_code)] async fn delete_conversation( State(state): State, Extension(user): Extension, @@ -1063,6 +1100,7 @@ async fn delete_conversation( ("session_token" = []) ) )] +#[allow(dead_code)] async fn create_conversation_share( State(state): State, Extension(user): Extension, @@ -1172,6 +1210,7 @@ async fn create_conversation_share( ("session_token" = []) ) )] +#[allow(dead_code)] async fn list_conversation_shares( State(state): State, Extension(user): Extension, @@ -1253,6 +1292,7 @@ async fn list_conversation_shares( ("session_token" = []) ) )] +#[allow(dead_code)] async fn delete_conversation_share( State(state): State, Extension(user): Extension, @@ -1282,6 +1322,7 @@ async fn delete_conversation_share( ("session_token" = []) ) )] +#[allow(dead_code)] async fn create_share_group( State(state): State, Extension(user): Extension, @@ -1359,6 +1400,7 @@ async fn create_share_group( ("session_token" = []) ) )] +#[allow(dead_code)] async fn list_share_groups( State(state): State, Extension(user): Extension, @@ -1428,6 +1470,7 @@ async fn list_share_groups( ("session_token" = []) ) )] +#[allow(dead_code)] async fn update_share_group( State(state): State, Extension(user): Extension, @@ -1513,6 +1556,7 @@ async fn update_share_group( ("session_token" = []) ) )] +#[allow(dead_code)] async fn delete_share_group( State(state): State, Extension(user): Extension, @@ -1540,6 +1584,7 @@ pub struct SharedConversationInfo { } /// Maximum concurrent requests when fetching conversation details +#[allow(dead_code)] const SHARED_CONVERSATIONS_FETCH_CONCURRENCY: usize = 10; /// List conversations shared with the authenticated user @@ -1556,6 +1601,7 @@ const SHARED_CONVERSATIONS_FETCH_CONCURRENCY: usize = 10; ("session_token" = []) ) )] +#[allow(dead_code)] async fn list_shared_with_me( State(state): State, Extension(user): Extension, @@ -1642,6 +1688,7 @@ async fn list_shared_with_me( ("session_token" = []) ) )] +#[allow(dead_code)] async fn create_conversation_items( State(state): State, Extension(user): Extension, @@ -1769,6 +1816,7 @@ async fn create_conversation_items( ("session_token" = []) // Optional - session token for authenticated access ) )] +#[allow(dead_code)] async fn list_conversation_items( State(state): State, Extension(user): Extension>, @@ -1843,6 +1891,7 @@ async fn list_conversation_items( ("session_token" = []) ) )] +#[allow(dead_code)] async fn pin_conversation( State(state): State, Extension(user): Extension, @@ -1915,6 +1964,7 @@ async fn pin_conversation( ("session_token" = []) ) )] +#[allow(dead_code)] async fn unpin_conversation( State(state): State, Extension(user): Extension, @@ -1987,6 +2037,7 @@ async fn unpin_conversation( ("session_token" = []) ) )] +#[allow(dead_code)] async fn archive_conversation( State(state): State, Extension(user): Extension, @@ -2059,6 +2110,7 @@ async fn archive_conversation( ("session_token" = []) ) )] +#[allow(dead_code)] async fn unarchive_conversation( State(state): State, Extension(user): Extension, @@ -2131,6 +2183,7 @@ async fn unarchive_conversation( ("session_token" = []) ) )] +#[allow(dead_code)] async fn clone_conversation( State(state): State, Extension(user): Extension, @@ -2204,6 +2257,7 @@ async fn clone_conversation( ("session_token" = []) ) )] +#[allow(dead_code)] async fn upload_file( State(state): State, Extension(user): Extension, @@ -2275,6 +2329,7 @@ async fn upload_file( ("session_token" = []) ) )] +#[allow(dead_code)] async fn list_files( State(state): State, Extension(user): Extension, @@ -2343,6 +2398,7 @@ async fn list_files( ("session_token" = []) ) )] +#[allow(dead_code)] async fn get_file( State(state): State, Extension(user): Extension, @@ -2397,6 +2453,7 @@ async fn get_file( ("session_token" = []) ) )] +#[allow(dead_code)] async fn delete_file( State(state): State, Extension(user): Extension, @@ -2458,6 +2515,7 @@ async fn delete_file( ("session_token" = []) ) )] +#[allow(dead_code)] async fn get_file_content( State(state): State, Extension(user): Extension, @@ -2550,7 +2608,8 @@ async fn proxy_responses( // Extract body bytes let body_bytes = extract_body_bytes(request).await?; - // Parsed JSON body (if applicable) + // Parsed JSON body. A non-empty request has to be an uncompressed JSON + // object so no stateful field can bypass local validation as opaque bytes. let mut body_json: Option = None; // Optional system prompt resolved from model settings let mut model_system_prompt: Option = None; @@ -2570,19 +2629,30 @@ async fn proxy_responses( body_bytes.len() ); - // Try to parse JSON body once for further processing (model visibility + system prompt) - match serde_json::from_slice::(&body_bytes) { - Ok(v) => { - body_json = Some(v); - } - Err(e) => { - tracing::debug!( - "Failed to parse /responses request body as JSON for user_id={}: {}", - user.user_id, - e - ); - } + if !has_only_identity_content_encoding(&headers) { + return Err(stateless_responses_bad_request( + "The stateless Responses API requires an uncompressed JSON object body.", + )); } + + let body = serde_json::from_slice::(&body_bytes).map_err(|e| { + tracing::debug!( + "Failed to parse /responses request body as JSON for user_id={}: {}", + user.user_id, + e + ); + stateless_responses_bad_request( + "The stateless Responses API requires an uncompressed JSON object body.", + ) + })?; + + if !body.is_object() { + return Err(stateless_responses_bad_request( + "The stateless Responses API requires a JSON object body.", + )); + } + + body_json = Some(body); } // Reject every request feature that would make Cloud API retain state @@ -4757,6 +4827,7 @@ async fn proxy_models( } /// Helper function to handle response: buffer, parse, and track resource +#[allow(dead_code)] async fn handle_trackable_response( state: &crate::state::AppState, user: &AuthenticatedUser, @@ -4948,6 +5019,7 @@ async fn build_response(status: u16, headers: HeaderMap, body: Body) -> Result, @@ -5020,6 +5094,7 @@ async fn validate_conversation_access_optional_auth( } } +#[allow(dead_code)] async fn validate_owner_conversation( state: &crate::state::AppState, user: &AuthenticatedUser, @@ -5047,6 +5122,7 @@ async fn validate_owner_conversation( }) } +#[allow(dead_code)] fn map_share_error(error: ConversationError) -> Response { let (status, message) = match error { ConversationError::NotFound => { @@ -5064,6 +5140,7 @@ fn map_share_error(error: ConversationError) -> Response { (status, Json(ErrorResponse { error: message })).into_response() } +#[allow(dead_code)] async fn fetch_conversation_from_proxy( state: &crate::state::AppState, conversation_id: &str, @@ -5126,6 +5203,7 @@ async fn fetch_conversation_from_proxy( Ok(conversation) } +#[allow(dead_code)] async fn validate_user_file( state: &crate::state::AppState, user: &AuthenticatedUser, @@ -5570,14 +5648,18 @@ async fn collect_stream_to_bytes( #[cfg(test)] mod tests { use super::{ - decompress_if_encoded, ensure_stream_usage_options, normalize_stateless_response_body, - validate_proxy_path_segment, validate_stateless_response_body, + create_optional_auth_router, create_retired_stateful_router, decompress_if_encoded, + ensure_stream_usage_options, has_only_identity_content_encoding, + normalize_stateless_response_body, validate_proxy_path_segment, + validate_stateless_response_body, }; + use axum::{body::Body, Router}; use bytes::Bytes; use flate2::{write::DeflateEncoder, write::GzEncoder, write::ZlibEncoder, Compression}; - use http::{HeaderMap, HeaderValue}; + use http::{HeaderMap, HeaderValue, Method, Request, StatusCode}; use serde_json::json; use std::io::Write; + use tower::ServiceExt; fn headers(content_encoding: &str) -> HeaderMap { let mut headers = HeaderMap::new(); @@ -5588,6 +5670,75 @@ mod tests { headers } + #[test] + fn stateless_responses_accept_only_identity_content_encoding() { + let empty_headers = HeaderMap::new(); + assert!(has_only_identity_content_encoding(&empty_headers)); + + let mut identity_headers = headers("identity"); + assert!(has_only_identity_content_encoding(&identity_headers)); + identity_headers.append("content-encoding", HeaderValue::from_static("IDENTITY")); + assert!(has_only_identity_content_encoding(&identity_headers)); + + let mixed_headers = headers("identity, gzip"); + assert!(!has_only_identity_content_encoding(&mixed_headers)); + let gzip_headers = headers("gzip"); + assert!(!has_only_identity_content_encoding(&gzip_headers)); + } + + async fn assert_retired_route(app: &Router, method: Method, path: &str) { + let response = app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(path) + .body(Body::empty()) + .expect("test request should be valid"), + ) + .await + .expect("router should not return an error"); + + assert_eq!( + response.status(), + StatusCode::GONE, + "unexpected route: {path}" + ); + assert_eq!( + response + .headers() + .get(http::header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store") + ); + } + + #[tokio::test] + async fn retired_stateful_router_merges_optional_reads_and_catches_descendants() { + // This is the same composition used by the application before the + // respective optional/session authentication layers are applied. + let app = create_optional_auth_router::<()>().merge(create_retired_stateful_router()); + + assert_retired_route(&app, Method::GET, "/v1/conversations/conv_legacy").await; + assert_retired_route(&app, Method::GET, "/v1/conversations/conv_legacy/items").await; + assert_retired_route(&app, Method::PATCH, "/v1/conversations/conv_legacy").await; + assert_retired_route( + &app, + Method::PATCH, + "/v1/conversations/conv_legacy/unknown-child", + ) + .await; + assert_retired_route(&app, Method::GET, "/v1/conversations/").await; + assert_retired_route(&app, Method::PATCH, "/v1/files/file_legacy/unknown-child").await; + assert_retired_route( + &app, + Method::GET, + "/v1/share-groups/group_legacy/unknown-child", + ) + .await; + assert_retired_route(&app, Method::GET, "/v1/shared-with-me/unknown-child").await; + } + fn gzip_encode(input: &[u8]) -> Vec { let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); encoder.write_all(input).unwrap(); @@ -5722,6 +5873,15 @@ mod tests { json!({ "tools": [{ "type": "mcp" }] }), "The stateless Responses API does not support MCP tools that require approval.", ), + ( + json!({ + "tools": [{ + "type": "mcp", + "require_approval": { "never": { "tool_names": ["search"] } } + }] + }), + "The stateless Responses API does not support MCP tools that require approval.", + ), ]; for (body, expected) in cases { @@ -5738,6 +5898,17 @@ mod tests { .is_ok()); } + #[test] + fn stateless_responses_require_a_json_object() { + for body in [json!(null), json!(["not", "an", "object"]), json!("text")] { + assert_eq!( + validate_stateless_response_body(&body), + Err("The stateless Responses API requires a JSON object body."), + "non-object body should be rejected: {body}" + ); + } + } + #[test] fn streaming_chat_requests_enable_usage_options() { let mut body = json!({ diff --git a/crates/api/src/routes/mod.rs b/crates/api/src/routes/mod.rs index 0386cdf0..83346f3f 100644 --- a/crates/api/src/routes/mod.rs +++ b/crates/api/src/routes/mod.rs @@ -127,10 +127,11 @@ pub fn create_router_with_cors(app_state: AppState, cors_config: config::CorsCon // Conversation read routes with optional authentication // These routes work for both authenticated users and unauthenticated users // (for accessing publicly shared conversations) - let optional_auth_routes = api::create_optional_auth_router().layer(from_fn_with_state( - auth_state.clone(), - crate::middleware::optional_auth_middleware, - )); + let optional_auth_routes = + api::create_optional_auth_router::().layer(from_fn_with_state( + auth_state.clone(), + crate::middleware::optional_auth_middleware, + )); let dual_auth_state = crate::middleware::DualAuthState { auth_state: auth_state.clone(), diff --git a/crates/api/tests/conversations_tests.rs b/crates/api/tests/conversations_tests.rs index eda71e4e..26d052a6 100644 --- a/crates/api/tests/conversations_tests.rs +++ b/crates/api/tests/conversations_tests.rs @@ -39,6 +39,15 @@ async fn retired_conversation_routes_return_the_migration_response() { assert_retired(server.get("/v1/conversations/conv_legacy").await); assert_retired(server.get("/v1/conversations/conv_legacy/items").await); + // Unknown descendants remain protected by the original session boundary. + assert_eq!( + server + .get("/v1/conversations/conv_legacy/unknown-child") + .await + .status_code(), + StatusCode::UNAUTHORIZED + ); + // Mutating conversation routes retain the old session-auth boundary. assert_eq!( server.post("/v1/conversations").await.status_code(), @@ -64,6 +73,7 @@ async fn retired_conversation_routes_return_the_migration_response() { (Method::POST, "/v1/conversations/conv_legacy/archive"), (Method::DELETE, "/v1/conversations/conv_legacy/archive"), (Method::POST, "/v1/conversations/conv_legacy/clone"), + (Method::GET, "/v1/conversations/"), // Exact known routes and unknown descendants also use the migration // response for methods that were never part of the old contract. (Method::PATCH, "/v1/conversations/conv_legacy"), diff --git a/crates/api/tests/files_tests.rs b/crates/api/tests/files_tests.rs index 76584f97..39632af9 100644 --- a/crates/api/tests/files_tests.rs +++ b/crates/api/tests/files_tests.rs @@ -41,6 +41,13 @@ async fn retired_file_and_sharing_routes_return_the_migration_response() { server.get("/v1/files").await.status_code(), StatusCode::UNAUTHORIZED ); + assert_eq!( + server + .get("/v1/files/file_legacy/unknown-child") + .await + .status_code(), + StatusCode::UNAUTHORIZED + ); for (method, path) in [ // Every method that was previously supported by the session-auth API. @@ -49,8 +56,10 @@ async fn retired_file_and_sharing_routes_return_the_migration_response() { (Method::GET, "/v1/files/file_legacy"), (Method::DELETE, "/v1/files/file_legacy"), (Method::GET, "/v1/files/file_legacy/content"), + (Method::GET, "/v1/files/"), (Method::POST, "/v1/share-groups"), (Method::GET, "/v1/share-groups"), + (Method::GET, "/v1/share-groups/"), (Method::PATCH, "/v1/share-groups/group_legacy"), (Method::DELETE, "/v1/share-groups/group_legacy"), (Method::GET, "/v1/shared-with-me"), @@ -60,6 +69,7 @@ async fn retired_file_and_sharing_routes_return_the_migration_response() { (Method::PATCH, "/v1/files/file_legacy/unknown-child"), (Method::PATCH, "/v1/share-groups/group_legacy/unknown-child"), (Method::POST, "/v1/shared-with-me"), + (Method::GET, "/v1/shared-with-me/unknown-child"), ] { assert_retired( server diff --git a/crates/api/tests/responses_stateless_tests.rs b/crates/api/tests/responses_stateless_tests.rs index 88e92728..207769a0 100644 --- a/crates/api/tests/responses_stateless_tests.rs +++ b/crates/api/tests/responses_stateless_tests.rs @@ -1,11 +1,14 @@ mod common; +use bytes::Bytes; use common::{ create_test_server_and_db, insert_test_subscription, mock_login, set_subscription_plans, TestServerConfig, }; -use http::{HeaderName, HeaderValue}; +use flate2::{write::GzEncoder, Compression}; +use http::{HeaderName, HeaderValue, StatusCode}; use serde_json::json; +use std::io::Write; use uuid::Uuid; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -17,6 +20,30 @@ fn bearer(token: &str) -> (HeaderName, HeaderValue) { ) } +fn gzip_json(value: &serde_json::Value) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder + .write_all(&serde_json::to_vec(value).expect("test JSON should serialize")) + .expect("gzip test body should be writable"); + encoder.finish().expect("gzip test body should finish") +} + +fn assert_stateless_bad_request(response: axum_test::TestResponse, expected_error: &str) { + assert_eq!(response.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + response + .headers() + .get(http::header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store") + ); + let body: serde_json::Value = response.json(); + assert_eq!( + body.get("error").and_then(|value| value.as_str()), + Some(expected_error) + ); +} + #[tokio::test] async fn responses_forwards_store_false_without_author_metadata() { let mock_upstream = MockServer::start().await; @@ -62,6 +89,7 @@ async fn responses_forwards_store_false_without_author_metadata() { let response = server .post("/v1/responses") .add_header(auth.0, auth.1) + .add_header(http::header::CONTENT_ENCODING, "identity") .json(&json!({ "model": "gpt-test", "metadata": { "client_key": "client_value" }, @@ -106,3 +134,80 @@ async fn responses_forwards_store_false_without_author_metadata() { "Chat API must not inject stateful author metadata" ); } + +#[tokio::test] +async fn responses_rejects_encoded_or_non_object_bodies_without_forwarding() { + let mock_upstream = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/responses")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&mock_upstream) + .await; + + let (server, db) = create_test_server_and_db(TestServerConfig { + proxy_base_url: Some(mock_upstream.uri()), + ..Default::default() + }) + .await; + + set_subscription_plans( + &server, + json!({ + "basic": { + "providers": { "stripe": { "price_id": "price_test_basic" } }, + "monthly_credits": { "max": 1_000_000_000 } + } + }), + ) + .await; + + let email = format!("stateless-invalid-body-{}@example.com", Uuid::new_v4()); + let token = mock_login(&server, &email).await; + insert_test_subscription(&server, &db, &email, false).await; + let auth = bearer(&token); + + let gzip_body = gzip_json(&json!({ + "model": "gpt-test", + "store": true, + "conversation": "conv_legacy" + })); + assert_stateless_bad_request( + server + .post("/v1/responses") + .add_header(auth.0.clone(), auth.1.clone()) + .add_header(http::header::CONTENT_ENCODING, "gzip") + .content_type("application/json") + .bytes(Bytes::from(gzip_body)) + .await, + "The stateless Responses API requires an uncompressed JSON object body.", + ); + + assert_stateless_bad_request( + server + .post("/v1/responses") + .add_header(auth.0.clone(), auth.1.clone()) + .content_type("application/json") + .bytes(Bytes::from_static(b"{\"model\":")) + .await, + "The stateless Responses API requires an uncompressed JSON object body.", + ); + + assert_stateless_bad_request( + server + .post("/v1/responses") + .add_header(auth.0, auth.1) + .json(&json!(["not", "an", "object"])) + .await, + "The stateless Responses API requires a JSON object body.", + ); + + assert!( + mock_upstream + .received_requests() + .await + .expect("mock upstream should record requests") + .is_empty(), + "invalid stateless requests must not reach Cloud API" + ); +} From 7a154691329ebaaf350d3ba42873a4a91ea1127b Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:37:04 +0800 Subject: [PATCH 3/5] test(api): satisfy responses subscription guard --- .../api/tests/responses_permissions_tests.rs | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/api/tests/responses_permissions_tests.rs b/crates/api/tests/responses_permissions_tests.rs index 0261162f..0f069367 100644 --- a/crates/api/tests/responses_permissions_tests.rs +++ b/crates/api/tests/responses_permissions_tests.rs @@ -1,12 +1,28 @@ mod common; -use common::{create_test_server, mock_login}; +use common::{ + create_test_server_and_db, insert_test_subscription, mock_login, set_subscription_plans, + TestServerConfig, +}; use serde_json::json; +use uuid::Uuid; #[tokio::test] async fn responses_rejects_conversation_state_locally() { - let server = create_test_server().await; - let token = mock_login(&server, "no-write@test.com").await; + let (server, db) = create_test_server_and_db(TestServerConfig::default()).await; + set_subscription_plans( + &server, + json!({ + "basic": { + "providers": { "stripe": { "price_id": "price_test_basic" } }, + "monthly_credits": { "max": 1_000_000_000 } + } + }), + ) + .await; + let email = format!("stateless-permissions-{}@example.com", Uuid::new_v4()); + let token = mock_login(&server, &email).await; + insert_test_subscription(&server, &db, &email, false).await; // The stateless proxy must reject a conversation reference before it can // invoke Cloud API or the legacy conversation-access service. From 9bf6ab0f25f27e691ada5a1ec0ef362797e66436 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:45:52 +0800 Subject: [PATCH 4/5] feat(api): retain Stage I read-only data views --- README.md | 22 +- crates/api/src/openapi.rs | 35 +- crates/api/src/routes/api.rs | 458 +++++------------- crates/api/src/routes/mod.rs | 16 +- crates/api/tests/README.md | 28 +- crates/api/tests/conversations_tests.rs | 281 +++++++++-- crates/api/tests/files_tests.rs | 142 ++++-- crates/api/tests/responses_stateless_tests.rs | 169 +++++++ 8 files changed, 688 insertions(+), 463 deletions(-) diff --git a/README.md b/README.md index fa516726..37c0a37c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ A Rust backend service that proxies OpenAI-compatible inference requests to **NE ``` crates/ ├── api/ # Axum HTTP server, routes, middleware, OpenAPI docs (utoipa) -├── services/ # Business logic: auth, response proxy, user management +├── services/ # Business logic: auth, temporary read views, response proxy, user management ├── database/ # PostgreSQL (tokio-postgres, deadpool), migrations, repositories └── config/ # Environment-based configuration structs ``` @@ -30,13 +30,15 @@ crates/ - **Repository Pattern**: Database access through trait-based repositories (`PostgresUserRepository`, etc.) - **Service Layer**: Business logic in `services` crate, injected into `AppState` - **NEAR AI Cloud API Proxy**: OpenAI-compatible inference routes forward to NEAR AI Cloud API with auth; Responses requests are stateless +- **Temporary Read Views**: Owner-only Conversation and File GET endpoints remain available for the Stage I migration/export window. Sharing surfaces and all write operations return `410 Gone`. - **Patroni Support**: Optional cluster discovery for HA PostgreSQL via `DATABASE_PRIMARY_APP_ID` ### Request Flow -1. Request → Auth middleware (validates session token) → Route handler -2. `/v1/responses` → validate stateless fields → forward to NEAR AI Cloud API with `store: false` -3. Usage, subscription, rate-limit, and attestation-related proxy behavior remain local to Chat API +1. Request → its historical authentication boundary → route handler +2. `/v1/responses` → validate stateless linkage fields → forward to NEAR AI Cloud API with `store: false` +3. Temporary owner-only Conversation/File GET views → local ownership lookup and, where needed, Cloud read view +4. Usage, subscription, rate-limit, and attestation-related proxy behavior remain local to Chat API ## Development @@ -81,9 +83,11 @@ docker compose down # Stop services ### Testing ```bash -cargo test --features test # All tests -cargo test --test admin_tests --features test # Admin tests only -cargo test --test responses_permissions_tests --features test # Stateless Responses boundary tests +cargo test --features test # All tests +cargo test --test admin_tests --features test # Admin tests only +cargo test --test responses_stateless_tests --features test +cargo test --test conversations_tests --features test +cargo test --test files_tests --features test ``` ### Code Quality @@ -185,11 +189,13 @@ OpenAPI docs available at `/docs`. **Key endpoints**: - `/v1/auth/*` - OAuth authentication - `/v1/responses` - OpenAI-compatible, stateless Responses API (proxied to NEAR AI Cloud API) +- `/v1/conversations/*` - Temporary owner-only Conversation views for migration/export +- `/v1/files/*` - Temporary read-only File views for migration/export - `/v1/attestation/report` - TEE attestation reports - `/v1/users/*` - User management - `/v1/admin/*` - Admin operations -**Stateless migration**: `/v1/conversations/**`, `/v1/files/**`, `/v1/share-groups/**`, and `/v1/shared-with-me` are retired. After their historical authentication boundary, they return `410 Gone`. Build multi-turn context in the next request rather than referring to a stored conversation or response. `/v1/responses` rejects stateful fields such as `conversation`, `previous_response_id`, `store: true`, file input, background mode, and continuation-dependent tools with a local `400` response. +**Stage I migration**: owner-only Conversation and File GET views remain temporarily available for authenticated private-chat data export. Sharing APIs, all established write operations (create/update/delete, item creation, upload, pin/archive, clone, and share-group mutation), plus unsupported methods and descendants within those legacy namespaces, return `410 Gone` with `Cache-Control: no-store` after session authentication. These views will be removed in Stage III. `/v1/responses` is stateless: requests are normalized to `store: false`, and response/conversation linkage fields such as `conversation`, `previous_response_id`, and `background: true` are rejected. Clients may use custom function tools and replay their own function results; Cloud validates tool and input shapes. **Note**: OpenAI-compatible inference requests are proxied to **NEAR AI Cloud API**. Set `OPENAI_BASE_URL` to your NEAR AI Cloud API endpoint. diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index d3ba5eaf..d38d80e3 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -6,7 +6,7 @@ use utoipa::OpenApi; #[openapi( info( title = "NEAR AI Chat API", - description = "An OpenAI-compatible, stateless inference proxy for NEAR AI Cloud API.", + description = "An authenticated OpenAI-compatible inference proxy with temporary read-only Private Chat views for migration and export.", version = "1.0.0", contact(name = "NEAR AI Team", email = "support@near.ai"), license(name = "MIT",) @@ -25,6 +25,14 @@ use utoipa::OpenApi; crate::routes::users::get_user_status, crate::routes::users::delete_current_user, crate::routes::users::get_my_usage, + // Temporary Stage I owner-only Conversation endpoints + crate::routes::api::list_conversations, + crate::routes::api::get_conversation, + crate::routes::api::list_conversation_items, + // Temporary Stage I read-only File endpoints + crate::routes::api::list_files, + crate::routes::api::get_file, + crate::routes::api::get_file_content, // Proxy endpoints crate::routes::api::proxy_responses, crate::routes::api::proxy_chat_completions, @@ -148,6 +156,11 @@ use utoipa::OpenApi; crate::models::UserUsageResponse, crate::routes::admin::TopUsageResponse, crate::routes::api::ErrorResponse, + // Temporary owner-only Conversation models + // Temporary read-only File models + crate::models::FileListResponse, + crate::models::FileGetResponse, + crate::routes::api::ListFilesParams, // Credits models crate::routes::credits::CreateCreditCheckoutRequest, crate::routes::credits::CreateCreditCheckoutResponse, @@ -223,6 +236,8 @@ use utoipa::OpenApi; (name = "Health", description = "Health check and service status endpoints"), (name = "Auth", description = "OAuth authentication endpoints"), (name = "Users", description = "User profile management endpoints"), + (name = "Conversations", description = "Temporary owner-only Conversation views for migration/export. Conversation sharing and all mutations return 410 Gone."), + (name = "Files", description = "Temporary owner-only File views for migration/export. File mutations and unsupported legacy paths return 410 Gone."), (name = "Proxy", description = "Proxy endpoints for OpenAI-compatible APIs"), (name = "Credits", description = "Credit purchase and balance endpoints"), (name = "Subscriptions", description = "Subscription management endpoints"), @@ -260,28 +275,36 @@ mod tests { use utoipa::OpenApi; #[test] - fn omits_retired_stateful_api_paths() { + fn documents_owner_only_views_but_not_stateful_or_sharing_surfaces() { let spec = serde_json::to_value(ApiDoc::openapi()).expect("OpenAPI serialization"); for path in [ "/v1/conversations", "/v1/conversations/{conversation_id}", "/v1/conversations/{conversation_id}/items", + "/v1/files", + "/v1/files/{file_id}", + "/v1/files/{file_id}/content", + ] { + assert!( + spec["paths"].get(path).is_some(), + "temporary read path {path} must be in OpenAPI" + ); + } + + for path in [ "/v1/conversations/{conversation_id}/shares", "/v1/conversations/{conversation_id}/shares/{share_id}", "/v1/conversations/{conversation_id}/pin", "/v1/conversations/{conversation_id}/archive", "/v1/conversations/{conversation_id}/clone", - "/v1/files", - "/v1/files/{file_id}", - "/v1/files/{file_id}/content", "/v1/share-groups", "/v1/share-groups/{group_id}", "/v1/shared-with-me", ] { assert!( spec["paths"].get(path).is_none(), - "retired stateful path {path} must not be in OpenAPI" + "disabled stateful or sharing path {path} must not be in OpenAPI" ); } } diff --git a/crates/api/src/routes/api.rs b/crates/api/src/routes/api.rs index 8d3a7d0a..a16935e0 100644 --- a/crates/api/src/routes/api.rs +++ b/crates/api/src/routes/api.rs @@ -95,64 +95,41 @@ mod openapi_errors { use openapi_errors::*; use openapi_tags::*; -/// Create retirement routes for legacy public conversation reads. +/// Create the Stage I stateful API surface. /// -/// These paths historically supported optional authentication for public shares. -/// Keep that boundary while returning the same migration response as the -/// authenticated stateful routes. -pub fn create_optional_auth_router() -> Router -where - S: Clone + Send + Sync + 'static, -{ - Router::new() - .route( - "/v1/conversations/{conversation_id}", - get(retired_stateful_api), - ) - .route( - "/v1/conversations/{conversation_id}/items", - get(retired_stateful_api), - ) -} - -/// Create the retired stateful surfaces without their authentication layer. -/// -/// Conversation GET routes intentionally omit their handlers here: they merge -/// with `create_optional_auth_router` at the application boundary so public -/// shared reads retain their historical optional-auth behavior. Every other -/// request is protected by the caller's session-auth layer. -fn create_retired_stateful_router() -> Router -where - S: Clone + Send + Sync + 'static, -{ - // The nested router's fallback is stored separately by Axum, which lets it - // catch unknown descendants without conflicting with `{conversation_id}`. +/// Owner-only Conversation and File views remain available temporarily for +/// private-chat export. Sharing surfaces and every established mutation return +/// the migration response. This router keeps that response scoped to the +/// legacy stateful namespaces, so unsupported methods and descendants cannot +/// fall through to unrelated app routes. +fn create_read_only_stateful_router() -> Router { let conversations_router = Router::new() .route( "/", - post(retired_stateful_api) - .get(retired_stateful_api) + get(list_conversations) + .post(retired_stateful_api) .fallback(retired_stateful_api), ) + // `batch` is internal to the Chat API list implementation. Never + // expose it as a public conversation ID or endpoint. + .route("/batch", any(retired_stateful_api)) .route( "/{conversation_id}", - post(retired_stateful_api) + get(get_conversation) + .post(retired_stateful_api) .delete(retired_stateful_api) .fallback(retired_stateful_api), ) - .route( - "/{conversation_id}/shares", - post(retired_stateful_api) - .get(retired_stateful_api) - .fallback(retired_stateful_api), - ) + .route("/{conversation_id}/shares", any(retired_stateful_api)) .route( "/{conversation_id}/shares/{share_id}", delete(retired_stateful_api).fallback(retired_stateful_api), ) .route( "/{conversation_id}/items", - post(retired_stateful_api).fallback(retired_stateful_api), + get(list_conversation_items) + .post(retired_stateful_api) + .fallback(retired_stateful_api), ) .route( "/{conversation_id}/pin", @@ -173,12 +150,7 @@ where .fallback(retired_stateful_api); let share_groups_router = Router::new() - .route( - "/", - post(retired_stateful_api) - .get(retired_stateful_api) - .fallback(retired_stateful_api), - ) + .route("/", any(retired_stateful_api)) .route( "/{group_id}", patch(retired_stateful_api) @@ -190,27 +162,24 @@ where let files_router = Router::new() .route( "/", - post(retired_stateful_api) - .get(retired_stateful_api) + get(list_files) + .post(retired_stateful_api) .fallback(retired_stateful_api), ) .route( "/{file_id}", - get(retired_stateful_api) + get(get_file) .delete(retired_stateful_api) .fallback(retired_stateful_api), ) .route( "/{file_id}/content", - get(retired_stateful_api).fallback(retired_stateful_api), + get(get_file_content).fallback(retired_stateful_api), ) .fallback(retired_stateful_api); let shared_with_me_router = Router::new() - .route( - "/", - get(retired_stateful_api).fallback(retired_stateful_api), - ) + .route("/", any(retired_stateful_api)) .fallback(retired_stateful_api); Router::new() @@ -218,10 +187,10 @@ where .route("/v1/conversations/", any(retired_stateful_api)) .nest("/v1/share-groups", share_groups_router) .route("/v1/share-groups/", any(retired_stateful_api)) - .nest("/v1/files", files_router) - .route("/v1/files/", any(retired_stateful_api)) .nest("/v1/shared-with-me", shared_with_me_router) .route("/v1/shared-with-me/", any(retired_stateful_api)) + .nest("/v1/files", files_router) + .route("/v1/files/", any(retired_stateful_api)) } /// Create the unified API router with all v1 proxy and API routes. @@ -230,7 +199,8 @@ where /// - Chat completions and images: dual auth + subscription + rate limited /// - Responses: dual auth + subscription + rate limited, always no-store /// - Model list, models, signature: dual auth only (not rate limited) -/// - Retired conversations, share groups, files: their existing auth boundary +/// - Temporary owner-only Conversation and File views: session auth; sharing, +/// mutations, and unsupported legacy paths return 410 pub fn create_api_router( rate_limit_state: crate::middleware::RateLimitState, dual_auth_state: crate::middleware::DualAuthState, @@ -292,12 +262,15 @@ pub fn create_api_router( crate::middleware::dual_auth_middleware, )); - // Retired stateful routes retain their existing session-auth boundary. The - // handlers deliberately do not access legacy services, the database, or - // Cloud API; they only return a consistent migration response. - let session_auth_routes = create_retired_stateful_router::().layer( - axum::middleware::from_fn_with_state(auth_state, crate::middleware::auth_middleware), - ); + // Temporary stateful read views retain their existing session-auth + // boundary. Wrap the whole layer so reads, migration responses, and auth + // errors are never cached. + let session_auth_routes = create_read_only_stateful_router() + .layer(axum::middleware::from_fn_with_state( + auth_state, + crate::middleware::auth_middleware, + )) + .layer(axum::middleware::map_response(force_no_store_response)); Router::new() .merge(llm_proxy_router) @@ -308,7 +281,7 @@ pub fn create_api_router( } /// Type of resource to track in the response -#[allow(dead_code)] // Retained only until #381 removes the retired stateful implementation. +#[allow(dead_code)] // Retained until the Stage III physical cleanup. enum TrackableResource { /// New conversation - records metrics Conversation, @@ -322,10 +295,11 @@ pub struct ErrorResponse { pub error: String, } -/// Message returned by every retired stateful API route. +/// Message returned by every disabled stateful mutation route. /// -/// The stateful surfaces are intentionally still registered so clients receive -/// a clear migration signal rather than a proxy error from Cloud API. +/// Temporary stateful read views remain available for the Stage I export +/// window. Disabled mutations return this clear migration signal rather than a +/// proxy error from Cloud API. pub const STATEFUL_API_RETIRED_MESSAGE: &str = "This stateful API has been retired. Use /v1/responses with store: false and include all context in each request."; @@ -350,14 +324,16 @@ fn has_only_identity_content_encoding(headers: &HeaderMap) -> bool { }) } -/// Apply no-store to every `/v1/responses` result, including middleware and -/// extractor rejections that do not enter `proxy_responses` itself. -async fn force_no_store_response(response: Response) -> Response { +/// Apply no-store to every temporary sensitive-data response, including +/// middleware and extractor rejections that do not enter a handler itself. +pub(crate) async fn force_no_store_response(response: Response) -> Response { with_no_store_cache_control(response) } -/// Return the stable migration response for legacy conversation, file, and -/// sharing routes. The route remains behind its existing authentication layer. +/// Return the stable migration response for a disabled Stage I stateful +/// surface. This covers mutations, retired sharing reads, and unsupported +/// paths within the scoped legacy namespaces; the route remains behind its +/// existing authentication layer. async fn retired_stateful_api() -> Response { with_no_store_cache_control( ( @@ -382,9 +358,9 @@ fn stateless_responses_bad_request(error: impl Into) -> Response { ) } -/// Reject response fields that require Cloud API to retain conversation, file, -/// or response state. Keep the messages aligned with Cloud API so callers get -/// a stable 400 locally instead of a version-dependent upstream error. +/// Reject the response fields that unambiguously require Cloud API to retain +/// state. Tool and input-item shapes are otherwise forwarded unchanged so +/// Cloud remains the single source of truth for its supported capabilities. fn validate_stateless_response_body(body: &serde_json::Value) -> Result<(), &'static str> { let request = body .as_object() @@ -416,72 +392,6 @@ fn validate_stateless_response_body(body: &serde_json::Value) -> Result<(), &'st return Err("The stateless Responses API does not support background."); } - if let Some(input_items) = request.get("input").and_then(serde_json::Value::as_array) { - for item in input_items { - match item.get("type").and_then(serde_json::Value::as_str) { - Some("mcp_approval_response") => { - return Err( - "The stateless Responses API does not support MCP approval continuation.", - ); - } - Some("function_call_output") => { - return Err( - "The stateless Responses API does not support function continuation.", - ); - } - _ => {} - } - - if item - .get("content") - .and_then(serde_json::Value::as_array) - .is_some_and(|parts| { - parts.iter().any(|part| { - part.get("type").and_then(serde_json::Value::as_str) == Some("input_file") - }) - }) - { - return Err("The stateless Responses API does not support input_file."); - } - } - } - - if let Some(tools) = request.get("tools").and_then(serde_json::Value::as_array) { - for tool in tools { - match tool.get("type").and_then(serde_json::Value::as_str) { - Some("file_search") => { - return Err("The stateless Responses API does not support file_search."); - } - Some("function") => { - return Err( - "The stateless Responses API does not support function tools because they require continuation.", - ); - } - Some("code_interpreter") => { - return Err( - "The stateless Responses API does not support code_interpreter because it requires continuation.", - ); - } - Some("computer") => { - return Err( - "The stateless Responses API does not support computer because it requires continuation.", - ); - } - Some("mcp") - if tool - .get("require_approval") - .and_then(serde_json::Value::as_str) - != Some("never") => - { - return Err( - "The stateless Responses API does not support MCP tools that require approval.", - ); - } - _ => {} - } - } - } - Ok(()) } @@ -633,7 +543,6 @@ impl From for ShareRecipientPayload { } } -#[allow(dead_code)] fn to_share_response( share: services::conversation::ports::ConversationShare, ) -> ConversationShareResponse { @@ -650,7 +559,6 @@ fn to_share_response( } } -#[allow(dead_code)] fn to_share_group_response(group: services::conversation::ports::ShareGroup) -> ShareGroupResponse { ShareGroupResponse { id: group.id, @@ -685,7 +593,6 @@ pub struct ValidatedListFilesParams { impl ListFilesParams { /// Validate query parameters and return normalized values (with defaults applied) - #[allow(dead_code)] fn validate(self) -> Result)> { // Apply default values let limit = self.limit.unwrap_or(LIST_FILES_LIMIT_MAX); @@ -925,7 +832,6 @@ async fn update_conversation( ("session_token" = []) ) )] -#[allow(dead_code)] async fn list_conversations( State(state): State, Extension(user): Extension, @@ -960,16 +866,13 @@ async fn list_conversations( Ok(Json(conversations).into_response()) } -/// Get a conversation - validates user access or public share and fetches details via service/OpenAI -/// Works with optional authentication - authenticated users get their access checked, -/// unauthenticated users can only access publicly shared conversations +/// Get a conversation for an authenticated user and fetch details via service/OpenAI. /// -/// # Authentication -/// This endpoint supports **optional authentication**: -/// - **With authentication**: Returns conversation if user owns it or has been granted access via sharing -/// - **Without authentication**: Returns conversation only if it has been publicly shared +/// Only the Conversation owner may use this temporary export view during the +/// Stage I window. Public and shared access are not exposed here. /// -/// This allows public sharing of conversations while maintaining access control for private conversations. +/// # Authentication +/// Returns the conversation only when the session user owns it. #[utoipa::path( get, path = "/v1/conversations/{conversation_id}", @@ -979,35 +882,26 @@ async fn list_conversations( ), responses( (status = 200, description = "Conversation retrieved successfully", body = serde_json::Value), - (status = 403, description = "Access denied - conversation not accessible to this user or not publicly shared"), + (status = 401, description = UNAUTHORIZED, body = ErrorResponse), (status = 404, description = CONVERSATION_NOT_FOUND) ), security( - (), // Optional - no auth required for publicly shared conversations - ("session_token" = []) // Optional - session token for authenticated access + ("session_token" = []) ) )] -#[allow(dead_code)] async fn get_conversation( State(state): State, - Extension(user): Extension>, + Extension(user): Extension, Path(conversation_id): Path, headers: HeaderMap, ) -> Result, Response> { tracing::info!( - "get_conversation called for user_id={:?}, conversation_id={}", - user.as_ref().map(|u| u.user_id), + "get_conversation called for user_id={}, conversation_id={}", + user.user_id, conversation_id ); - // Check user access OR public share access - validate_conversation_access_optional_auth( - &state, - user.as_ref(), - &conversation_id, - SharePermission::Read, - ) - .await?; + validate_owner_conversation(&state, &user, &conversation_id).await?; let conversation = fetch_conversation_from_proxy(&state, &conversation_id, headers.clone()).await?; @@ -1210,7 +1104,7 @@ async fn create_conversation_share( ("session_token" = []) ) )] -#[allow(dead_code)] +#[allow(dead_code)] // Sharing reads are removed from the Stage I surface. async fn list_conversation_shares( State(state): State, Extension(user): Extension, @@ -1400,7 +1294,7 @@ async fn create_share_group( ("session_token" = []) ) )] -#[allow(dead_code)] +#[allow(dead_code)] // Sharing reads are removed from the Stage I surface. async fn list_share_groups( State(state): State, Extension(user): Extension, @@ -1584,7 +1478,6 @@ pub struct SharedConversationInfo { } /// Maximum concurrent requests when fetching conversation details -#[allow(dead_code)] const SHARED_CONVERSATIONS_FETCH_CONCURRENCY: usize = 10; /// List conversations shared with the authenticated user @@ -1601,7 +1494,7 @@ const SHARED_CONVERSATIONS_FETCH_CONCURRENCY: usize = 10; ("session_token" = []) ) )] -#[allow(dead_code)] +#[allow(dead_code)] // Sharing reads are removed from the Stage I surface. async fn list_shared_with_me( State(state): State, Extension(user): Extension, @@ -1790,15 +1683,11 @@ async fn create_conversation_items( .await } -/// List conversation items - works with optional authentication -/// Authenticated users get their access checked, unauthenticated users can only access public conversations +/// List conversation items for an authenticated user. /// /// # Authentication -/// This endpoint supports **optional authentication**: -/// - **With authentication**: Returns items if user owns the conversation or has been granted access via sharing -/// - **Without authentication**: Returns items only if the conversation has been publicly shared -/// -/// This allows public sharing of conversation content while maintaining access control for private conversations. +/// Only the Conversation owner may use this temporary export view during the +/// Stage I window. #[utoipa::path( get, path = "/v1/conversations/{conversation_id}/items", @@ -1808,39 +1697,30 @@ async fn create_conversation_items( ), responses( (status = 200, description = "Conversation items retrieved successfully"), - (status = 403, description = "Access denied - conversation not accessible to this user or not publicly shared"), + (status = 401, description = UNAUTHORIZED, body = ErrorResponse), (status = 404, description = CONVERSATION_NOT_FOUND) ), security( - (), // Optional - no auth required for publicly shared conversations - ("session_token" = []) // Optional - session token for authenticated access + ("session_token" = []) ) )] -#[allow(dead_code)] async fn list_conversation_items( State(state): State, - Extension(user): Extension>, + Extension(user): Extension, Path(conversation_id): Path, headers: HeaderMap, ) -> Result { tracing::info!( - "list_conversation_items called for user_id={:?}, conversation_id={}", - user.as_ref().map(|u| u.user_id), + "list_conversation_items called for user_id={}, conversation_id={}", + user.user_id, conversation_id ); - // Check user access OR public share access - validate_conversation_access_optional_auth( - &state, - user.as_ref(), - &conversation_id, - SharePermission::Read, - ) - .await?; + validate_owner_conversation(&state, &user, &conversation_id).await?; tracing::debug!( - "Forwarding conversation items list request to OpenAI for user_id={:?}", - user.as_ref().map(|u| u.user_id) + "Forwarding conversation items list request to OpenAI for user_id={}", + user.user_id ); // Forward to OpenAI @@ -2329,7 +2209,6 @@ async fn upload_file( ("session_token" = []) ) )] -#[allow(dead_code)] async fn list_files( State(state): State, Extension(user): Extension, @@ -2398,7 +2277,6 @@ async fn list_files( ("session_token" = []) ) )] -#[allow(dead_code)] async fn get_file( State(state): State, Extension(user): Extension, @@ -2515,7 +2393,6 @@ async fn delete_file( ("session_token" = []) ) )] -#[allow(dead_code)] async fn get_file_content( State(state): State, Extension(user): Extension, @@ -5037,7 +4914,6 @@ async fn validate_user_conversation( } /// Validate user has access OR the conversation is publicly shared -#[allow(dead_code)] async fn validate_user_or_public_conversation( state: &crate::state::AppState, user: &AuthenticatedUser, @@ -5066,35 +4942,6 @@ async fn validate_user_or_public_conversation( .map_err(map_share_error) } -/// Validate conversation access with optional authentication -/// - If user is authenticated: check their access (owner, shared, or public) -/// - If user is not authenticated: only check if publicly shared -#[allow(dead_code)] -async fn validate_conversation_access_optional_auth( - state: &crate::state::AppState, - user: Option<&AuthenticatedUser>, - conversation_id: &str, - required_permission: SharePermission, -) -> Result<(), Response> { - validate_proxy_path_segment(conversation_id) - .map_err(|_| invalid_proxy_path_segment_response("conversation_id"))?; - - if let Some(user) = user { - // User is authenticated - check their access or public share - validate_user_or_public_conversation(state, user, conversation_id, required_permission) - .await - } else { - // User is not authenticated - only public share is allowed - state - .conversation_share_service - .get_public_access_by_conversation_id(conversation_id, required_permission) - .await - .map(|_| ()) - .map_err(map_share_error) - } -} - -#[allow(dead_code)] async fn validate_owner_conversation( state: &crate::state::AppState, user: &AuthenticatedUser, @@ -5122,7 +4969,6 @@ async fn validate_owner_conversation( }) } -#[allow(dead_code)] fn map_share_error(error: ConversationError) -> Response { let (status, message) = match error { ConversationError::NotFound => { @@ -5140,7 +4986,6 @@ fn map_share_error(error: ConversationError) -> Response { (status, Json(ErrorResponse { error: message })).into_response() } -#[allow(dead_code)] async fn fetch_conversation_from_proxy( state: &crate::state::AppState, conversation_id: &str, @@ -5203,7 +5048,6 @@ async fn fetch_conversation_from_proxy( Ok(conversation) } -#[allow(dead_code)] async fn validate_user_file( state: &crate::state::AppState, user: &AuthenticatedUser, @@ -5648,18 +5492,15 @@ async fn collect_stream_to_bytes( #[cfg(test)] mod tests { use super::{ - create_optional_auth_router, create_retired_stateful_router, decompress_if_encoded, - ensure_stream_usage_options, has_only_identity_content_encoding, - normalize_stateless_response_body, validate_proxy_path_segment, - validate_stateless_response_body, + create_read_only_stateful_router, decompress_if_encoded, ensure_stream_usage_options, + has_only_identity_content_encoding, normalize_stateless_response_body, + validate_proxy_path_segment, validate_stateless_response_body, }; - use axum::{body::Body, Router}; use bytes::Bytes; use flate2::{write::DeflateEncoder, write::GzEncoder, write::ZlibEncoder, Compression}; - use http::{HeaderMap, HeaderValue, Method, Request, StatusCode}; + use http::{HeaderMap, HeaderValue}; use serde_json::json; use std::io::Write; - use tower::ServiceExt; fn headers(content_encoding: &str) -> HeaderMap { let mut headers = HeaderMap::new(); @@ -5686,57 +5527,11 @@ mod tests { assert!(!has_only_identity_content_encoding(&gzip_headers)); } - async fn assert_retired_route(app: &Router, method: Method, path: &str) { - let response = app - .clone() - .oneshot( - Request::builder() - .method(method) - .uri(path) - .body(Body::empty()) - .expect("test request should be valid"), - ) - .await - .expect("router should not return an error"); - - assert_eq!( - response.status(), - StatusCode::GONE, - "unexpected route: {path}" - ); - assert_eq!( - response - .headers() - .get(http::header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()), - Some("no-store") - ); - } - - #[tokio::test] - async fn retired_stateful_router_merges_optional_reads_and_catches_descendants() { - // This is the same composition used by the application before the - // respective optional/session authentication layers are applied. - let app = create_optional_auth_router::<()>().merge(create_retired_stateful_router()); - - assert_retired_route(&app, Method::GET, "/v1/conversations/conv_legacy").await; - assert_retired_route(&app, Method::GET, "/v1/conversations/conv_legacy/items").await; - assert_retired_route(&app, Method::PATCH, "/v1/conversations/conv_legacy").await; - assert_retired_route( - &app, - Method::PATCH, - "/v1/conversations/conv_legacy/unknown-child", - ) - .await; - assert_retired_route(&app, Method::GET, "/v1/conversations/").await; - assert_retired_route(&app, Method::PATCH, "/v1/files/file_legacy/unknown-child").await; - assert_retired_route( - &app, - Method::GET, - "/v1/share-groups/group_legacy/unknown-child", - ) - .await; - assert_retired_route(&app, Method::GET, "/v1/shared-with-me/unknown-child").await; + #[test] + fn stage_one_stateful_router_has_non_overlapping_scoped_fallbacks() { + // Router construction catches overlapping nested and exact routes, + // including the explicit trailing-slash namespace reservations. + let _router = create_read_only_stateful_router(); } fn gzip_encode(input: &[u8]) -> Vec { @@ -5819,7 +5614,7 @@ mod tests { } #[test] - fn stateless_responses_rejects_every_cloud_stateful_feature() { + fn stateless_responses_rejects_only_cloud_state_linkage_fields() { let cases = [ ( json!({ "store": true }), @@ -5837,65 +5632,44 @@ mod tests { json!({ "background": true }), "The stateless Responses API does not support background.", ), - ( - json!({ "input": [{ "type": "function_call_output" }] }), - "The stateless Responses API does not support function continuation.", - ), - ( - json!({ "input": [{ "type": "mcp_approval_response" }] }), - "The stateless Responses API does not support MCP approval continuation.", - ), - ( - json!({ - "input": [{ - "content": [{ "type": "input_file", "file_id": "file_legacy" }] - }] - }), - "The stateless Responses API does not support input_file.", - ), - ( - json!({ "tools": [{ "type": "file_search" }] }), - "The stateless Responses API does not support file_search.", - ), - ( - json!({ "tools": [{ "type": "function" }] }), - "The stateless Responses API does not support function tools because they require continuation.", - ), - ( - json!({ "tools": [{ "type": "code_interpreter" }] }), - "The stateless Responses API does not support code_interpreter because it requires continuation.", - ), - ( - json!({ "tools": [{ "type": "computer" }] }), - "The stateless Responses API does not support computer because it requires continuation.", - ), - ( - json!({ "tools": [{ "type": "mcp" }] }), - "The stateless Responses API does not support MCP tools that require approval.", - ), - ( - json!({ - "tools": [{ - "type": "mcp", - "require_approval": { "never": { "tool_names": ["search"] } } - }] - }), - "The stateless Responses API does not support MCP tools that require approval.", - ), ]; for (body, expected) in cases { assert_eq!( validate_stateless_response_body(&body), Err(expected), - "stateful body should be rejected: {body}" + "state linkage field should be rejected: {body}" ); } + } - assert!(validate_stateless_response_body(&json!({ - "tools": [{ "type": "mcp", "require_approval": "never" }] - })) - .is_ok()); + #[test] + fn stateless_responses_forward_tool_and_input_shapes_to_cloud() { + for body in [ + json!({ "input": [{ "type": "function_call_output" }] }), + json!({ "input": [{ "type": "mcp_approval_response" }] }), + json!({ + "input": [{ + "content": [{ "type": "input_file", "file_id": "file_legacy" }] + }] + }), + json!({ "tools": [{ "type": "file_search" }] }), + json!({ "tools": [{ "type": "function" }] }), + json!({ "tools": [{ "type": "code_interpreter" }] }), + json!({ "tools": [{ "type": "computer" }] }), + json!({ "tools": [{ "type": "mcp" }] }), + json!({ + "tools": [{ + "type": "mcp", + "require_approval": { "never": { "tool_names": ["search"] } } + }] + }), + ] { + assert!( + validate_stateless_response_body(&body).is_ok(), + "tool/input shape should be forwarded to Cloud: {body}" + ); + } } #[test] diff --git a/crates/api/src/routes/mod.rs b/crates/api/src/routes/mod.rs index 83346f3f..cbd977db 100644 --- a/crates/api/src/routes/mod.rs +++ b/crates/api/src/routes/mod.rs @@ -124,15 +124,6 @@ pub fn create_router_with_cors(app_state: AppState, cors_config: config::CorsCon // Public subscription routes (webhook, no auth required) let public_subscription_routes = subscriptions::create_public_subscriptions_router(); - // Conversation read routes with optional authentication - // These routes work for both authenticated users and unauthenticated users - // (for accessing publicly shared conversations) - let optional_auth_routes = - api::create_optional_auth_router::().layer(from_fn_with_state( - auth_state.clone(), - crate::middleware::optional_auth_middleware, - )); - let dual_auth_state = crate::middleware::DualAuthState { auth_state: auth_state.clone(), agent_auth_state: crate::middleware::AgentAuthState { @@ -152,9 +143,7 @@ pub fn create_router_with_cors(app_state: AppState, cors_config: config::CorsCon subscription_state, ); - // Build the base router - // Note: optional_auth_routes must come BEFORE api_routes since they share paths - // but have different HTTP methods (optional auth for GET, required auth for POST/DELETE) + // Build the base router. let router = Router::new() .route("/health", get(health_check)) .merge(configs_routes) // Configs route (requires user auth) @@ -167,8 +156,7 @@ pub fn create_router_with_cors(app_state: AppState, cors_config: config::CorsCon .nest("/v1/agents", agent_routes) // Agent routes (requires user auth) .merge(verify_agent_key_route) // Agent key verification (no session auth) .nest("/v1/admin", admin_routes) - .merge(optional_auth_routes) // Conversation read routes (optional auth) - .merge(api_routes) // API routes: llm proxy, models proxy, conversations, share groups, files + .merge(api_routes) // API routes: proxy, temporary views, and disabled mutations .merge(attestation_routes) // Merge attestation routes (already have /v1 prefix) .with_state(app_state) // Add static file serving as fallback (must be last) diff --git a/crates/api/tests/README.md b/crates/api/tests/README.md index 5aeb28ca..bea61e56 100644 --- a/crates/api/tests/README.md +++ b/crates/api/tests/README.md @@ -1,20 +1,28 @@ # API Tests -These integration tests exercise Chat API's authentication, billing, proxy, and -stateless Responses boundaries. They use the `test` feature to enable the -mock-login endpoint and require a PostgreSQL test database. +These integration tests exercise Chat API's authentication, billing, proxy, +stateless Responses, and temporary Stage I read-only data views. They use the +`test` feature to enable the mock-login endpoint and require a PostgreSQL test +database. -## Stateless API coverage +## Stage I API coverage - `responses_permissions_tests` verifies that stateful Responses fields fail locally with a stable `400` response. -- `conversations_tests` and `files_tests` verify that the retired stateful API - surfaces return the same `410 Gone` migration response after their historical - authentication boundary, without reaching Cloud API or local state services. +- `responses_stateless_tests` verifies `store: false` forwarding, including + client-managed function replay. Tool and input-item shapes are forwarded to + Cloud for capability validation. +- `conversations_tests` verifies that owner-only Conversation GET views remain + available for export while sharing APIs and all disabled stateful operations + return `410 Gone`. +- `files_tests` verifies that existing File GET views remain available for + export while upload and delete return `410 Gone`. -The retired paths are intentionally absent from OpenAPI. They are still mounted -at their historical authentication boundaries so clients receive a clear -migration response instead of a proxy failure. +Temporary views require session authentication and ownership of the requested +Conversation or File. They and their migration responses use +`Cache-Control: no-store`. Sharing APIs, unsupported methods, and descendants +within the legacy Conversation, File, and sharing namespaces return the same +authenticated `410 Gone` migration response. ## Running tests diff --git a/crates/api/tests/conversations_tests.rs b/crates/api/tests/conversations_tests.rs index 26d052a6..56048c05 100644 --- a/crates/api/tests/conversations_tests.rs +++ b/crates/api/tests/conversations_tests.rs @@ -1,10 +1,14 @@ mod common; use api::routes::api::STATEFUL_API_RETIRED_MESSAGE; -use axum_test::TestResponse; -use common::{create_test_server, mock_login}; +use axum_test::{TestResponse, TestServer}; +use common::{create_test_server_and_db, mock_login, TestServerConfig}; use http::{HeaderName, HeaderValue, Method, StatusCode}; -use serde_json::Value; +use serde_json::{json, Value}; +use services::user::ports::UserRepository; +use uuid::Uuid; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; fn bearer(token: &str) -> (HeaderName, HeaderValue) { ( @@ -13,8 +17,7 @@ fn bearer(token: &str) -> (HeaderName, HeaderValue) { ) } -fn assert_retired(response: TestResponse) { - assert_eq!(response.status_code(), StatusCode::GONE); +fn assert_no_store(response: &TestResponse) { assert_eq!( response .headers() @@ -22,6 +25,11 @@ fn assert_retired(response: TestResponse) { .and_then(|value| value.to_str().ok()), Some("no-store") ); +} + +fn assert_retired_mutation(response: TestResponse) { + assert_eq!(response.status_code(), StatusCode::GONE); + assert_no_store(&response); let body: Value = response.json(); assert_eq!( body.get("error").and_then(Value::as_str), @@ -29,59 +37,238 @@ fn assert_retired(response: TestResponse) { ); } +async fn stage_one_fixture() -> (TestServer, MockServer, String, String) { + let upstream = MockServer::start().await; + let conversation_id = format!("conv_stage1_{}", Uuid::new_v4()); + + Mock::given(method("POST")) + .and(path("/conversations/batch")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": [{"id": conversation_id.clone(), "object": "conversation"}], + "missing_ids": [] + }))) + .mount(&upstream) + .await; + Mock::given(method("GET")) + .and(path(format!("/conversations/{conversation_id}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": conversation_id.clone(), + "object": "conversation", + "metadata": {"title": "temporary export view"} + }))) + .mount(&upstream) + .await; + Mock::given(method("GET")) + .and(path(format!("/conversations/{conversation_id}/items"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "object": "list", + "data": [], + "first_id": null, + "last_id": null, + "has_more": false + }))) + .mount(&upstream) + .await; + let (server, db) = create_test_server_and_db(TestServerConfig { + proxy_base_url: Some(upstream.uri()), + ..Default::default() + }) + .await; + let email = format!("stage-one-views-{}@example.com", Uuid::new_v4()); + let token = mock_login(&server, &email).await; + let user = db + .user_repository() + .get_user_by_email(&email) + .await + .expect("get user") + .expect("user exists"); + let client = db.pool().get().await.expect("db client"); + + client + .execute( + "INSERT INTO conversations (id, user_id) VALUES ($1, $2)", + &[&conversation_id, &user.id], + ) + .await + .expect("insert conversation"); + (server, upstream, token, conversation_id) +} + #[tokio::test] -async fn retired_conversation_routes_return_the_migration_response() { - let server = create_test_server().await; - let token = mock_login(&server, "retired-conversations@example.com").await; +async fn stage_one_owner_conversation_views_remain_readable() { + let (server, _upstream, token, conversation_id) = stage_one_fixture().await; + let auth = bearer(&token); - // Publicly shared conversation reads remain optional-auth, but never expose - // the legacy resource now that the API is retired. - assert_retired(server.get("/v1/conversations/conv_legacy").await); - assert_retired(server.get("/v1/conversations/conv_legacy/items").await); + let response = server + .get("/v1/conversations") + .add_header(auth.0.clone(), auth.1.clone()) + .await; + assert_eq!(response.status_code(), StatusCode::OK); + assert_no_store(&response); + let conversations: Vec = response.json(); + assert_eq!(conversations[0]["id"], conversation_id); - // Unknown descendants remain protected by the original session boundary. - assert_eq!( - server - .get("/v1/conversations/conv_legacy/unknown-child") - .await - .status_code(), - StatusCode::UNAUTHORIZED - ); + for path in [ + format!("/v1/conversations/{conversation_id}"), + format!("/v1/conversations/{conversation_id}/items"), + ] { + let response = server + .get(&path) + .add_header(auth.0.clone(), auth.1.clone()) + .await; + assert_eq!(response.status_code(), StatusCode::OK, "GET {path}"); + assert_no_store(&response); + } - // Mutating conversation routes retain the old session-auth boundary. - assert_eq!( - server.post("/v1/conversations").await.status_code(), - StatusCode::UNAUTHORIZED - ); + let unauthenticated = server + .get(&format!("/v1/conversations/{conversation_id}")) + .await; + assert_eq!(unauthenticated.status_code(), StatusCode::UNAUTHORIZED); + assert_no_store(&unauthenticated); +} +#[tokio::test] +async fn conversation_detail_and_items_are_owner_only() { + let (server, _upstream, _owner_token, conversation_id) = stage_one_fixture().await; + let other_email = format!("stage-one-non-owner-{}@example.com", Uuid::new_v4()); + let other_token = mock_login(&server, &other_email).await; + let auth = bearer(&other_token); + + for path in [ + format!("/v1/conversations/{conversation_id}"), + format!("/v1/conversations/{conversation_id}/items"), + ] { + let response = server + .get(&path) + .add_header(auth.0.clone(), auth.1.clone()) + .await; + assert_eq!(response.status_code(), StatusCode::NOT_FOUND, "GET {path}"); + assert_no_store(&response); + } +} + +#[tokio::test] +async fn anonymous_conversation_reads_require_session_auth() { + let (server, _upstream, _token, conversation_id) = stage_one_fixture().await; + + for path in [ + format!("/v1/conversations/{conversation_id}"), + format!("/v1/conversations/{conversation_id}/items"), + format!("/v1/conversations/{conversation_id}/unknown-child"), + format!("/v1/conversations/{conversation_id}/shares"), + "/v1/conversations/".to_string(), + "/v1/share-groups".to_string(), + "/v1/shared-with-me".to_string(), + ] { + let response = server.get(&path).await; + assert_eq!( + response.status_code(), + StatusCode::UNAUTHORIZED, + "GET {path}" + ); + assert_no_store(&response); + } +} + +#[tokio::test] +async fn stage_one_stateful_conversation_and_sharing_surfaces_are_gone() { + let (server, _upstream, token, conversation_id) = stage_one_fixture().await; let auth = bearer(&token); + for (method, path) in [ - // Every method that was previously supported by the session-auth API. - (Method::POST, "/v1/conversations"), - (Method::GET, "/v1/conversations"), - (Method::POST, "/v1/conversations/conv_legacy"), - (Method::DELETE, "/v1/conversations/conv_legacy"), - (Method::POST, "/v1/conversations/conv_legacy/items"), - (Method::POST, "/v1/conversations/conv_legacy/shares"), - (Method::GET, "/v1/conversations/conv_legacy/shares"), + (Method::POST, "/v1/conversations".to_string()), + (Method::POST, format!("/v1/conversations/{conversation_id}")), + ( + Method::DELETE, + format!("/v1/conversations/{conversation_id}"), + ), + ( + Method::POST, + format!("/v1/conversations/{conversation_id}/items"), + ), + ( + Method::POST, + format!("/v1/conversations/{conversation_id}/shares"), + ), + ( + Method::GET, + format!("/v1/conversations/{conversation_id}/shares"), + ), + ( + Method::DELETE, + format!( + "/v1/conversations/{conversation_id}/shares/{}", + Uuid::new_v4() + ), + ), + ( + Method::POST, + format!("/v1/conversations/{conversation_id}/pin"), + ), + ( + Method::DELETE, + format!("/v1/conversations/{conversation_id}/pin"), + ), + ( + Method::POST, + format!("/v1/conversations/{conversation_id}/archive"), + ), ( Method::DELETE, - "/v1/conversations/conv_legacy/shares/share_legacy", - ), - (Method::POST, "/v1/conversations/conv_legacy/pin"), - (Method::DELETE, "/v1/conversations/conv_legacy/pin"), - (Method::POST, "/v1/conversations/conv_legacy/archive"), - (Method::DELETE, "/v1/conversations/conv_legacy/archive"), - (Method::POST, "/v1/conversations/conv_legacy/clone"), - (Method::GET, "/v1/conversations/"), - // Exact known routes and unknown descendants also use the migration - // response for methods that were never part of the old contract. - (Method::PATCH, "/v1/conversations/conv_legacy"), - (Method::PATCH, "/v1/conversations/conv_legacy/unknown-child"), + format!("/v1/conversations/{conversation_id}/archive"), + ), + ( + Method::POST, + format!("/v1/conversations/{conversation_id}/clone"), + ), + (Method::POST, "/v1/share-groups".to_string()), + (Method::GET, "/v1/share-groups".to_string()), + ( + Method::PATCH, + format!("/v1/share-groups/{}", Uuid::new_v4()), + ), + ( + Method::DELETE, + format!("/v1/share-groups/{}", Uuid::new_v4()), + ), + // Unsupported methods on a retained read view and unlisted legacy + // descendants remain within the authenticated migration namespace. + ( + Method::PATCH, + format!("/v1/conversations/{conversation_id}"), + ), + ( + Method::GET, + format!("/v1/conversations/{conversation_id}/unknown-child"), + ), + ( + Method::GET, + format!( + "/v1/conversations/{conversation_id}/shares/{}", + Uuid::new_v4() + ), + ), + ( + Method::GET, + format!("/v1/share-groups/{}/unknown-child", Uuid::new_v4()), + ), + (Method::POST, "/v1/shared-with-me".to_string()), + (Method::GET, "/v1/shared-with-me".to_string()), + (Method::GET, "/v1/shared-with-me/unknown-child".to_string()), + // Axum nesting does not cover the trailing-slash prefix, so those + // exact legacy namespace paths are explicitly reserved too. + (Method::GET, "/v1/conversations/".to_string()), + (Method::GET, "/v1/share-groups/".to_string()), + (Method::GET, "/v1/shared-with-me/".to_string()), + // `batch` is used only for Chat API's internal Cloud list request and + // is never a public view or a conversation ID. + (Method::GET, "/v1/conversations/batch".to_string()), + (Method::POST, "/v1/conversations/batch".to_string()), + (Method::PATCH, "/v1/conversations/batch".to_string()), ] { - assert_retired( + assert_retired_mutation( server - .method(method, path) + .method(method, &path) .add_header(auth.0.clone(), auth.1.clone()) .await, ); diff --git a/crates/api/tests/files_tests.rs b/crates/api/tests/files_tests.rs index 39632af9..3ca2d48b 100644 --- a/crates/api/tests/files_tests.rs +++ b/crates/api/tests/files_tests.rs @@ -1,10 +1,14 @@ mod common; use api::routes::api::STATEFUL_API_RETIRED_MESSAGE; -use axum_test::TestResponse; -use common::{create_test_server, mock_login}; +use axum_test::{TestResponse, TestServer}; +use common::{create_test_server_and_db, mock_login, TestServerConfig}; use http::{HeaderName, HeaderValue, Method, StatusCode}; use serde_json::Value; +use services::user::ports::UserRepository; +use uuid::Uuid; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; fn bearer(token: &str) -> (HeaderName, HeaderValue) { ( @@ -13,8 +17,7 @@ fn bearer(token: &str) -> (HeaderName, HeaderValue) { ) } -fn assert_retired(response: TestResponse) { - assert_eq!(response.status_code(), StatusCode::GONE); +fn assert_no_store(response: &TestResponse) { assert_eq!( response .headers() @@ -22,6 +25,11 @@ fn assert_retired(response: TestResponse) { .and_then(|value| value.to_str().ok()), Some("no-store") ); +} + +fn assert_retired_mutation(response: TestResponse) { + assert_eq!(response.status_code(), StatusCode::GONE); + assert_no_store(&response); let body: Value = response.json(); assert_eq!( body.get("error").and_then(Value::as_str), @@ -29,51 +37,113 @@ fn assert_retired(response: TestResponse) { ); } +async fn stage_one_fixture() -> (TestServer, MockServer, String, String) { + let upstream = MockServer::start().await; + let file_id = format!("file_stage1_{}", Uuid::new_v4()); + Mock::given(method("GET")) + .and(path(format!("/files/{file_id}/content"))) + .respond_with(ResponseTemplate::new(200).set_body_string("temporary file content")) + .mount(&upstream) + .await; + + let (server, db) = create_test_server_and_db(TestServerConfig { + proxy_base_url: Some(upstream.uri()), + ..Default::default() + }) + .await; + let email = format!("stage-one-files-{}@example.com", Uuid::new_v4()); + let token = mock_login(&server, &email).await; + let user = db + .user_repository() + .get_user_by_email(&email) + .await + .expect("get user") + .expect("user exists"); + db.pool() + .get() + .await + .expect("db client") + .execute( + "INSERT INTO files (id, user_id, bytes, file_created_at, filename, purpose) + VALUES ($1, $2, 25, 123, 'temporary-export.txt', 'assistants')", + &[&file_id, &user.id], + ) + .await + .expect("insert file"); + + (server, upstream, token, file_id) +} + #[tokio::test] -async fn retired_file_and_sharing_routes_return_the_migration_response() { - let server = create_test_server().await; - let token = mock_login(&server, "retired-files@example.com").await; +async fn stage_one_file_views_remain_readable() { + let (server, _upstream, token, file_id) = stage_one_fixture().await; let auth = bearer(&token); - // Files and sharing routes remain session-auth only, even though they no - // longer call their stateful services after authentication succeeds. + let response = server + .get("/v1/files") + .add_header(auth.0.clone(), auth.1.clone()) + .await; + assert_eq!(response.status_code(), StatusCode::OK); + assert_no_store(&response); + let list: Value = response.json(); + assert_eq!(list["data"][0]["id"], file_id); + + let response = server + .get(&format!("/v1/files/{file_id}")) + .add_header(auth.0.clone(), auth.1.clone()) + .await; + assert_eq!(response.status_code(), StatusCode::OK); + assert_no_store(&response); + let file: Value = response.json(); + assert_eq!(file["id"], file_id); + + let response = server + .get(&format!("/v1/files/{file_id}/content")) + .add_header(auth.0.clone(), auth.1.clone()) + .await; + assert_eq!(response.status_code(), StatusCode::OK); + assert_no_store(&response); + assert_eq!(response.text(), "temporary file content"); + + let unauthenticated = server.get("/v1/files").await; + assert_eq!(unauthenticated.status_code(), StatusCode::UNAUTHORIZED); + assert_no_store(&unauthenticated); + + let unauthenticated_unknown = server + .get(&format!("/v1/files/{file_id}/unknown-child")) + .await; assert_eq!( - server.get("/v1/files").await.status_code(), + unauthenticated_unknown.status_code(), StatusCode::UNAUTHORIZED ); + assert_no_store(&unauthenticated_unknown); + + let unauthenticated_trailing_slash = server.get("/v1/files/").await; assert_eq!( - server - .get("/v1/files/file_legacy/unknown-child") - .await - .status_code(), + unauthenticated_trailing_slash.status_code(), StatusCode::UNAUTHORIZED ); + assert_no_store(&unauthenticated_trailing_slash); +} + +#[tokio::test] +async fn stage_one_file_mutations_and_fallbacks_are_gone() { + let (server, _upstream, token, file_id) = stage_one_fixture().await; + let auth = bearer(&token); for (method, path) in [ - // Every method that was previously supported by the session-auth API. - (Method::POST, "/v1/files"), - (Method::GET, "/v1/files"), - (Method::GET, "/v1/files/file_legacy"), - (Method::DELETE, "/v1/files/file_legacy"), - (Method::GET, "/v1/files/file_legacy/content"), - (Method::GET, "/v1/files/"), - (Method::POST, "/v1/share-groups"), - (Method::GET, "/v1/share-groups"), - (Method::GET, "/v1/share-groups/"), - (Method::PATCH, "/v1/share-groups/group_legacy"), - (Method::DELETE, "/v1/share-groups/group_legacy"), - (Method::GET, "/v1/shared-with-me"), - // Exact known routes and unknown descendants also use the migration - // response for methods that were never part of the old contract. - (Method::PATCH, "/v1/files/file_legacy"), - (Method::PATCH, "/v1/files/file_legacy/unknown-child"), - (Method::PATCH, "/v1/share-groups/group_legacy/unknown-child"), - (Method::POST, "/v1/shared-with-me"), - (Method::GET, "/v1/shared-with-me/unknown-child"), + (Method::POST, "/v1/files".to_string()), + (Method::DELETE, format!("/v1/files/{file_id}")), + // Unsupported methods on retained views and unlisted descendants must + // remain inside the authenticated migration namespace. + (Method::PATCH, format!("/v1/files/{file_id}")), + (Method::POST, format!("/v1/files/{file_id}/content")), + (Method::GET, format!("/v1/files/{file_id}/unknown-child")), + (Method::GET, "/v1/files/".to_string()), ] { - assert_retired( + assert_retired_mutation( server - .method(method, path) + .method(method, &path) .add_header(auth.0.clone(), auth.1.clone()) .await, ); diff --git a/crates/api/tests/responses_stateless_tests.rs b/crates/api/tests/responses_stateless_tests.rs index 207769a0..06b0b691 100644 --- a/crates/api/tests/responses_stateless_tests.rs +++ b/crates/api/tests/responses_stateless_tests.rs @@ -135,6 +135,175 @@ async fn responses_forwards_store_false_without_author_metadata() { ); } +#[tokio::test] +async fn responses_forwards_client_managed_function_replay() { + let mock_upstream = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/responses")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "resp_function_replay", + "object": "response", + "model": "gpt-test", + "output": [], + "usage": { + "input_tokens": 1, + "output_tokens": 1, + "total_tokens": 2 + } + }))) + .expect(1) + .mount(&mock_upstream) + .await; + + let (server, db) = create_test_server_and_db(TestServerConfig { + proxy_base_url: Some(mock_upstream.uri()), + ..Default::default() + }) + .await; + + set_subscription_plans( + &server, + json!({ + "basic": { + "providers": { "stripe": { "price_id": "price_test_basic" } }, + "monthly_credits": { "max": 1_000_000_000 } + } + }), + ) + .await; + + let email = format!("stateless-function-replay-{}@example.com", Uuid::new_v4()); + let token = mock_login(&server, &email).await; + insert_test_subscription(&server, &db, &email, false).await; + + let request = json!({ + "model": "gpt-test", + "input": [ + { "role": "user", "content": "What is the weather in Shanghai?" }, + { + "type": "function_call", + "call_id": "call_weather", + "name": "get_weather", + "arguments": "{\"location\":\"Shanghai\"}" + }, + { + "type": "function_call_output", + "call_id": "call_weather", + "output": "{\"temperature_c\":22}" + } + ], + "tools": [{ + "type": "function", + "name": "get_weather", + "parameters": { + "type": "object", + "properties": { "location": { "type": "string" } }, + "required": ["location"] + } + }] + }); + + let auth = bearer(&token); + let response = server + .post("/v1/responses") + .add_header(auth.0, auth.1) + .json(&request) + .await; + + assert_eq!(response.status_code(), StatusCode::OK); + assert_eq!( + response + .headers() + .get(http::header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store") + ); + + let requests = mock_upstream + .received_requests() + .await + .expect("mock upstream should record the request"); + assert_eq!(requests.len(), 1); + let forwarded: serde_json::Value = + serde_json::from_slice(&requests[0].body).expect("forwarded body should be JSON"); + + assert_eq!(forwarded.get("store"), Some(&json!(false))); + assert_eq!(forwarded.get("tools"), request.get("tools")); + assert_eq!(forwarded.get("input"), request.get("input")); +} + +#[tokio::test] +async fn responses_forwards_builtin_tool_shapes_to_cloud_for_validation() { + let mock_upstream = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/responses")) + .respond_with( + ResponseTemplate::new(400) + .insert_header("content-type", "application/json") + .set_body_json(json!({ "error": "unsupported tool from Cloud API" })), + ) + .expect(1) + .mount(&mock_upstream) + .await; + + let (server, db) = create_test_server_and_db(TestServerConfig { + proxy_base_url: Some(mock_upstream.uri()), + ..Default::default() + }) + .await; + + set_subscription_plans( + &server, + json!({ + "basic": { + "providers": { "stripe": { "price_id": "price_test_basic" } }, + "monthly_credits": { "max": 1_000_000_000 } + } + }), + ) + .await; + + let email = format!("stateless-builtin-forward-{}@example.com", Uuid::new_v4()); + let token = mock_login(&server, &email).await; + insert_test_subscription(&server, &db, &email, false).await; + + let request = json!({ + "model": "gpt-test", + "input": "hello", + "tools": [{ "type": "code_interpreter" }] + }); + + let auth = bearer(&token); + let response = server + .post("/v1/responses") + .add_header(auth.0, auth.1) + .json(&request) + .await; + + assert_eq!(response.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + response + .headers() + .get(http::header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store") + ); + assert_eq!( + response.json::(), + json!({ "error": "unsupported tool from Cloud API" }) + ); + + let requests = mock_upstream + .received_requests() + .await + .expect("mock upstream should record the request"); + assert_eq!(requests.len(), 1); + let forwarded: serde_json::Value = + serde_json::from_slice(&requests[0].body).expect("forwarded body should be JSON"); + assert_eq!(forwarded.get("tools"), request.get("tools")); + assert_eq!(forwarded.get("store"), Some(&json!(false))); +} + #[tokio::test] async fn responses_rejects_encoded_or_non_object_bodies_without_forwarding() { let mock_upstream = MockServer::start().await; From 09902665f46a91f28c0253eddbe2e492755a2765 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:54:30 +0800 Subject: [PATCH 5/5] docs(api): clarify Stage I account deletion exception --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 37c0a37c..c0581443 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ crates/ - **Repository Pattern**: Database access through trait-based repositories (`PostgresUserRepository`, etc.) - **Service Layer**: Business logic in `services` crate, injected into `AppState` - **NEAR AI Cloud API Proxy**: OpenAI-compatible inference routes forward to NEAR AI Cloud API with auth; Responses requests are stateless -- **Temporary Read Views**: Owner-only Conversation and File GET endpoints remain available for the Stage I migration/export window. Sharing surfaces and all write operations return `410 Gone`. +- **Temporary Read Views**: Owner-only Conversation and File GET endpoints remain available for the Stage I migration/export window. Ordinary Conversation, File, and sharing writes return `410 Gone`; the existing `DELETE /v1/users/me` account-deletion flow remains available. - **Patroni Support**: Optional cluster discovery for HA PostgreSQL via `DATABASE_PRIMARY_APP_ID` ### Request Flow @@ -195,7 +195,11 @@ OpenAPI docs available at `/docs`. - `/v1/users/*` - User management - `/v1/admin/*` - Admin operations -**Stage I migration**: owner-only Conversation and File GET views remain temporarily available for authenticated private-chat data export. Sharing APIs, all established write operations (create/update/delete, item creation, upload, pin/archive, clone, and share-group mutation), plus unsupported methods and descendants within those legacy namespaces, return `410 Gone` with `Cache-Control: no-store` after session authentication. These views will be removed in Stage III. `/v1/responses` is stateless: requests are normalized to `store: false`, and response/conversation linkage fields such as `conversation`, `previous_response_id`, and `background: true` are rejected. Clients may use custom function tools and replay their own function results; Cloud validates tool and input shapes. +**Stage I migration**: owner-only Conversation and File GET views remain temporarily available for authenticated private-chat data export. Ordinary Conversation, File, and sharing state writes (create/update/delete, item creation, upload, pin/archive, clone, and share-group mutation), plus unsupported methods and descendants within those legacy namespaces, return `410 Gone` with `Cache-Control: no-store` after session authentication. These views will be removed in Stage III. + +**Account-deletion exception**: `DELETE /v1/users/me` remains available. Its existing asynchronous worker continues Cloud Conversation/File cleanup through Cloud API's retained, API-key/workspace-scoped resource DELETE endpoints before it performs local finalization; this flow is outside the retired session-proxy write surface. + +`/v1/responses` is stateless: requests are normalized to `store: false`, and response/conversation linkage fields such as `conversation`, `previous_response_id`, and `background: true` are rejected. Clients may use custom function tools and replay their own function results; Cloud validates tool and input shapes. **Note**: OpenAI-compatible inference requests are proxied to **NEAR AI Cloud API**. Set `OPENAI_BASE_URL` to your NEAR AI Cloud API endpoint.