Phase 1 foundation: backend, web profile, and mobile app
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:
@@ -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>,
|
||||
}
|
||||
Reference in New Issue
Block a user