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,21 @@
|
||||
[package]
|
||||
name = "at-shared"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Shared types, errors, and config for maarcadetweet"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
url = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
@@ -0,0 +1,78 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Default polling interval for the handle-sync worker, in seconds.
|
||||
/// Bumped to 5 minutes — handle changes are infrequent and a missing
|
||||
/// `@handle` is purely cosmetic, so we don't need to hammer the PLC.
|
||||
fn default_handle_sync_interval() -> u64 {
|
||||
300
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub pds_host: String,
|
||||
pub pds_port: u16,
|
||||
pub pds_public_url: String,
|
||||
pub pds_handle_dns_zone: String,
|
||||
pub pds_jwt_secret: String,
|
||||
pub appview_host: String,
|
||||
pub appview_port: u16,
|
||||
pub appview_public_url: String,
|
||||
pub jetstream_url: String,
|
||||
pub jetstream_collections: Vec<String>,
|
||||
pub database_url_pds: String,
|
||||
pub database_url_appview: String,
|
||||
pub s3_endpoint: String,
|
||||
pub s3_region: String,
|
||||
pub s3_access_key: String,
|
||||
pub s3_secret_key: String,
|
||||
pub s3_bucket_pds: String,
|
||||
pub s3_bucket_appview: String,
|
||||
pub plc_directory_url: String,
|
||||
/// Optional shared secret for `POST /internal/ingest-commit`. If unset,
|
||||
/// the endpoint accepts anonymous requests (dev mode). If set, callers
|
||||
/// must send `X-Ingest-Secret: <value>`.
|
||||
#[serde(default)]
|
||||
pub appview_ingest_secret: Option<String>,
|
||||
/// How often the handle-sync worker scans the `posts` table for rows
|
||||
/// with an empty `handle` column and resolves them via the PLC
|
||||
/// directory. Default: 300s (5 minutes).
|
||||
#[serde(default = "default_handle_sync_interval")]
|
||||
pub appview_handle_sync_interval_secs: u64,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
let env = |k: &str| std::env::var(k).map_err(|_| anyhow::anyhow!("missing env: {k}"));
|
||||
let collections: Vec<String> = env("JETSTREAM_COLLECTIONS")?
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
Ok(Self {
|
||||
pds_host: env("PDS_HOST")?,
|
||||
pds_port: env("PDS_PORT")?.parse()?,
|
||||
pds_public_url: env("PDS_PUBLIC_URL")?,
|
||||
pds_handle_dns_zone: env("PDS_HANDLE_DNS_ZONE")?,
|
||||
pds_jwt_secret: env("PDS_JWT_SECRET")?,
|
||||
appview_host: env("APPVIEW_HOST")?,
|
||||
appview_port: env("APPVIEW_PORT")?.parse()?,
|
||||
appview_public_url: env("APPVIEW_PUBLIC_URL")?,
|
||||
jetstream_url: env("JETSTREAM_URL")?,
|
||||
jetstream_collections: collections,
|
||||
database_url_pds: env("DATABASE_URL_PDS")?,
|
||||
database_url_appview: env("DATABASE_URL_APPVIEW")?,
|
||||
s3_endpoint: env("S3_ENDPOINT")?,
|
||||
s3_region: env("S3_REGION")?,
|
||||
s3_access_key: env("S3_ACCESS_KEY")?,
|
||||
s3_secret_key: env("S3_SECRET_KEY")?,
|
||||
s3_bucket_pds: env("S3_BUCKET_PDS")?,
|
||||
s3_bucket_appview: env("S3_BUCKET_APPVIEW")?,
|
||||
plc_directory_url: env("PLC_DIRECTORY_URL")?,
|
||||
appview_ingest_secret: std::env::var("APPVIEW_INGEST_SECRET").ok(),
|
||||
appview_handle_sync_interval_secs: std::env::var("APPVIEW_HANDLE_SYNC_INTERVAL_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or_else(default_handle_sync_interval),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(tag = "method", content = "id")]
|
||||
pub enum Did {
|
||||
Plc { id: String },
|
||||
Web { id: String },
|
||||
Key { id: String },
|
||||
}
|
||||
|
||||
impl Did {
|
||||
pub fn method(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Plc { .. } => "plc",
|
||||
Self::Web { .. } => "web",
|
||||
Self::Key { .. } => "key",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &str {
|
||||
match self {
|
||||
Self::Plc { id }
|
||||
| Self::Web { id }
|
||||
| Self::Key { id } => id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> String {
|
||||
format!("did:{}:{}", self.method(), self.id())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Did {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for Did {
|
||||
type Err = anyhow::Error;
|
||||
fn from_str(s: &str) -> anyhow::Result<Self> {
|
||||
let s = s.strip_prefix("did:").ok_or_else(|| anyhow::anyhow!("not a did"))?;
|
||||
let (method, rest) = s
|
||||
.split_once(':')
|
||||
.ok_or_else(|| anyhow::anyhow!("malformed did"))?;
|
||||
Ok(match method {
|
||||
"plc" => Self::Plc { id: rest.to_string() },
|
||||
"web" => Self::Web { id: rest.to_string() },
|
||||
"key" => Self::Key { id: rest.to_string() },
|
||||
other => anyhow::bail!("unknown did method: {other}"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_did() {
|
||||
let d: Did = "did:plc:abc123def".parse().unwrap();
|
||||
assert_eq!(d, Did::Plc { id: "abc123def".into() });
|
||||
assert_eq!(d.as_str(), "did:plc:abc123def");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid() {
|
||||
assert!("not-a-did".parse::<Did>().is_err());
|
||||
assert!("did:unknown:x".parse::<Did>().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod config;
|
||||
pub mod did;
|
||||
pub mod time;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AtError {
|
||||
#[error("invalid request: {0}")]
|
||||
InvalidRequest(String),
|
||||
|
||||
#[error("authentication required")]
|
||||
Unauthenticated,
|
||||
|
||||
#[error("forbidden: {0}")]
|
||||
Forbidden(String),
|
||||
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("rate limited")]
|
||||
RateLimited,
|
||||
|
||||
#[error("upstream error: {0}")]
|
||||
Upstream(String),
|
||||
|
||||
#[error("storage error: {0}")]
|
||||
Storage(String),
|
||||
|
||||
#[error("crypto error: {0}")]
|
||||
Crypto(String),
|
||||
|
||||
#[error("serialization error: {0}")]
|
||||
Codec(String),
|
||||
|
||||
#[error("internal: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl AtError {
|
||||
pub fn status(&self) -> u16 {
|
||||
match self {
|
||||
Self::InvalidRequest(_) => 400,
|
||||
Self::Unauthenticated => 401,
|
||||
Self::Forbidden(_) => 403,
|
||||
Self::NotFound(_) => 404,
|
||||
Self::Conflict(_) => 409,
|
||||
Self::RateLimited => 429,
|
||||
Self::Upstream(_) | Self::Storage(_) | Self::Crypto(_) | Self::Codec(_) => 502,
|
||||
Self::Internal(_) => 500,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::InvalidRequest(_) => "InvalidRequest",
|
||||
Self::Unauthenticated => "Unauthenticated",
|
||||
Self::Forbidden(_) => "Forbidden",
|
||||
Self::NotFound(_) => "NotFound",
|
||||
Self::Conflict(_) => "Conflict",
|
||||
Self::RateLimited => "RateLimited",
|
||||
Self::Upstream(_) => "UpstreamError",
|
||||
Self::Storage(_) => "StorageError",
|
||||
Self::Crypto(_) => "CryptoError",
|
||||
Self::Codec(_) => "CodecError",
|
||||
Self::Internal(_) => "InternalServerError",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type AtResult<T> = Result<T, AtError>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct XrpcErrorBody {
|
||||
#[serde(rename = "error")]
|
||||
pub error: String,
|
||||
#[serde(rename = "message", skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl XrpcErrorBody {
|
||||
pub fn new(name: impl Into<String>, message: Option<String>) -> Self {
|
||||
Self {
|
||||
error: name.into(),
|
||||
message,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub fn now() -> DateTime<Utc> {
|
||||
Utc::now()
|
||||
}
|
||||
|
||||
pub fn parse_iso(s: &str) -> anyhow::Result<DateTime<Utc>> {
|
||||
Ok(DateTime::parse_from_rfc3339(s)?.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Cursor {
|
||||
pub ts: i64,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
impl Cursor {
|
||||
pub fn encode(&self) -> String {
|
||||
use base64::Engine;
|
||||
let raw = format!("{}:{}", self.ts, self.id);
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes())
|
||||
}
|
||||
|
||||
pub fn decode(s: &str) -> anyhow::Result<Self> {
|
||||
use base64::Engine;
|
||||
let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(s.as_bytes())?;
|
||||
let s = std::str::from_utf8(&raw)?;
|
||||
let (ts, id) = s.split_once(':').ok_or_else(|| anyhow::anyhow!("bad cursor"))?;
|
||||
Ok(Self {
|
||||
ts: ts.parse()?,
|
||||
id: id.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user