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,203 @@
//! Card CRUD business logic (PRD §13.2). Ownership is enforced on every
//! mutating/owned-read path; non-owned access returns `NotFound` rather than
//! `Forbidden` so card existence is not leaked.
use cardclaws_db::models::card::CardRow;
use cardclaws_db::queries::{cards, users};
use cardclaws_types::{AppError, Tier};
use uuid::Uuid;
use crate::error::SqlxResultExt;
use crate::services::vcard;
use crate::state::AppState;
/// Load a card the caller owns, or `NotFound`.
pub async fn get_owned(state: &AppState, id: Uuid, user_id: Uuid) -> Result<CardRow, AppError> {
let card = cards::find_by_id(&state.db, id)
.await
.map_db()?
.filter(|c| c.owner_id == user_id)
.ok_or_else(|| AppError::NotFound("card".into()))?;
Ok(card)
}
pub async fn list(state: &AppState, user_id: Uuid) -> Result<Vec<CardRow>, AppError> {
cards::list_by_owner(&state.db, user_id).await.map_db()
}
pub async fn create(
state: &AppState,
user_id: Uuid,
handle: &str,
definition: &serde_json::Value,
) -> Result<CardRow, AppError> {
crate::validation::validate_handle(handle)?;
require_object(definition)?;
cards::insert(
&state.db,
cards::NewCard {
owner_id: user_id,
handle,
definition,
},
)
.await
.map_err(map_handle_conflict)
}
pub async fn replace(
state: &AppState,
id: Uuid,
user_id: Uuid,
definition: &serde_json::Value,
) -> Result<CardRow, AppError> {
get_owned(state, id, user_id).await?;
require_object(definition)?;
cards::update_definition(&state.db, id, definition)
.await
.map_db()
}
/// Shallow-merge the provided top-level keys into the existing definition.
pub async fn patch(
state: &AppState,
id: Uuid,
user_id: Uuid,
partial: &serde_json::Value,
) -> Result<CardRow, AppError> {
let existing = get_owned(state, id, user_id).await?;
let mut merged = existing.definition.clone();
let (Some(base), Some(patch)) = (merged.as_object_mut(), partial.as_object()) else {
return Err(AppError::BadRequest(
"definition and patch must be JSON objects".into(),
));
};
for (k, v) in patch {
base.insert(k.clone(), v.clone());
}
cards::update_definition(&state.db, id, &merged)
.await
.map_db()
}
/// Archive (soft-delete): status -> archived. Never destroys the row (§15.2).
pub async fn archive(state: &AppState, id: Uuid, user_id: Uuid) -> Result<CardRow, AppError> {
get_owned(state, id, user_id).await?;
cards::set_status(&state.db, id, "archived").await.map_db()
}
/// Publish: enforce the tier's active-card limit, then status -> active.
pub async fn publish(
state: &AppState,
id: Uuid,
user_id: Uuid,
tier: Tier,
) -> Result<CardRow, AppError> {
let card = get_owned(state, id, user_id).await?;
// Already-active cards re-publish freely; only a draft/archived card
// becoming active consumes a slot.
if card.status != "active" {
if let Some(limit) = tier.active_card_limit() {
let active = cards::count_active_for_owner(&state.db, user_id)
.await
.map_db()?;
if active >= limit {
return Err(AppError::TierLimit(format!(
"your plan allows {limit} active card(s); archive one or upgrade"
)));
}
}
}
require_object(&card.definition)?;
cards::set_status(&state.db, id, "active").await.map_db()
}
pub async fn duplicate(state: &AppState, id: Uuid, user_id: Uuid) -> Result<CardRow, AppError> {
let src = get_owned(state, id, user_id).await?;
let new_handle = format!("{}-copy-{}", src.handle, short_id());
let new_handle = truncate_handle(&new_handle);
cards::insert(
&state.db,
cards::NewCard {
owner_id: user_id,
handle: &new_handle,
definition: &src.definition,
},
)
.await
.map_err(map_handle_conflict)
}
/// Public, unauthenticated lookup — active cards only (§6.6, review A2).
pub async fn get_public_by_handle(state: &AppState, handle: &str) -> Result<CardRow, AppError> {
cards::find_active_by_handle(&state.db, handle)
.await
.map_db()?
.ok_or_else(|| AppError::NotFound("card".into()))
}
/// Public profile response: the active card plus the owner's display name (the
/// name the web profile renders in the hero). Used by the Astro profile.
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PublicProfile {
#[serde(flatten)]
pub card: CardRow,
pub owner_display_name: String,
}
pub async fn public_profile(state: &AppState, handle: &str) -> Result<PublicProfile, AppError> {
let card = get_public_by_handle(state, handle).await?;
let owner = users::find_by_id(&state.db, card.owner_id)
.await
.map_db()?
.ok_or_else(|| AppError::Internal("card owner missing".into()))?;
Ok(PublicProfile {
owner_display_name: owner.display_name,
card,
})
}
/// Render the card's contact data as an RFC 6350 vCard (PRD §13.2, §17.3).
pub async fn export_vcf(state: &AppState, id: Uuid, user_id: Uuid) -> Result<String, AppError> {
let card = get_owned(state, id, user_id).await?;
let owner = users::find_by_id(&state.db, card.owner_id)
.await
.map_db()?
.ok_or_else(|| AppError::Internal("card owner missing".into()))?;
let contact = vcard::extract_contact(&card.definition);
Ok(vcard::build_vcard(&owner.display_name, &contact))
}
// ---- Helpers --------------------------------------------------------------
fn require_object(definition: &serde_json::Value) -> Result<(), AppError> {
if definition.is_object() {
Ok(())
} else {
Err(AppError::Validation(
"card definition must be a JSON object".into(),
))
}
}
fn map_handle_conflict(e: sqlx::Error) -> AppError {
if let sqlx::Error::Database(db_err) = &e {
if db_err.is_unique_violation() {
return AppError::Conflict("handle already in use".into());
}
}
AppError::Internal(format!("db: {e}"))
}
fn short_id() -> String {
Uuid::new_v4().simple().to_string()[..8].to_string()
}
fn truncate_handle(handle: &str) -> String {
handle.chars().take(30).collect()
}