Phase 1 foundation: backend, web profile, and mobile app
CI / policy (push) Successful in 0s
CI / mobile (push) Successful in 47s
CI / profile (push) Successful in 49s
CI / backend (push) Failing after 49s

Greenfield implementation of CardClaws Phase 1 across three surfaces.

Backend (Rust/Axum workspace, 67 tests):
- cardclaws-types/config/db/auth/api/wallet crates
- Auth: register, login + lockout, magic link, refresh rotation, Apple verify
- Cards: CRUD, tier-limited publish, duplicate, public handle lookup
- Assets: R2 presigned uploads; vCard export
- Apple Wallet .pkpass pipeline (PKCS#7 signer behind apple-signing feature)
- Analytics ingest + summary with daily-salted IP hashing
- Migrations 0001 (incl. cardclaws_sessions) + 0002 analytics

Web profile (Astro SSR): cardclaws.com/[handle] hero + flip, contact actions,
client-built vCard, visit attribution. Verified end-to-end.

Mobile (Expo SDK 51): auth, card list/create, builder v1 (bg/text/logo,
palette, undo/redo), Skia/Reanimated viewer (flip + ambient). 22 logic tests.

CI: policy/backend/profile/mobile jobs; LOC + no-placeholder lint.

Review fixes baked in: `back` (not `cardclaws`) key; strip image is a bundled
manifest file (not a URL); sessions table added; NFC reframed; test doubles
allowed for external services.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-04 10:17:26 -05:00
co-authored by Claude Opus 4.8
commit c30d3afeec
128 changed files with 36279 additions and 0 deletions
@@ -0,0 +1,16 @@
[package]
name = "cardclaws-types"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
@@ -0,0 +1,78 @@
//! Request/response DTOs for the authentication endpoints (PRD §13.1).
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::user::Tier;
#[derive(Debug, Clone, Deserialize)]
pub struct RegisterRequest {
pub email: String,
pub password: String,
pub handle: String,
pub display_name: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct MagicLinkRequest {
pub email: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct MagicLinkVerifyRequest {
pub token: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AppleOAuthRequest {
/// The identity token (a JWT) returned by Sign in with Apple.
pub identity_token: String,
/// Apple only returns the name on first authorization, so the client passes
/// it through when present.
pub display_name: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RefreshRequest {
pub refresh_token: String,
}
/// Returned by every successful authentication. Access token is short-lived
/// (15 min), refresh token is long-lived (30 days) and rotates on use.
#[derive(Debug, Clone, Serialize)]
pub struct TokenPair {
pub access_token: String,
pub refresh_token: String,
/// Access-token lifetime in seconds.
pub expires_in: i64,
pub user: AuthUserInfo,
}
/// Minimal user info embedded in an auth response so the client need not make a
/// second round-trip after login.
#[derive(Debug, Clone, Serialize)]
pub struct AuthUserInfo {
pub id: Uuid,
pub email: String,
pub handle: String,
pub display_name: String,
pub tier: Tier,
}
/// Claims encoded inside the access JWT.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessClaims {
/// Subject — the user id.
pub sub: Uuid,
pub tier: Tier,
/// Expiry (unix seconds).
pub exp: i64,
/// Issued-at (unix seconds).
pub iat: i64,
}
@@ -0,0 +1,310 @@
//! The canonical `CardDefinition` — the single source of truth for a card,
//! shared by the mobile renderer, the web profile, and the backend. Mirrors the
//! TypeScript definition in PRD §8.3.
//!
//! NOTE: the back side key is `back` (NOT `cardclaws` as printed in the mangled
//! PRD §12.1 JSON schema — see the plan's review section A2). It round-trips to
//! the `cards.definition` JSONB column.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CardDefinition {
pub id: Uuid,
pub owner_id: Uuid,
pub handle: String,
pub version: i32,
pub face: CardSide,
pub back: CardSide,
pub palette: ColorPalette,
pub settings: CardSettings,
pub profile: ProfileData,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CardSide {
pub layers: Vec<Layer>,
pub background: BackgroundConfig,
pub entry_animation: EntryAnimationType,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum EntryAnimationType {
Rise,
Fade,
Scale,
Deal,
None,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum BackgroundConfig {
Solid { value: String },
Gradient { value: GradientConfig },
Image { r2_key: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct GradientConfig {
/// linear | radial | conic
pub kind: String,
pub stops: Vec<GradientStop>,
/// Angle in degrees for linear gradients.
pub angle: Option<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GradientStop {
pub color: String,
/// 0.0 - 1.0
pub position: f32,
}
/// All layer variants. Geometry fields (x/y/width/height) are fractions of the
/// canvas (0.0 - 1.0) so a card renders identically at any device size.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Layer {
Background(BaseLayer),
Text(TextLayer),
Logo(LogoLayer),
Shape(ShapeLayer),
Qr(BaseLayer),
Contact(ContactLayer),
Video(LogoLayer),
Particle(BaseLayer),
AnimatedGradient(BaseLayer),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct BaseLayer {
pub id: Uuid,
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
pub opacity: f32,
pub z_index: i32,
#[serde(default)]
pub entry_animation: Option<AnimationConfig>,
#[serde(default)]
pub loop_animation: Option<AnimationConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct TextLayer {
#[serde(flatten)]
pub base: BaseLayer,
pub text: String,
pub font_family: String,
pub font_weight: i32,
pub font_size: f32,
pub line_height: f32,
pub letter_spacing: f32,
pub color: String,
/// left | center | right
pub align: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LogoLayer {
#[serde(flatten)]
pub base: BaseLayer,
pub r2_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ShapeLayer {
#[serde(flatten)]
pub base: BaseLayer,
/// rectangle | circle | line
pub shape: String,
pub fill: Option<String>,
pub stroke: Option<String>,
pub stroke_width: f32,
pub corner_radius: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ContactLayer {
#[serde(flatten)]
pub base: BaseLayer,
pub fields: ContactFields,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ContactFields {
pub phone: Option<String>,
pub email: Option<String>,
pub website: Option<String>,
pub linkedin: Option<String>,
pub company: Option<String>,
pub title: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AnimationConfig {
/// fade | slide | scale | pulse | float | shimmer | none
pub kind: String,
pub duration_ms: u32,
pub delay_ms: u32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ColorPalette {
pub colors: Vec<PaletteColor>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PaletteColor {
pub name: String,
pub hex: String,
/// primary | secondary | accent | background | text | null
pub role: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CardSettings {
/// swipe | doubleTap | both
pub flip_gesture: String,
pub flip_duration_ms: u32,
pub ambient_mode_enabled: bool,
pub ambient_mode_delay_ms: u32,
pub haptic_enabled: bool,
}
impl Default for CardSettings {
fn default() -> Self {
Self {
flip_gesture: "both".to_string(),
flip_duration_ms: 400,
ambient_mode_enabled: true,
ambient_mode_delay_ms: 5000,
haptic_enabled: true,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileData {
#[serde(default)]
pub bio: String,
pub avatar_r2_key: Option<String>,
#[serde(default)]
pub links: Vec<ProfileLink>,
#[serde(default)]
pub portfolio: Vec<PortfolioItem>,
#[serde(default)]
pub testimonials: Vec<Testimonial>,
#[serde(default)]
pub contact_form_enabled: bool,
#[serde(default)]
pub theme_overrides: ThemeOverrides,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProfileLink {
pub id: Uuid,
#[serde(rename = "type")]
pub link_type: String,
pub label: String,
pub url: String,
pub icon_slug: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PortfolioItem {
pub id: Uuid,
/// image | video
#[serde(rename = "type")]
pub media_type: String,
pub r2_key: String,
pub caption: String,
pub link_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Testimonial {
pub id: Uuid,
pub text: String,
pub author_name: String,
pub author_company: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ThemeOverrides {
pub background_color: Option<String>,
pub accent_color: Option<String>,
pub text_color: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
/// The back side must serialize under the key `back`, not `cardclaws`.
#[test]
fn card_definition_uses_back_key() {
let card = CardDefinition {
id: Uuid::nil(),
owner_id: Uuid::nil(),
handle: "omar".to_string(),
version: 1,
face: empty_side(),
back: empty_side(),
palette: ColorPalette::default(),
settings: CardSettings::default(),
profile: ProfileData::default(),
};
let json = serde_json::to_value(&card).unwrap();
assert!(json.get("back").is_some(), "back key must exist");
assert!(
json.get("cardclaws").is_none(),
"the mangled `cardclaws` key must NOT exist"
);
}
#[test]
fn card_definition_round_trips() {
let card = CardDefinition {
id: Uuid::nil(),
owner_id: Uuid::nil(),
handle: "omar".to_string(),
version: 3,
face: empty_side(),
back: empty_side(),
palette: ColorPalette::default(),
settings: CardSettings::default(),
profile: ProfileData::default(),
};
let json = serde_json::to_string(&card).unwrap();
let back: CardDefinition = serde_json::from_str(&json).unwrap();
assert_eq!(card, back);
}
fn empty_side() -> CardSide {
CardSide {
layers: vec![],
background: BackgroundConfig::Solid {
value: "#000000".to_string(),
},
entry_animation: EntryAnimationType::Fade,
}
}
}
@@ -0,0 +1,78 @@
//! The single application error type. Every crate returns `Result<_, AppError>`
//! and the API layer is responsible for turning it into an HTTP response.
use thiserror::Error;
/// Stable machine-readable error code returned to clients in the JSON body.
/// Kept separate from the HTTP status so clients can branch on a stable string.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
BadRequest,
Unauthorized,
Forbidden,
NotFound,
Conflict,
Validation,
RateLimited,
TierLimit,
Internal,
}
#[derive(Debug, Error)]
pub enum AppError {
#[error("{0}")]
BadRequest(String),
#[error("unauthorized")]
Unauthorized,
#[error("forbidden")]
Forbidden,
#[error("{0} not found")]
NotFound(String),
#[error("{0}")]
Conflict(String),
#[error("validation failed: {0}")]
Validation(String),
#[error("rate limit exceeded")]
RateLimited,
/// The caller's tier does not permit this action (e.g. card-count cap).
#[error("tier limit reached: {0}")]
TierLimit(String),
/// Any unexpected internal failure. The inner string is logged but never
/// surfaced verbatim to clients (see the API error mapping).
#[error("internal error: {0}")]
Internal(String),
}
impl AppError {
pub fn code(&self) -> ErrorCode {
match self {
AppError::BadRequest(_) => ErrorCode::BadRequest,
AppError::Unauthorized => ErrorCode::Unauthorized,
AppError::Forbidden => ErrorCode::Forbidden,
AppError::NotFound(_) => ErrorCode::NotFound,
AppError::Conflict(_) => ErrorCode::Conflict,
AppError::Validation(_) => ErrorCode::Validation,
AppError::RateLimited => ErrorCode::RateLimited,
AppError::TierLimit(_) => ErrorCode::TierLimit,
AppError::Internal(_) => ErrorCode::Internal,
}
}
/// The client-safe message. Internal errors are redacted to avoid leaking
/// implementation detail; everything else echoes its `Display`.
pub fn public_message(&self) -> String {
match self {
AppError::Internal(_) => "internal error".to_string(),
other => other.to_string(),
}
}
}
@@ -0,0 +1,13 @@
//! Shared types across all CardClaws backend crates.
//!
//! This crate holds the canonical data shapes (users, cards, auth DTOs) and the
//! single application error enum that every crate maps into. It has no runtime
//! dependencies beyond serde/uuid/chrono so it can be linked by leaf crates.
pub mod auth;
pub mod card;
pub mod errors;
pub mod user;
pub use errors::{AppError, ErrorCode};
pub use user::{Tier, User};
@@ -0,0 +1,66 @@
//! User account types shared between the DB layer and the API surface.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Subscription tier. Authoritative source is the `users.tier` column; client
/// state is never trusted for feature gating (PRD §19.2).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Tier {
Free,
Pro,
Team,
Enterprise,
}
impl Tier {
/// Maximum number of `active` cards allowed for this tier (PRD §6.8.2).
/// `None` means unlimited.
pub fn active_card_limit(self) -> Option<i64> {
match self {
Tier::Free => Some(1),
Tier::Pro => Some(5),
Tier::Team | Tier::Enterprise => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Tier::Free => "free",
Tier::Pro => "pro",
Tier::Team => "team",
Tier::Enterprise => "enterprise",
}
}
}
impl std::str::FromStr for Tier {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"free" => Ok(Tier::Free),
"pro" => Ok(Tier::Pro),
"team" => Ok(Tier::Team),
"enterprise" => Ok(Tier::Enterprise),
other => Err(format!("unknown tier: {other}")),
}
}
}
/// A user account. The `password_hash` is never serialized to clients — it is
/// `#[serde(skip)]` so an accidental `Json(user)` cannot leak it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: Uuid,
pub email: String,
pub handle: String,
pub display_name: String,
pub tier: Tier,
pub avatar_r2_key: Option<String>,
#[serde(skip)]
pub password_hash: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}