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,118 @@
//! Analytics ingestion + summary (PRD §18). IPs are hashed with a per-day
//! rotating salt before storage — never persisted in plaintext (§18.3).
use cardclaws_db::models::analytics::AnalyticsSummary;
use cardclaws_db::queries::analytics;
use cardclaws_types::AppError;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::error::SqlxResultExt;
use crate::middleware::rate_limit;
use crate::services::card_service;
use crate::state::AppState;
/// Client-ingestible event types (PRD §12.2). Server-originated types
/// (`profile_visit`) are recorded internally and not accepted from clients.
const CLIENT_EVENT_TYPES: &[&str] = &["qr_scan", "contact_save", "link_click", "wallet_add"];
/// All recordable event types (client + server-originated).
const ALL_EVENT_TYPES: &[&str] = &[
"profile_visit",
"qr_scan",
"nfc_tap",
"contact_save",
"link_click",
"wallet_add",
"contact_form_submission",
"card_share_event",
];
/// Record a client-reported event (PRD §18.1 secondary path). Rate-limited per
/// card. Rejects server-only event types.
pub async fn ingest_client_event(
state: &AppState,
card_id: Uuid,
event_type: &str,
ip: Option<&str>,
user_agent: Option<&str>,
) -> Result<(), AppError> {
if !CLIENT_EVENT_TYPES.contains(&event_type) {
return Err(AppError::Validation(format!(
"event_type '{event_type}' is not client-ingestible"
)));
}
// 100 events/min per card (§20.4).
rate_limit::check(
state.cache.as_ref(),
&format!("analytics:ingest:{card_id}"),
100,
60,
)
.await?;
record(state, card_id, event_type, ip, user_agent).await
}
/// Record any event type internally (used for server-originated `profile_visit`).
/// Errors are swallowed by callers that treat analytics as best-effort.
pub async fn record(
state: &AppState,
card_id: Uuid,
event_type: &str,
ip: Option<&str>,
user_agent: Option<&str>,
) -> Result<(), AppError> {
debug_assert!(ALL_EVENT_TYPES.contains(&event_type));
let ip_hash = ip.map(|raw| hash_ip(&state.ip_hash_secret, raw));
analytics::insert_event(
&state.db,
analytics::NewEvent {
card_id,
event_type,
ip_hash: ip_hash.as_deref(),
user_agent,
},
)
.await
.map_db()
}
/// Owner-only metrics summary for a card.
pub async fn summary(
state: &AppState,
card_id: Uuid,
user_id: Uuid,
) -> Result<AnalyticsSummary, AppError> {
card_service::get_owned(state, card_id, user_id).await?;
analytics::summary(&state.db, card_id).await.map_db()
}
/// SHA-256 of `date:secret:ip`. The date component rotates the salt daily so a
/// hash cannot be correlated across days, while same-day uniqueness is
/// preserved for unique-visitor counting (§18.3).
fn hash_ip(secret: &str, ip: &str) -> String {
let day = chrono::Utc::now().format("%Y-%m-%d");
let mut hasher = Sha256::new();
hasher.update(format!("{day}:{secret}:{ip}").as_bytes());
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ip_hash_is_stable_within_day_and_ip_specific() {
let a = hash_ip("seed", "1.2.3.4");
let b = hash_ip("seed", "1.2.3.4");
let c = hash_ip("seed", "5.6.7.8");
assert_eq!(a, b);
assert_ne!(a, c);
assert_eq!(a.len(), 64);
}
}
@@ -0,0 +1,303 @@
//! Authentication business logic. Handlers stay thin (PRD §9.2) and delegate
//! here. This module wires the `cardclaws-auth` primitives to the database and
//! cache.
use chrono::{Duration, Utc};
use rand::Rng;
use cardclaws_auth::token::{generate_opaque_token, hash_token};
use cardclaws_auth::{apple, jwt, magic_link, password};
use cardclaws_db::queries::{sessions, users};
use cardclaws_types::auth::*;
use cardclaws_types::{AppError, User};
use crate::error::SqlxResultExt;
use crate::middleware::rate_limit;
use crate::state::AppState;
/// Refresh-token lifetime (30 days, PRD §6.8.1).
const REFRESH_TTL_DAYS: i64 = 30;
// ---- Public flows ---------------------------------------------------------
pub async fn register(state: &AppState, req: RegisterRequest) -> Result<TokenPair, AppError> {
crate::validation::validate_email(&req.email)?;
crate::validation::validate_password(&req.password)?;
crate::validation::validate_handle(&req.handle)?;
if users::email_or_handle_taken(&state.db, &req.email, &req.handle)
.await
.map_db()?
{
return Err(AppError::Conflict("email or handle already in use".into()));
}
let hash = password::hash_password(&req.password)
.map_err(|_| AppError::Internal("password hashing failed".into()))?;
let user = users::insert(
&state.db,
users::NewUser {
email: &req.email,
handle: &req.handle,
display_name: &req.display_name,
password_hash: Some(&hash),
},
)
.await
.map_err(map_unique_violation)?;
issue_tokens(state, &user, None).await
}
pub async fn login(
state: &AppState,
req: LoginRequest,
device_fingerprint: Option<&str>,
) -> Result<TokenPair, AppError> {
// Lockout: 10 failed attempts / 5 min per email (§20.1).
let lock_key = format!("login:fail:{}", req.email);
let user = users::find_by_email(&state.db, &req.email).await.map_db()?;
let Some(user) = user else {
// Count the attempt even for unknown emails to avoid timing/enumeration.
bump_login_failures(state, &lock_key).await?;
return Err(AppError::Unauthorized);
};
let Some(stored) = &user.password_hash else {
// OAuth/magic-link-only account: no password to check.
return Err(AppError::Unauthorized);
};
let ok = password::verify_password(&req.password, stored).unwrap_or(false);
if !ok {
bump_login_failures(state, &lock_key).await?;
return Err(AppError::Unauthorized);
}
issue_tokens(state, &user, device_fingerprint).await
}
/// Always returns Ok(()) regardless of whether the email exists, to avoid
/// account enumeration. Only sends a link if the account is real.
pub async fn magic_link_request(state: &AppState, req: MagicLinkRequest) -> Result<(), AppError> {
crate::validation::validate_email(&req.email)?;
if let Some(user) = users::find_by_email(&state.db, &req.email).await.map_db()? {
let minted = magic_link::mint();
let key = format!("magic:{}", minted.hash);
state
.cache
.set_ex(&key, &user.email, magic_link::MAGIC_LINK_TTL_SECS)
.await
.map_err(|e| AppError::Internal(e.0))?;
let link = format!(
"{}/auth/magic?token={}",
state.profile_base_url, minted.token
);
let html = format!(
"<p>Tap to sign in to CardClaws:</p><p><a href=\"{link}\">Sign in</a></p>\
<p>This link expires in 15 minutes.</p>"
);
// Best-effort: a transient email failure should not leak account state.
let _ = state
.email
.send(&user.email, "Your CardClaws sign-in link", &html)
.await;
}
Ok(())
}
pub async fn magic_link_verify(
state: &AppState,
req: MagicLinkVerifyRequest,
) -> Result<TokenPair, AppError> {
let hash = hash_token(&req.token);
let key = format!("magic:{hash}");
let email = state
.cache
.get_del(&key)
.await
.map_err(|e| AppError::Internal(e.0))?
.ok_or(AppError::Unauthorized)?;
let user = users::find_by_email(&state.db, &email)
.await
.map_db()?
.ok_or(AppError::Unauthorized)?;
issue_tokens(state, &user, None).await
}
pub async fn oauth_apple(state: &AppState, req: AppleOAuthRequest) -> Result<TokenPair, AppError> {
let claims = apple::verify_identity_token(
state.apple.as_ref(),
&req.identity_token,
&state.apple_audience,
)
.await
.map_err(|_| AppError::Unauthorized)?;
let email = claims.email.ok_or_else(|| {
AppError::BadRequest("Apple did not provide an email for this account".into())
})?;
if let Some(user) = users::find_by_email(&state.db, &email).await.map_db()? {
return issue_tokens(state, &user, None).await;
}
// First sign-in: provision an account with a generated unique handle.
let display_name = req
.display_name
.filter(|n| !n.trim().is_empty())
.unwrap_or_else(|| email.split('@').next().unwrap_or("user").to_string());
let handle = generate_unique_handle(state, &email).await?;
let user = users::insert(
&state.db,
users::NewUser {
email: &email,
handle: &handle,
display_name: &display_name,
password_hash: None,
},
)
.await
.map_err(map_unique_violation)?;
issue_tokens(state, &user, None).await
}
pub async fn refresh(state: &AppState, req: RefreshRequest) -> Result<TokenPair, AppError> {
let hash = hash_token(&req.refresh_token);
let session = sessions::find_live_by_hash(&state.db, &hash)
.await
.map_db()?
.ok_or(AppError::Unauthorized)?;
let user = users::find_by_id(&state.db, session.user_id)
.await
.map_db()?
.ok_or(AppError::Unauthorized)?;
// Rotate: invalidate the presented token, then mint a fresh pair.
sessions::delete(&state.db, session.id).await.map_db()?;
issue_tokens(state, &user, session.device_fingerprint.as_deref()).await
}
pub async fn logout(state: &AppState, refresh_token: &str) -> Result<(), AppError> {
let hash = hash_token(refresh_token);
if let Some(session) = sessions::find_live_by_hash(&state.db, &hash)
.await
.map_db()?
{
sessions::delete(&state.db, session.id).await.map_db()?;
}
Ok(())
}
// ---- Helpers --------------------------------------------------------------
/// Mint an access JWT + a fresh refresh token, persisting the refresh session.
async fn issue_tokens(
state: &AppState,
user: &User,
device_fingerprint: Option<&str>,
) -> Result<TokenPair, AppError> {
let access_token = state
.jwt
.encode_access(user.id, user.tier)
.map_err(|_| AppError::Internal("token encoding failed".into()))?;
let refresh_token = generate_opaque_token();
let refresh_hash = hash_token(&refresh_token);
let expires_at = Utc::now() + Duration::days(REFRESH_TTL_DAYS);
sessions::insert(
&state.db,
sessions::NewSession {
user_id: user.id,
refresh_token_hash: &refresh_hash,
device_fingerprint,
expires_at,
},
)
.await
.map_db()?;
Ok(TokenPair {
access_token,
refresh_token,
expires_in: jwt::ACCESS_TOKEN_TTL_SECS,
user: AuthUserInfo {
id: user.id,
email: user.email.clone(),
handle: user.handle.clone(),
display_name: user.display_name.clone(),
tier: user.tier,
},
})
}
async fn bump_login_failures(state: &AppState, key: &str) -> Result<(), AppError> {
let count = rate_limit::check(
state.cache.as_ref(),
key,
rate_limit::LOGIN_MAX_FAILS,
rate_limit::LOGIN_WINDOW_SECS,
)
.await;
// `check` returns RateLimited once the threshold is crossed; surface that as
// an explicit lockout so the client can message it.
match count {
Err(AppError::RateLimited) => Err(AppError::RateLimited),
_ => Ok(()),
}
}
/// Build a unique handle from an email local part plus random digits on
/// collision. Sanitizes to the handle charset and pads short names.
async fn generate_unique_handle(state: &AppState, email: &str) -> Result<String, AppError> {
let mut base: String = email
.split('@')
.next()
.unwrap_or("user")
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.map(|c| c.to_ascii_lowercase())
.collect();
if base.chars().count() < 3 {
base = format!("user{base}");
}
base.truncate(24);
if !users::handle_taken(&state.db, &base).await.map_db()? && !is_reserved(&base) {
return Ok(base);
}
for _ in 0..10 {
let suffix: u32 = rand::thread_rng().gen_range(1000..9999);
let candidate = format!("{base}{suffix}");
if !users::handle_taken(&state.db, &candidate).await.map_db()? {
return Ok(candidate);
}
}
Err(AppError::Internal(
"could not allocate a unique handle".into(),
))
}
fn is_reserved(handle: &str) -> bool {
crate::validation::validate_handle(handle).is_err()
}
/// Map a Postgres unique-violation into a clean 409.
fn map_unique_violation(e: sqlx::Error) -> AppError {
if let sqlx::Error::Database(db_err) = &e {
if db_err.is_unique_violation() {
return AppError::Conflict("email or handle already in use".into());
}
}
AppError::Internal(format!("db: {e}"))
}
@@ -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()
}
@@ -0,0 +1,5 @@
pub mod analytics_service;
pub mod auth_service;
pub mod card_service;
pub mod vcard;
pub mod wallet_service;
@@ -0,0 +1,149 @@
//! vCard 3.0 (RFC 6350) generation from a card's contact data (PRD §17.3).
//!
//! The card `definition` is stored as opaque JSON, so we extract contact fields
//! from the first `contact`-type layer found on either side, falling back to the
//! account display name for the formatted name.
use serde::Deserialize;
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContactInfo {
pub phone: Option<String>,
pub email: Option<String>,
pub website: Option<String>,
pub linkedin: Option<String>,
pub company: Option<String>,
pub title: Option<String>,
}
/// Pull contact fields out of a card definition by scanning face/back layers for
/// the first `{"type":"contact","fields":{...}}` layer.
pub fn extract_contact(definition: &serde_json::Value) -> ContactInfo {
for side in ["face", "back"] {
if let Some(layers) = definition
.get(side)
.and_then(|s| s.get("layers"))
.and_then(|l| l.as_array())
{
for layer in layers {
if layer.get("type").and_then(|t| t.as_str()) == Some("contact") {
if let Some(fields) = layer.get("fields") {
if let Ok(info) = serde_json::from_value::<ContactInfo>(fields.clone()) {
return info;
}
}
}
}
}
}
ContactInfo::default()
}
/// Build an RFC 6350 vCard 3.0 string. `display_name` becomes FN; the structured
/// N field is a best-effort split on the first space.
pub fn build_vcard(display_name: &str, contact: &ContactInfo) -> String {
let (first, last) = split_name(display_name);
let mut lines = vec![
"BEGIN:VCARD".to_string(),
"VERSION:3.0".to_string(),
format!("FN:{}", escape(display_name)),
format!("N:{};{};;;", escape(&last), escape(&first)),
];
if let Some(org) = &contact.company {
lines.push(format!("ORG:{}", escape(org)));
}
if let Some(title) = &contact.title {
lines.push(format!("TITLE:{}", escape(title)));
}
if let Some(phone) = &contact.phone {
lines.push(format!("TEL;TYPE=CELL:{}", escape(phone)));
}
if let Some(email) = &contact.email {
lines.push(format!("EMAIL:{}", escape(email)));
}
if let Some(url) = &contact.website {
lines.push(format!("URL:{}", escape(url)));
}
if let Some(linkedin) = &contact.linkedin {
lines.push(format!(
"X-SOCIALPROFILE;TYPE=linkedin:{}",
escape(linkedin)
));
}
lines.push("END:VCARD".to_string());
// vCard uses CRLF line endings.
lines.join("\r\n") + "\r\n"
}
fn split_name(display_name: &str) -> (String, String) {
match display_name.split_once(' ') {
Some((first, last)) => (first.to_string(), last.to_string()),
None => (display_name.to_string(), String::new()),
}
}
/// Escape the characters that are special in vCard property values.
fn escape(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace(';', "\\;")
.replace(',', "\\,")
.replace('\n', "\\n")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn extracts_contact_from_back_layer() {
let def = json!({
"face": { "layers": [] },
"back": { "layers": [
{ "type": "text", "text": "Omar" },
{ "type": "contact", "fields": {
"phone": "+15551234567",
"email": "[email protected]",
"company": "RedClaw",
"title": "Founder"
}}
]}
});
let c = extract_contact(&def);
assert_eq!(c.email.as_deref(), Some("[email protected]"));
assert_eq!(c.company.as_deref(), Some("RedClaw"));
}
#[test]
fn builds_valid_vcard_structure() {
let contact = ContactInfo {
phone: Some("+15551234567".into()),
email: Some("[email protected]".into()),
company: Some("RedClaw".into()),
title: Some("Founder".into()),
website: Some("https://redclaw.dev".into()),
linkedin: None,
};
let vcf = build_vcard("Omar Sobh", &contact);
assert!(vcf.starts_with("BEGIN:VCARD\r\nVERSION:3.0\r\n"));
assert!(vcf.contains("FN:Omar Sobh\r\n"));
assert!(vcf.contains("N:Sobh;Omar;;;\r\n"));
assert!(vcf.contains("ORG:RedClaw\r\n"));
assert!(vcf.contains("TITLE:Founder\r\n"));
assert!(vcf.contains("TEL;TYPE=CELL:+15551234567\r\n"));
assert!(vcf.contains("EMAIL:[email protected]\r\n"));
assert!(vcf.trim_end().ends_with("END:VCARD"));
}
#[test]
fn escapes_special_characters() {
let contact = ContactInfo {
company: Some("Red;Claw, Inc".into()),
..Default::default()
};
let vcf = build_vcard("Solo", &contact);
assert!(vcf.contains("ORG:Red\\;Claw\\, Inc"));
}
}
@@ -0,0 +1,60 @@
//! Builds an Apple Wallet `.pkpass` for a card (PRD §10.1). Pulls contact data
//! out of the card definition (reusing the vCard extractor) and the holder name
//! from the account.
use cardclaws_db::queries::users;
use cardclaws_types::AppError;
use cardclaws_wallet::apple::{build_pkpass, PassInput};
use uuid::Uuid;
use crate::error::SqlxResultExt;
use crate::services::{card_service, vcard};
use crate::state::AppState;
pub async fn apple_pkpass(
state: &AppState,
card_id: Uuid,
user_id: Uuid,
) -> Result<Vec<u8>, AppError> {
let card = card_service::get_owned(state, card_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);
let input = PassInput {
serial_number: card.id.to_string(),
pass_type_id: state.wallet.apple_pass_type_id.clone(),
team_id: state.wallet.apple_team_id.clone(),
organization_name: state.wallet.organization_name.clone(),
holder_name: owner.display_name,
title: contact.title,
company: contact.company,
email: contact.email,
phone: contact.phone,
website: contact.website,
profile_url: format!("{}/{}", state.profile_base_url, card.handle),
background_hex: background_hex(&card.definition),
};
build_pkpass(&input, &state.brand, state.pass_signer.as_ref())
.map_err(|e| AppError::Internal(e.to_string()))
}
/// Extract the face background color (solid) from the definition, defaulting to
/// the CardClaws dark base when absent or non-solid.
fn background_hex(definition: &serde_json::Value) -> String {
definition
.get("face")
.and_then(|f| f.get("background"))
.and_then(|b| {
if b.get("type").and_then(|t| t.as_str()) == Some("solid") {
b.get("value").and_then(|v| v.as_str())
} else {
None
}
})
.unwrap_or("#101014")
.to_string()
}