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:
tomdebone
2026-07-05 20:01:31 +02:00
commit c586fd39c9
134 changed files with 35279 additions and 0 deletions
+21
View File
@@ -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 }
+78
View File
@@ -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),
})
}
}
+72
View File
@@ -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());
}
}
+92
View File
@@ -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,
}
}
}
+35
View File
@@ -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(),
})
}
}