use crate::routes::helpers::{ apply_repo_write, err, to_sqlx_error, RepoWriteOutcome, }; use crate::routes::types::{CreateRecordReq, CreateRecordResp}; use crate::state::AppState; use at_crypto::cid::cid_for_cbor; use at_repo::blockstore::Blockstore; use at_repo::rev::Tid; use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; use axum::Json; use bytes::Bytes; use cid::Cid; use tracing::info; pub async fn create_record( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, (StatusCode, Json)> { let did = req.repo.clone(); let auth = headers .get("authorization") .and_then(|v| v.to_str().ok()) .and_then(|s| s.strip_prefix("Bearer ")); let token = match auth { Some(t) => t.to_string(), None => { return Err(err( StatusCode::UNAUTHORIZED, "Unauthenticated", "missing Authorization: Bearer header", )); } }; let server_pk = crate::jwt_issuer::server_p256_public_multibase(&state.cfg) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", e.to_string()))?; let claims = match at_crypto::jwt::verify_jwt(&token, &server_pk) { Ok(c) => c, Err(e) => { return Err(err( StatusCode::UNAUTHORIZED, "TokenInvalid", format!("invalid token: {e}"), )); } }; if claims.sub != did { return Err(err( StatusCode::FORBIDDEN, "Forbidden", "token sub does not match repo", )); } let validate = req.validate.unwrap_or(true); if validate { if let Err(e) = state.lex.validate(&req.collection, &req.record) { return Err(err( StatusCode::BAD_REQUEST, "InvalidRequest", format!("lex validation failed: {e}"), )); } } let rkey = req .rkey .clone() .unwrap_or_else(|| Tid::new().as_str().to_string()); // 1. Encode the record value as CBOR, compute its CID. let mut record_buf = Vec::new(); ciborium::into_writer(&req.record, &mut record_buf).map_err(|e| { err( StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", format!("cbor: {e}"), ) })?; let value_cid: Cid = cid_for_cbor(&record_buf).map_err(|e| { err( StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", format!("cid: {e}"), ) })?; let push_handle = state.appview.clone(); let push_did = did.clone(); let push_coll = req.collection.clone(); let push_rkey = rkey.clone(); let push_cid = value_cid.to_string(); let push_record = req.record.clone(); let collection = req.collection.clone(); let outcome = apply_repo_write(&state, &did, move |repo| { let value_cid = value_cid; let rkey = rkey; let record_buf = record_buf; let collection = collection; Box::pin(async move { // Repo assumes the value block is already in the // blockstore — that's the caller's responsibility. repo.blockstore .put(&value_cid, Bytes::from(record_buf)) .await .map_err(to_sqlx_error)?; let (uri, _returned_cid) = repo .put_record(&collection, &rkey, value_cid) .await .map_err(to_sqlx_error)?; let commit = repo.commit().await.map_err(to_sqlx_error)?; let head_cid_bytes = commit.cid.to_bytes().to_vec(); let head_commit_bytes = commit.signed_bytes.clone(); Ok(RepoWriteOutcome { commit, head_cid_bytes, head_commit_bytes, }) }) }) .await?; let uri = format!("at://{did}/{push_coll}/{push_rkey}"); let commit = outcome.commit; info!(uri = %uri, cid = %value_cid, commit = %commit.cid, "record created"); // 10. Best-effort push to the AppView's `/internal/ingest-commit`. // We send the full record value (not just the CID) because the // AppView's indexer reads `embed` and `reply` off it. // // **Spawned** (not awaited) so a transient AppView outage never // blocks the user's write response. If the push fails, the // global Jetstream feed will eventually replay the commit to // the AppView. tokio::spawn(async move { if let Err(e) = push_handle .push_create(&push_did, &push_coll, &push_rkey, &push_cid, &push_record) .await { tracing::warn!(error = %e, did = %push_did, "appview push_create failed; jetstream will replay"); } }); Ok(Json(CreateRecordResp { uri, cid: value_cid.to_string(), commit: Some(serde_json::json!({ "cid": commit.cid.to_string(), "rev": commit.rev, })), validation_status: if validate { Some("valid".into()) } else { None }, })) }