Files
maarcadetweet/crates/pds-server/src/routes/repo.rs
T
tomdebone c586fd39c9 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.
2026-07-05 20:01:31 +02:00

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
},
}))
}