maarcadetweet: initial commit
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.
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
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
|
||||
},
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user