AT Protocol PDS + AppView + Tauri Desktop Client, 160-char post limit. - PDS (Rust + axum + sqlx) - Auth: createAccount, createSession, refreshSession - Records: createRecord, deleteRecord (race-safe via SELECT FOR UPDATE) - Feed: feed.like.create, feed.repost.create - Sync: getRepo, getBlocks, getLatestCommit, getRecord (with MST proof), listRepos - Identity: resolveHandle - MST: spec-conformant (at-mst crate, 27 tests) - Repo: signed commits, TID counter (monotonic, 4096 wrap safe) - AppView (Rust + axum + sqlx) - Jetstream consumer (WebSocket, exponential backoff, 38k+ events indexed) - REST API: timeline/home (graph-aware), profile, search, post (with thread hydration) - Handle-sync worker (did:plc + did:web) - JSONB embed storage + thread columns (migration 0003) - Like/repost counter cache (migration 0004) - Tauri 2 + Svelte 5 Desktop Client - System tray (Show/Compose/Quit menu) - OS notifications (tauri-plugin-notification) - Auto-update (tauri-plugin-updater, placeholder endpoint) - Window-state (tauri-plugin-window-state) - 160-char compose with live counter - Image/Link embed rendering - LocalStorage-persisted like state - Timeline with poll (prepend new posts) - Custom TitleBar (transparent, no decorations) - Orange/IBM Plex Mono maarcade design Tests: 231 Rust + 9 vitest = 240 passed.
160 lines
5.2 KiB
Rust
160 lines
5.2 KiB
Rust
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<AppState>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<CreateRecordReq>,
|
|
) -> Result<Json<CreateRecordResp>, (StatusCode, Json<crate::routes::types::ErrorBody>)> {
|
|
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
|
|
},
|
|
}))
|
|
}
|