571cb00fe4
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
- api-server 新增 POST /api/external/v1/editor/scenes/generations,复用 editor:image-generate scope、幂等键和站内场景生图队列 - editor_project.rs 抽取站内与外部共用的场景生图 payload 构造函数,站内 handler 改为调用共享函数 - 同步更新 external v1 OpenAPI 契约与 MCP 派生排除标记,登记 docs/README.md 索引 - AGC 客户端美术包背景阶段改走新场景路由,stylePreset 固定 custom + customStyle 保留现有风格文案 - external_generation_state 快照与 recovery_scan 账本白名单支持新场景路由 - direct_runtime 背景资源识别同时兼容新场景路由与旧通用路由,新增旧路由回放测试 - 更新 genarrative-external-editor-api skill 参考文档 - 新增主规范、里程碑规范与实施计划三份 SDD 文档
490 lines
18 KiB
Rust
490 lines
18 KiB
Rust
use axum::{
|
|
Extension, Router,
|
|
extract::DefaultBodyLimit,
|
|
middleware,
|
|
routing::{get, patch, post},
|
|
};
|
|
|
|
use crate::{
|
|
editor_project::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES,
|
|
external_api_auth::{require_external_api_key, require_external_mcp_api_key},
|
|
external_assets_api::{
|
|
confirm_external_asset_object, create_external_direct_upload_ticket,
|
|
get_external_asset_read_url,
|
|
},
|
|
external_editor_api::{
|
|
create_external_editor_asset, create_external_editor_asset_folder,
|
|
create_external_editor_project, create_external_editor_project_resource,
|
|
delete_external_editor_asset, delete_external_editor_asset_folder,
|
|
delete_external_editor_project, edit_external_editor_image,
|
|
extract_external_editor_ui_design_assets, generate_external_editor_background_music,
|
|
generate_external_editor_character_animation, generate_external_editor_icon_spritesheet,
|
|
generate_external_editor_image, generate_external_editor_scene,
|
|
generate_external_editor_sound_effect, generate_external_editor_video,
|
|
get_external_editor_asset_library, get_external_editor_generation_job,
|
|
get_external_editor_project, list_external_editor_projects,
|
|
load_recent_external_editor_project, openapi_json, remove_external_editor_image_background,
|
|
rename_external_editor_project, save_external_editor_canvas, update_external_editor_asset,
|
|
update_external_editor_asset_folder,
|
|
},
|
|
external_mcp,
|
|
external_skill_api::{
|
|
download_external_skill_archive, get_external_agent_integration_manifest,
|
|
get_external_skill_entry,
|
|
},
|
|
state::AppState,
|
|
};
|
|
|
|
pub fn router(state: AppState) -> Router<AppState> {
|
|
let mcp_router = Router::new()
|
|
.nest_service("/api/external/v1/mcp", external_mcp::service())
|
|
.layer(Extension(state.clone()))
|
|
.route_layer(middleware::from_fn_with_state(
|
|
state.clone(),
|
|
require_external_mcp_api_key,
|
|
));
|
|
|
|
let auth = middleware::from_fn_with_state(state, require_external_api_key);
|
|
let protected_routes = [
|
|
(
|
|
"/api/external/v1/assets/direct-upload-tickets",
|
|
post(create_external_direct_upload_ticket),
|
|
),
|
|
(
|
|
"/api/external/v1/assets/objects/confirm",
|
|
post(confirm_external_asset_object),
|
|
),
|
|
(
|
|
"/api/external/v1/assets/read-url",
|
|
get(get_external_asset_read_url),
|
|
),
|
|
(
|
|
"/api/external/v1/editor/projects",
|
|
get(list_external_editor_projects).post(create_external_editor_project),
|
|
),
|
|
(
|
|
"/api/external/v1/editor/projects/recent",
|
|
get(load_recent_external_editor_project),
|
|
),
|
|
(
|
|
"/api/external/v1/editor/projects/{project_id}",
|
|
get(get_external_editor_project).delete(delete_external_editor_project),
|
|
),
|
|
(
|
|
"/api/external/v1/editor/projects/{project_id}/metadata",
|
|
patch(rename_external_editor_project),
|
|
),
|
|
(
|
|
"/api/external/v1/editor/projects/{project_id}/canvas",
|
|
patch(save_external_editor_canvas)
|
|
.layer(DefaultBodyLimit::max(EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES)),
|
|
),
|
|
(
|
|
"/api/external/v1/editor/projects/{project_id}/resources",
|
|
post(create_external_editor_project_resource),
|
|
),
|
|
(
|
|
"/api/external/v1/editor/assets/library",
|
|
get(get_external_editor_asset_library),
|
|
),
|
|
(
|
|
"/api/external/v1/editor/assets/folders",
|
|
post(create_external_editor_asset_folder),
|
|
),
|
|
(
|
|
"/api/external/v1/editor/assets/folders/{folder_id}",
|
|
patch(update_external_editor_asset_folder).delete(delete_external_editor_asset_folder),
|
|
),
|
|
(
|
|
"/api/external/v1/editor/assets",
|
|
post(create_external_editor_asset),
|
|
),
|
|
(
|
|
"/api/external/v1/editor/assets/{asset_id}",
|
|
patch(update_external_editor_asset).delete(delete_external_editor_asset),
|
|
),
|
|
(
|
|
"/api/external/v1/generations/{operation_id}",
|
|
get(get_external_editor_generation_job),
|
|
),
|
|
(
|
|
"/api/external/v1/editor/images/generations",
|
|
post(generate_external_editor_image),
|
|
),
|
|
(
|
|
"/api/external/v1/editor/scenes/generations",
|
|
post(generate_external_editor_scene),
|
|
),
|
|
(
|
|
"/api/external/v1/editor/images/edits",
|
|
post(edit_external_editor_image),
|
|
),
|
|
(
|
|
"/api/external/v1/editor/images/background-removals",
|
|
post(remove_external_editor_image_background),
|
|
),
|
|
(
|
|
"/api/external/v1/editor/icon-spritesheets/generations",
|
|
post(generate_external_editor_icon_spritesheet),
|
|
),
|
|
(
|
|
"/api/external/v1/editor/ui-designs/assets/extractions",
|
|
post(extract_external_editor_ui_design_assets),
|
|
),
|
|
(
|
|
"/api/external/v1/editor/character-animations/generations",
|
|
post(generate_external_editor_character_animation),
|
|
),
|
|
(
|
|
"/api/external/v1/editor/videos/generations",
|
|
post(generate_external_editor_video),
|
|
),
|
|
(
|
|
"/api/external/v1/editor/audios/sound-effects/generations",
|
|
post(generate_external_editor_sound_effect),
|
|
),
|
|
(
|
|
"/api/external/v1/editor/audios/background-music/generations",
|
|
post(generate_external_editor_background_music),
|
|
),
|
|
];
|
|
// 在方法层统一鉴权,未支持的方法仍由 Axum 返回 405。
|
|
let protected_router = protected_routes
|
|
.into_iter()
|
|
.fold(Router::new(), |router, (path, methods)| {
|
|
router.route(path, methods.route_layer(auth.clone()))
|
|
});
|
|
|
|
Router::new()
|
|
.route("/api/external/v1/openapi.json", get(openapi_json))
|
|
.route(
|
|
"/api/external/v1/agent-integration.json",
|
|
get(get_external_agent_integration_manifest),
|
|
)
|
|
.route(
|
|
"/api/external/v1/skill/SKILL.md",
|
|
get(get_external_skill_entry),
|
|
)
|
|
.route(
|
|
"/api/external/v1/skill.zip",
|
|
get(download_external_skill_archive),
|
|
)
|
|
.merge(protected_router)
|
|
.merge(mcp_router)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod route_contract_tests {
|
|
use std::sync::Arc;
|
|
|
|
use axum::{
|
|
body::Body,
|
|
http::{Request, StatusCode, header},
|
|
middleware,
|
|
};
|
|
use futures_util::future::BoxFuture;
|
|
use http_body_util::BodyExt;
|
|
use spacetime_client::{
|
|
ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyRecord, SpacetimeClientError,
|
|
};
|
|
use tower::ServiceExt;
|
|
|
|
use super::router;
|
|
use crate::{
|
|
config::AppConfig,
|
|
request_context::attach_request_context,
|
|
state::{AppState, ExternalApiAuthState, external_api_auth::ExternalApiKeyAuthenticator},
|
|
};
|
|
|
|
const VALID_KEY: &str = "tnr_sk_route-body-limit-fixture";
|
|
|
|
struct FixtureKeyAuthenticator;
|
|
|
|
impl ExternalApiKeyAuthenticator for FixtureKeyAuthenticator {
|
|
fn authenticate_external_api_key(
|
|
&self,
|
|
input: ExternalApiKeyAuthenticateRecordInput,
|
|
) -> BoxFuture<'_, Result<ExternalApiKeyRecord, SpacetimeClientError>> {
|
|
Box::pin(async move {
|
|
if input.key_hash != crate::external_api_keys::hash_external_api_key(VALID_KEY) {
|
|
return Err(SpacetimeClientError::Procedure(
|
|
"API Key 不存在".to_string(),
|
|
));
|
|
}
|
|
Ok(ExternalApiKeyRecord {
|
|
key_id: "key-route-fixture".to_string(),
|
|
owner_user_id: "owner-route-fixture".to_string(),
|
|
name: "route fixture".to_string(),
|
|
key_prefix: "tnr_sk_route".to_string(),
|
|
scopes: Vec::new(),
|
|
created_at: "2026-09-18T00:00:00Z".to_string(),
|
|
last_used_at: None,
|
|
revoked_at: None,
|
|
updated_at: "2026-09-18T00:00:00Z".to_string(),
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
const PROTECTED_ROUTES: &[(&str, &[&str])] = &[
|
|
("/api/external/v1/assets/direct-upload-tickets", &["POST"]),
|
|
("/api/external/v1/assets/objects/confirm", &["POST"]),
|
|
("/api/external/v1/assets/read-url", &["GET"]),
|
|
("/api/external/v1/editor/projects", &["GET", "POST"]),
|
|
("/api/external/v1/editor/projects/recent", &["GET"]),
|
|
(
|
|
"/api/external/v1/editor/projects/{project_id}",
|
|
&["GET", "DELETE"],
|
|
),
|
|
(
|
|
"/api/external/v1/editor/projects/{project_id}/metadata",
|
|
&["PATCH"],
|
|
),
|
|
(
|
|
"/api/external/v1/editor/projects/{project_id}/canvas",
|
|
&["PATCH"],
|
|
),
|
|
(
|
|
"/api/external/v1/editor/projects/{project_id}/resources",
|
|
&["POST"],
|
|
),
|
|
("/api/external/v1/editor/assets/library", &["GET"]),
|
|
("/api/external/v1/editor/assets/folders", &["POST"]),
|
|
(
|
|
"/api/external/v1/editor/assets/folders/{folder_id}",
|
|
&["PATCH", "DELETE"],
|
|
),
|
|
("/api/external/v1/editor/assets", &["POST"]),
|
|
(
|
|
"/api/external/v1/editor/assets/{asset_id}",
|
|
&["PATCH", "DELETE"],
|
|
),
|
|
("/api/external/v1/generations/{operation_id}", &["GET"]),
|
|
("/api/external/v1/editor/images/generations", &["POST"]),
|
|
("/api/external/v1/editor/scenes/generations", &["POST"]),
|
|
("/api/external/v1/editor/images/edits", &["POST"]),
|
|
(
|
|
"/api/external/v1/editor/images/background-removals",
|
|
&["POST"],
|
|
),
|
|
(
|
|
"/api/external/v1/editor/icon-spritesheets/generations",
|
|
&["POST"],
|
|
),
|
|
(
|
|
"/api/external/v1/editor/ui-designs/assets/extractions",
|
|
&["POST"],
|
|
),
|
|
(
|
|
"/api/external/v1/editor/character-animations/generations",
|
|
&["POST"],
|
|
),
|
|
("/api/external/v1/editor/videos/generations", &["POST"]),
|
|
(
|
|
"/api/external/v1/editor/audios/sound-effects/generations",
|
|
&["POST"],
|
|
),
|
|
(
|
|
"/api/external/v1/editor/audios/background-music/generations",
|
|
&["POST"],
|
|
),
|
|
];
|
|
|
|
fn app() -> axum::Router {
|
|
let state = AppState::new(AppConfig::default())
|
|
.expect("state should build")
|
|
.with_external_api_auth_state(ExternalApiAuthState::new(Arc::new(
|
|
FixtureKeyAuthenticator,
|
|
)));
|
|
router(state.clone())
|
|
.layer(middleware::from_fn(attach_request_context))
|
|
.with_state(state)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn protected_route_matrix_keeps_auth_methods_and_head_behavior() {
|
|
let app = app();
|
|
for (template, supported) in PROTECTED_ROUTES {
|
|
let path = template
|
|
.split('/')
|
|
.map(|segment| {
|
|
if segment.starts_with('{') {
|
|
"fixture"
|
|
} else {
|
|
segment
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("/");
|
|
let mut allowed = supported.to_vec();
|
|
if allowed.contains(&"GET") {
|
|
allowed.push("HEAD");
|
|
}
|
|
allowed.sort_unstable();
|
|
for credential in [None, Some("Bearer invalid-token")] {
|
|
for method in [
|
|
"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE",
|
|
] {
|
|
let mut request = Request::builder().method(method).uri(&path);
|
|
if let Some(credential) = credential {
|
|
request = request.header(header::AUTHORIZATION, credential);
|
|
}
|
|
let response = app
|
|
.clone()
|
|
.oneshot(request.body(Body::empty()).expect("request should build"))
|
|
.await
|
|
.expect("request should complete");
|
|
let expected = if allowed.contains(&method) {
|
|
StatusCode::UNAUTHORIZED
|
|
} else {
|
|
StatusCode::METHOD_NOT_ALLOWED
|
|
};
|
|
assert_eq!(
|
|
response.status(),
|
|
expected,
|
|
"{method} {path}, credential={credential:?}"
|
|
);
|
|
if expected == StatusCode::METHOD_NOT_ALLOWED {
|
|
let mut actual = response.headers()[header::ALLOW]
|
|
.to_str()
|
|
.expect("Allow should be ASCII")
|
|
.split(',')
|
|
.map(str::trim)
|
|
.collect::<Vec<_>>();
|
|
actual.sort_unstable();
|
|
assert_eq!(actual, allowed, "Allow for {method} {path}");
|
|
}
|
|
if method == "HEAD" {
|
|
assert!(
|
|
response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("HEAD body should collect")
|
|
.to_bytes()
|
|
.is_empty(),
|
|
"HEAD {path} must not return a body"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_paths_remain_not_found_before_authentication() {
|
|
for credential in [None, Some("Bearer invalid-token")] {
|
|
let mut request = Request::builder().uri("/api/external/v1/missing/unknown/route");
|
|
if let Some(credential) = credential {
|
|
request = request.header(header::AUTHORIZATION, credential);
|
|
}
|
|
let response = app()
|
|
.oneshot(request.body(Body::empty()).expect("request should build"))
|
|
.await
|
|
.expect("request should complete");
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn integration_documents_remain_public_and_mcp_keeps_its_auth_challenge() {
|
|
let app = app();
|
|
for path in [
|
|
"/api/external/v1/openapi.json",
|
|
"/api/external/v1/agent-integration.json",
|
|
"/api/external/v1/skill/SKILL.md",
|
|
"/api/external/v1/skill.zip",
|
|
] {
|
|
for method in ["GET", "HEAD"] {
|
|
let response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(method)
|
|
.uri(path)
|
|
.header(header::AUTHORIZATION, "Bearer invalid-token")
|
|
.body(Body::empty())
|
|
.expect("document request should build"),
|
|
)
|
|
.await
|
|
.expect("document request should complete");
|
|
assert_eq!(response.status(), StatusCode::OK, "{method} {path}");
|
|
if method == "HEAD" {
|
|
assert!(
|
|
response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("HEAD body should collect")
|
|
.to_bytes()
|
|
.is_empty()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
for method in ["GET", "POST", "DELETE", "OPTIONS"] {
|
|
let response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(method)
|
|
.uri("/api/external/v1/mcp")
|
|
.body(Body::empty())
|
|
.expect("MCP request should build"),
|
|
)
|
|
.await
|
|
.expect("MCP request should complete");
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "MCP {method}");
|
|
assert_eq!(
|
|
response.headers()[header::WWW_AUTHENTICATE],
|
|
"Bearer realm=\"genarrative-external-editor\""
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn canvas_authentication_precedes_body_limit_rejection() {
|
|
let response = app()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("PATCH")
|
|
.uri("/api/external/v1/editor/projects/fixture/canvas")
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from("x".repeat(
|
|
crate::editor_project::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES + 1,
|
|
)))
|
|
.expect("oversized request should build"),
|
|
)
|
|
.await
|
|
.expect("oversized request should complete");
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn authenticated_canvas_keeps_its_four_mebibyte_body_limit() {
|
|
let app = app();
|
|
for (size, expected) in [
|
|
(2 * 1024 * 1024 + 1, StatusCode::UNPROCESSABLE_ENTITY),
|
|
(
|
|
crate::editor_project::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES + 1,
|
|
StatusCode::PAYLOAD_TOO_LARGE,
|
|
),
|
|
] {
|
|
let response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("PATCH")
|
|
.uri("/api/external/v1/editor/projects/fixture/canvas")
|
|
.header(header::AUTHORIZATION, format!("Bearer {VALID_KEY}"))
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(format!("\"{}\"", "x".repeat(size))))
|
|
.expect("canvas request should build"),
|
|
)
|
|
.await
|
|
.expect("canvas request should complete");
|
|
assert_eq!(response.status(), expected, "size={size}");
|
|
}
|
|
}
|
|
}
|