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
+81
View File
@@ -0,0 +1,81 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
policy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: LOC limit (PRD §14)
run: bash scripts/loc-lint.sh
- name: No stubs/placeholders (PRD §15.4)
run: bash scripts/policy-grep.sh
backend:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: cardclaws
POSTGRES_PASSWORD: cardclaws
POSTGRES_DB: cardclaws_test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U cardclaws"
--health-interval 5s --health-timeout 3s --health-retries 10
redis:
image: redis:7
ports: ["6379:6379"]
env:
TEST_DATABASE_URL: postgres://cardclaws:cardclaws@localhost:5432/cardclaws_test
defaults:
run:
working-directory: cardclaws-backend
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Format
run: cargo fmt --all --check
- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Test
run: cargo test --workspace
profile:
runs-on: ubuntu-latest
defaults:
run:
working-directory: cardclaws-profile
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: npm ci
- name: Type-check
run: npm run check
- name: Build
run: npm run build
mobile:
runs-on: ubuntu-latest
defaults:
run:
working-directory: cardclaws-mobile
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: npm ci
- name: Type-check
run: npm run typecheck
- name: Unit tests
run: npm test
+18
View File
@@ -0,0 +1,18 @@
# Rust
/cardclaws-backend/target/
**/*.rs.bk
# Node / Expo / Astro
node_modules/
.expo/
dist/
.astro/
*.log
# Env / secrets
.env
.env.*
!.env.example
# macOS
.DS_Store
+107
View File
@@ -0,0 +1,107 @@
# CardClaws
Interactive digital business card platform (RedClaw Systems LLC). Monorepo with
three surfaces:
- `cardclaws-backend/` — Rust/Axum API (Cargo workspace).
- `cardclaws-mobile/` — React Native Expo app.
- `cardclaws-profile/` — Astro web profile.
## Mobile (Expo)
```bash
cd cardclaws-mobile
npm install
npm run typecheck # tsc --noEmit
npm test # pure-logic unit suites (no simulator needed)
EXPO_PUBLIC_API_BASE=http://localhost:8080 npm start # run in a simulator
```
See the implementation plan in `~/.claude/plans/` and the PRD for full scope.
## Backend — local development
Prerequisites: Rust (stable), Docker.
```bash
# 1. Start Postgres + Redis (Postgres on host port 5544, Redis on 6399).
docker compose up -d
# 2. Configure secrets (env-based locally; Infisical in prod).
export DATABASE_URL="postgres://cardclaws:cardclaws@localhost:5544/cardclaws"
export REDIS_URL="redis://127.0.0.1:6399"
export JWT_SECRET="dev-only-change-me"
export IP_HASH_SECRET="dev-only-change-me"
# 3. Run the API (migrations run automatically on startup).
cd cardclaws-backend
cargo run -p cardclaws-api
```
### Tests
```bash
cd cardclaws-backend
# Unit tests (no database needed).
cargo test --workspace
# Integration tests need a Postgres; set TEST_DATABASE_URL.
export TEST_DATABASE_URL="postgres://cardclaws:cardclaws@localhost:5544/cardclaws"
cargo test --workspace
```
Integration tests skip themselves cleanly when `TEST_DATABASE_URL` is unset.
### CI gates
```bash
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
bash scripts/loc-lint.sh # no source file > 1300 lines (PRD §14)
bash scripts/policy-grep.sh # no stub/placeholder markers (PRD §15.4)
```
## Status
- **B0** Monorepo + CI scaffolding — done.
- **B1** Backend foundation (config, db, auth, api) — done. `/v1/auth/*` fully
tested (register, login + lockout, magic link, refresh rotation, logout,
Apple sign-in verification).
- **B2** Card data layer + assets — done. `/v1/cards` CRUD (create, list, get,
replace, patch, archive, publish w/ per-tier active-card limit, duplicate,
public get-by-handle), R2 presigned uploads + delete, vCard export.
(PNG export deferred to the card-render milestone.)
- **Web profile** (`cardclaws-profile`, Astro SSR) — done + verified end-to-end.
`cardclaws.com/[handle]` renders the card hero (click-to-flip) + sticky
contact-actions bar; Save Contact downloads a client-built RFC 6350 vCard and
pings `contact_save`; SSR forwards the visitor IP so `profile_visit` is
attributed correctly. `npm run build` + `astro check` clean.
- **B3** Mobile app (Expo) — built. Auth (login/register), card list + create,
builder v1 (background/text/logo layers, palette, undo/redo), and the
Skia/Reanimated card viewer (3D flip + entry + ambient drift). Logic core
unit-tested (22 tests: handle validation, k-means palette, vCard, undo/redo
store, QR); full app type-checks. Component/E2E runs need a simulator.
- **B4** Apple Wallet + analytics (backend) — done. `cardclaws-wallet` crate
(pass.json builder, SHA-1 manifest, strip render, zip packager, PKCS#7
OpenSSL signer behind the `apple-signing` feature) wired at
`POST /v1/cards/{id}/wallet/apple`. Analytics ingest + summary
(`/v1/analytics/event`, `/v1/cards/{id}/analytics`) with daily-salted IP
hashing; `profile_visit` recorded on public handle lookup.
Remaining for B4: the Astro web profile (client surface) + mobile viewer/flip.
Backend tests: **67 passing** (unit + integration). Run with a live Postgres
(`TEST_DATABASE_URL`) to exercise the integration suite.
### Apple pass signing
The pass pipeline is structurally complete and tested with a fake signer. For
real, device-loadable passes build with the feature and supply the cert
material:
```bash
export APPLE_PASS_P12_BASE64="$(base64 -i PassType.p12)"
export APPLE_PASS_P12_PASSWORD="…"
export APPLE_WWDR_PEM="$(cat AppleWWDRCAG4.pem)"
cargo run -p cardclaws-api --features apple-signing
```
+3606
View File
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
[workspace]
resolver = "2"
members = [
"crates/cardclaws-types",
"crates/cardclaws-config",
"crates/cardclaws-db",
"crates/cardclaws-auth",
"crates/cardclaws-wallet",
"crates/cardclaws-api",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "UNLICENSED"
authors = ["RedClaw Systems LLC"]
rust-version = "1.80"
[workspace.dependencies]
# Internal crates
cardclaws-types = { path = "crates/cardclaws-types" }
cardclaws-config = { path = "crates/cardclaws-config" }
cardclaws-db = { path = "crates/cardclaws-db" }
cardclaws-auth = { path = "crates/cardclaws-auth" }
cardclaws-wallet = { path = "crates/cardclaws-wallet" }
# Async runtime + web
tokio = { version = "1", features = ["full"] }
axum = { version = "0.7", features = ["macros"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "limit"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Database
sqlx = { version = "0.8", features = [
"runtime-tokio",
"tls-rustls",
"postgres",
"uuid",
"chrono",
"json",
"migrate",
] }
# Cache / sessions / rate limiting
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
# Auth + crypto
jsonwebtoken = "9"
argon2 = { version = "0.5", features = ["std"] }
sha2 = "0.10"
rand = "0.8"
base64 = "0.22"
# HTTP client (Apple JWKS, OAuth)
reqwest = { version = "0.12", default-features = false, features = [
"json",
"rustls-tls",
] }
# Object storage (Cloudflare R2 via the S3 API). Presigned URLs are generated
# offline; deletes use reqwest.
rusty-s3 = "0.5"
url = "2"
# Common
uuid = { version = "1", features = ["v4", "serde"] }
sha1 = "0.10"
zip = { version = "2", default-features = false, features = ["deflate"] }
image = { version = "0.25", default-features = false, features = ["png"] }
chrono = { version = "0.4", features = ["serde"] }
thiserror = "2"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
validator = { version = "0.19", features = ["derive"] }
async-trait = "0.1"
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
@@ -0,0 +1,62 @@
[package]
name = "cardclaws-api"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[lints]
workspace = true
[features]
# Enable real Apple PKCS#7 pass signing in the binary (pulls in openssl via the
# wallet crate). Without it the dev build uses a fake signer that produces
# structurally-correct but unsigned passes — logged loudly at startup.
default = []
apple-signing = ["cardclaws-wallet/apple-signing"]
[[bin]]
name = "cardclaws-api"
path = "src/main.rs"
[dependencies]
cardclaws-types = { workspace = true }
cardclaws-config = { workspace = true }
cardclaws-db = { workspace = true }
cardclaws-auth = { workspace = true }
cardclaws-wallet = { workspace = true }
tokio = { workspace = true }
axum = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
redis = { workspace = true }
reqwest = { workspace = true }
rusty-s3 = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
sha2 = { workspace = true }
rand = { workspace = true }
base64 = { workspace = true }
validator = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
cardclaws-auth = { workspace = true }
cardclaws-wallet = { workspace = true }
cardclaws-config = { workspace = true }
tower = { workspace = true, features = ["util"] }
http-body-util = "0.1"
tokio = { workspace = true }
axum = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
sqlx = { workspace = true }
async-trait = { workspace = true }
zip = { workspace = true }
@@ -0,0 +1,128 @@
//! Object storage for user assets (logos, media) on Cloudflare R2.
//!
//! Uploads use **presigned PUT URLs**: the client uploads bytes directly to R2,
//! so large files never transit the API. Presigning is a pure HMAC operation
//! (no network), generated via `rusty-s3`. Behind an [`ObjectStore`] trait so
//! tests use an in-memory fake.
//!
//! NOTE: because uploads go straight to R2, server-side MIME re-validation and
//! ClamAV scanning (PRD §20.2) happen lazily on first access, not here — that
//! scan-on-serve path is a later milestone.
use std::sync::Mutex;
use std::time::Duration;
use async_trait::async_trait;
use cardclaws_config::R2Config;
use rusty_s3::actions::S3Action;
use rusty_s3::{Bucket, Credentials, UrlStyle};
/// How long a presigned upload URL stays valid (PRD §10.1 uses 5 min for passes;
/// reuse that here).
const PRESIGN_TTL: Duration = Duration::from_secs(300);
#[derive(Debug, thiserror::Error)]
#[error("object store error: {0}")]
pub struct StoreError(pub String);
#[async_trait]
pub trait ObjectStore: Send + Sync {
/// Return a presigned PUT URL the client can upload `key` to.
fn presign_put(&self, key: &str) -> Result<String, StoreError>;
/// Delete an object.
async fn delete(&self, key: &str) -> Result<(), StoreError>;
}
// ---- R2 -------------------------------------------------------------------
pub struct R2Store {
bucket: Bucket,
creds: Credentials,
http: reqwest::Client,
}
impl R2Store {
pub fn new(cfg: &R2Config) -> Result<Self, StoreError> {
let url = cfg
.endpoint
.parse()
.map_err(|e| StoreError(format!("{e}")))?;
// R2 ignores the region but the S3 signer requires one; "auto" is
// Cloudflare's convention.
let bucket = Bucket::new(url, UrlStyle::Path, cfg.bucket.clone(), "auto")
.map_err(|e| StoreError(e.to_string()))?;
let creds = Credentials::new(cfg.access_key.clone(), cfg.secret_key.clone());
Ok(Self {
bucket,
creds,
http: reqwest::Client::new(),
})
}
}
#[async_trait]
impl ObjectStore for R2Store {
fn presign_put(&self, key: &str) -> Result<String, StoreError> {
let action = self.bucket.put_object(Some(&self.creds), key);
Ok(action.sign(PRESIGN_TTL).to_string())
}
async fn delete(&self, key: &str) -> Result<(), StoreError> {
let url = self
.bucket
.delete_object(Some(&self.creds), key)
.sign(PRESIGN_TTL);
let resp = self
.http
.delete(url)
.send()
.await
.map_err(|e| StoreError(e.to_string()))?;
if resp.status().is_success() || resp.status().as_u16() == 404 {
Ok(())
} else {
Err(StoreError(format!("delete status {}", resp.status())))
}
}
}
// ---- In-memory fake (tests) ----------------------------------------------
#[derive(Default)]
pub struct InMemoryStore {
pub deleted: Mutex<Vec<String>>,
}
#[async_trait]
impl ObjectStore for InMemoryStore {
fn presign_put(&self, key: &str) -> Result<String, StoreError> {
Ok(format!("https://upload.test/{key}"))
}
async fn delete(&self, key: &str) -> Result<(), StoreError> {
self.deleted.lock().unwrap().push(key.to_string());
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn r2_presign_produces_https_url_containing_key() {
let cfg = R2Config {
endpoint: "https://acct.r2.cloudflarestorage.com".into(),
bucket: "cardclaws-assets".into(),
access_key: "AKIA_TEST".into(),
secret_key: "secret_test".into(),
};
let store = R2Store::new(&cfg).unwrap();
let url = store.presign_put("assets/abc/logo.png").unwrap();
assert!(url.starts_with("https://"));
assert!(url.contains("cardclaws-assets"));
assert!(url.contains("logo.png"));
assert!(url.contains("X-Amz-Signature"));
}
}
@@ -0,0 +1,113 @@
//! A tiny cache abstraction over the operations the API needs from Redis:
//! counters-with-expiry (rate limiting, login lockout) and short-lived
//! key/value (magic links). Behind a trait so tests use an in-memory fake and
//! don't require a running Redis (test-double policy, plan A3).
use std::collections::HashMap;
use std::sync::Mutex;
use async_trait::async_trait;
use redis::AsyncCommands;
#[async_trait]
pub trait Cache: Send + Sync {
/// Increment a counter, setting `ttl_secs` expiry on first creation. Returns
/// the new value. Used for fixed-window rate limits and login lockouts.
async fn incr(&self, key: &str, ttl_secs: u64) -> Result<i64, CacheError>;
/// Store a value with a TTL.
async fn set_ex(&self, key: &str, value: &str, ttl_secs: u64) -> Result<(), CacheError>;
/// Atomically fetch and delete a value (single-use tokens).
async fn get_del(&self, key: &str) -> Result<Option<String>, CacheError>;
}
#[derive(Debug, thiserror::Error)]
#[error("cache error: {0}")]
pub struct CacheError(pub String);
// ---- Redis implementation -------------------------------------------------
pub struct RedisCache {
conn: redis::aio::ConnectionManager,
}
impl RedisCache {
pub async fn connect(url: &str) -> Result<Self, CacheError> {
let client = redis::Client::open(url).map_err(|e| CacheError(e.to_string()))?;
let conn = redis::aio::ConnectionManager::new(client)
.await
.map_err(|e| CacheError(e.to_string()))?;
Ok(Self { conn })
}
}
#[async_trait]
impl Cache for RedisCache {
async fn incr(&self, key: &str, ttl_secs: u64) -> Result<i64, CacheError> {
let mut conn = self.conn.clone();
let value: i64 = conn
.incr(key, 1)
.await
.map_err(|e| CacheError(e.to_string()))?;
if value == 1 {
let _: () = conn
.expire(key, ttl_secs as i64)
.await
.map_err(|e| CacheError(e.to_string()))?;
}
Ok(value)
}
async fn set_ex(&self, key: &str, value: &str, ttl_secs: u64) -> Result<(), CacheError> {
let mut conn = self.conn.clone();
let _: () = conn
.set_ex(key, value, ttl_secs)
.await
.map_err(|e| CacheError(e.to_string()))?;
Ok(())
}
async fn get_del(&self, key: &str) -> Result<Option<String>, CacheError> {
let mut conn = self.conn.clone();
// GETDEL is atomic (Redis 6.2+).
let value: Option<String> = redis::cmd("GETDEL")
.arg(key)
.query_async(&mut conn)
.await
.map_err(|e| CacheError(e.to_string()))?;
Ok(value)
}
}
// ---- In-memory fake (tests) ----------------------------------------------
/// Non-expiring in-memory cache for tests. TTLs are accepted and ignored — tests
/// assert on counts/values, not on real-time expiry.
#[derive(Default)]
pub struct InMemoryCache {
counters: Mutex<HashMap<String, i64>>,
values: Mutex<HashMap<String, String>>,
}
#[async_trait]
impl Cache for InMemoryCache {
async fn incr(&self, key: &str, _ttl_secs: u64) -> Result<i64, CacheError> {
let mut map = self.counters.lock().unwrap();
let entry = map.entry(key.to_string()).or_insert(0);
*entry += 1;
Ok(*entry)
}
async fn set_ex(&self, key: &str, value: &str, _ttl_secs: u64) -> Result<(), CacheError> {
self.values
.lock()
.unwrap()
.insert(key.to_string(), value.to_string());
Ok(())
}
async fn get_del(&self, key: &str) -> Result<Option<String>, CacheError> {
Ok(self.values.lock().unwrap().remove(key))
}
}
@@ -0,0 +1,93 @@
//! Transactional email behind an [`EmailSender`] trait. Production uses Resend
//! (PRD §7.2); tests use [`CapturingEmailSender`] to assert on what was sent.
use std::sync::Mutex;
use async_trait::async_trait;
use serde::Serialize;
#[derive(Debug, thiserror::Error)]
#[error("email send failed: {0}")]
pub struct EmailError(pub String);
#[async_trait]
pub trait EmailSender: Send + Sync {
async fn send(&self, to: &str, subject: &str, html: &str) -> Result<(), EmailError>;
}
// ---- Resend ---------------------------------------------------------------
pub struct ResendEmailSender {
client: reqwest::Client,
api_key: String,
from: String,
}
impl ResendEmailSender {
pub fn new(api_key: String, from: String) -> Self {
Self {
client: reqwest::Client::new(),
api_key,
from,
}
}
}
#[derive(Serialize)]
struct ResendPayload<'a> {
from: &'a str,
to: [&'a str; 1],
subject: &'a str,
html: &'a str,
}
#[async_trait]
impl EmailSender for ResendEmailSender {
async fn send(&self, to: &str, subject: &str, html: &str) -> Result<(), EmailError> {
let resp = self
.client
.post("https://api.resend.com/emails")
.bearer_auth(&self.api_key)
.json(&ResendPayload {
from: &self.from,
to: [to],
subject,
html,
})
.send()
.await
.map_err(|e| EmailError(e.to_string()))?;
if resp.status().is_success() {
Ok(())
} else {
Err(EmailError(format!("resend status {}", resp.status())))
}
}
}
// ---- Capturing fake (tests) ----------------------------------------------
#[derive(Default)]
pub struct CapturingEmailSender {
pub sent: Mutex<Vec<SentEmail>>,
}
#[derive(Debug, Clone)]
pub struct SentEmail {
pub to: String,
pub subject: String,
pub html: String,
}
#[async_trait]
impl EmailSender for CapturingEmailSender {
async fn send(&self, to: &str, subject: &str, html: &str) -> Result<(), EmailError> {
self.sent.lock().unwrap().push(SentEmail {
to: to.to_string(),
subject: subject.to_string(),
html: html.to_string(),
});
Ok(())
}
}
@@ -0,0 +1,74 @@
//! HTTP error mapping. `AppError` lives in `cardclaws-types`, and `IntoResponse`
//! lives in axum, so the orphan rule forces a local newtype to bridge them.
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use cardclaws_types::{AppError, ErrorCode};
use serde::Serialize;
/// Newtype wrapper so we can implement `IntoResponse` for our error.
pub struct ApiError(pub AppError);
impl From<AppError> for ApiError {
fn from(e: AppError) -> Self {
ApiError(e)
}
}
/// Any sqlx error that escapes the query layer is an internal failure. Unique
/// violations are mapped to 409 at the call site before reaching here.
impl From<sqlx::Error> for ApiError {
fn from(e: sqlx::Error) -> Self {
ApiError(AppError::Internal(format!("db: {e}")))
}
}
#[derive(Serialize)]
struct ErrorBody {
code: ErrorCode,
message: String,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let err = self.0;
let status = match err.code() {
ErrorCode::BadRequest | ErrorCode::Validation => StatusCode::BAD_REQUEST,
ErrorCode::Unauthorized => StatusCode::UNAUTHORIZED,
ErrorCode::Forbidden => StatusCode::FORBIDDEN,
ErrorCode::NotFound => StatusCode::NOT_FOUND,
ErrorCode::Conflict => StatusCode::CONFLICT,
ErrorCode::TierLimit => StatusCode::PAYMENT_REQUIRED,
ErrorCode::RateLimited => StatusCode::TOO_MANY_REQUESTS,
ErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR,
};
// Internal errors are logged with detail but redacted in the response.
if let AppError::Internal(detail) = &err {
tracing::error!(error = %detail, "internal error");
}
let body = ErrorBody {
code: err.code(),
message: err.public_message(),
};
(status, Json(body)).into_response()
}
}
/// Convenience alias for handler return types.
pub type ApiResult<T> = Result<T, ApiError>;
/// Map a raw sqlx error into an `AppError::Internal`. Service code uses this so
/// it can keep returning the domain `AppError` (which has no sqlx dependency)
/// while still using `?` on database calls.
pub trait SqlxResultExt<T> {
fn map_db(self) -> Result<T, cardclaws_types::AppError>;
}
impl<T> SqlxResultExt<T> for Result<T, sqlx::Error> {
fn map_db(self) -> Result<T, cardclaws_types::AppError> {
self.map_err(|e| cardclaws_types::AppError::Internal(format!("db: {e}")))
}
}
@@ -0,0 +1,69 @@
//! Analytics handlers (PRD §13.5): client event ingest + owner summary.
use axum::extract::{Path, State};
use axum::http::HeaderMap;
use axum::Json;
use cardclaws_db::models::analytics::AnalyticsSummary;
use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::error::ApiResult;
use crate::middleware::auth::AuthUser;
use crate::services::analytics_service;
use crate::state::AppState;
#[derive(Deserialize)]
pub struct IngestRequest {
pub card_id: Uuid,
pub event_type: String,
}
/// Public client-side event ingest (PRD §18.1 secondary path).
pub async fn ingest_event(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<IngestRequest>,
) -> ApiResult<Json<Value>> {
let ip = client_ip(&headers);
let ua = header_str(&headers, "user-agent");
analytics_service::ingest_client_event(
&state,
req.card_id,
&req.event_type,
ip.as_deref(),
ua.as_deref(),
)
.await?;
Ok(Json(json!({ "status": "recorded" })))
}
/// Owner-only metrics summary for a card.
pub async fn summary(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<AnalyticsSummary>> {
Ok(Json(
analytics_service::summary(&state, id, user.user_id).await?,
))
}
/// Best-effort client IP from the proxy headers Cloudflare/Hetzner set. The
/// first IP in `x-forwarded-for` is the original client.
pub fn client_ip(headers: &HeaderMap) -> Option<String> {
header_str(headers, "cf-connecting-ip")
.or_else(|| {
header_str(headers, "x-forwarded-for")
.map(|xff| xff.split(',').next().unwrap_or("").trim().to_string())
})
.or_else(|| header_str(headers, "x-real-ip"))
.filter(|s| !s.is_empty())
}
pub fn header_str(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
}
@@ -0,0 +1,71 @@
//! Asset upload/delete handlers (PRD §13.7). Uploads are presigned so bytes go
//! directly to R2. Keys are namespaced per user and deletes are restricted to
//! the caller's own prefix.
use axum::extract::{Path, State};
use axum::Json;
use cardclaws_types::AppError;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use uuid::Uuid;
use crate::error::ApiResult;
use crate::middleware::auth::AuthUser;
use crate::state::AppState;
#[derive(Deserialize)]
pub struct UploadRequest {
/// File extension without the dot, e.g. "png", "jpg", "mp4".
pub ext: String,
}
#[derive(Serialize)]
pub struct UploadResponse {
/// The object key to store in the card definition / profile.
pub key: String,
/// Presigned PUT URL (valid 5 minutes) the client uploads bytes to.
pub upload_url: String,
}
/// Allowed upload extensions (server-side gate; full MIME re-validation happens
/// on first serve — see assets.rs note).
const ALLOWED_EXT: &[&str] = &[
"png", "jpg", "jpeg", "webp", "gif", "mp4", "mov", "ttf", "otf",
];
pub async fn presign_upload(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<UploadRequest>,
) -> ApiResult<Json<UploadResponse>> {
let ext = req.ext.to_ascii_lowercase();
if !ALLOWED_EXT.contains(&ext.as_str()) {
return Err(AppError::Validation(format!("unsupported file type: {ext}")).into());
}
let key = format!("assets/{}/{}.{}", user.user_id, Uuid::new_v4(), ext);
let upload_url = state
.assets
.presign_put(&key)
.map_err(|e| AppError::Internal(e.0))?;
Ok(Json(UploadResponse { key, upload_url }))
}
pub async fn delete_asset(
State(state): State<AppState>,
user: AuthUser,
Path(key): Path<String>,
) -> ApiResult<Json<Value>> {
// Only allow deleting objects under the caller's own prefix.
let prefix = format!("assets/{}/", user.user_id);
if !key.starts_with(&prefix) {
return Err(AppError::Forbidden.into());
}
state
.assets
.delete(&key)
.await
.map_err(|e| AppError::Internal(e.0))?;
Ok(Json(json!({ "status": "deleted" })))
}
@@ -0,0 +1,61 @@
//! Auth HTTP handlers (PRD §13.1). Thin: validate shape via deserialization,
//! delegate to `auth_service`, return typed JSON.
use axum::extract::State;
use axum::Json;
use cardclaws_types::auth::*;
use crate::error::ApiResult;
use crate::services::auth_service;
use crate::state::AppState;
pub async fn register(
State(state): State<AppState>,
Json(req): Json<RegisterRequest>,
) -> ApiResult<Json<TokenPair>> {
Ok(Json(auth_service::register(&state, req).await?))
}
pub async fn login(
State(state): State<AppState>,
Json(req): Json<LoginRequest>,
) -> ApiResult<Json<TokenPair>> {
Ok(Json(auth_service::login(&state, req, None).await?))
}
pub async fn magic_link_request(
State(state): State<AppState>,
Json(req): Json<MagicLinkRequest>,
) -> ApiResult<Json<serde_json::Value>> {
auth_service::magic_link_request(&state, req).await?;
Ok(Json(serde_json::json!({ "status": "sent" })))
}
pub async fn magic_link_verify(
State(state): State<AppState>,
Json(req): Json<MagicLinkVerifyRequest>,
) -> ApiResult<Json<TokenPair>> {
Ok(Json(auth_service::magic_link_verify(&state, req).await?))
}
pub async fn oauth_apple(
State(state): State<AppState>,
Json(req): Json<AppleOAuthRequest>,
) -> ApiResult<Json<TokenPair>> {
Ok(Json(auth_service::oauth_apple(&state, req).await?))
}
pub async fn refresh(
State(state): State<AppState>,
Json(req): Json<RefreshRequest>,
) -> ApiResult<Json<TokenPair>> {
Ok(Json(auth_service::refresh(&state, req).await?))
}
pub async fn logout(
State(state): State<AppState>,
Json(req): Json<RefreshRequest>,
) -> ApiResult<Json<serde_json::Value>> {
auth_service::logout(&state, &req.refresh_token).await?;
Ok(Json(serde_json::json!({ "status": "ok" })))
}
@@ -0,0 +1,145 @@
//! Card HTTP handlers (PRD §13.2). Thin: extract + delegate to `card_service`.
use axum::extract::{Path, State};
use axum::http::header;
use axum::response::{IntoResponse, Response};
use axum::Json;
use cardclaws_db::models::card::CardRow;
use serde::Deserialize;
use uuid::Uuid;
use crate::error::ApiResult;
use crate::middleware::auth::AuthUser;
use crate::services::card_service;
use crate::state::AppState;
#[derive(Deserialize)]
pub struct CreateCardRequest {
pub handle: String,
pub definition: serde_json::Value,
}
#[derive(Deserialize)]
pub struct DefinitionBody {
pub definition: serde_json::Value,
}
pub async fn list_cards(
State(state): State<AppState>,
user: AuthUser,
) -> ApiResult<Json<Vec<CardRow>>> {
Ok(Json(card_service::list(&state, user.user_id).await?))
}
pub async fn create_card(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<CreateCardRequest>,
) -> ApiResult<Json<CardRow>> {
let card = card_service::create(&state, user.user_id, &req.handle, &req.definition).await?;
Ok(Json(card))
}
pub async fn get_card(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<CardRow>> {
Ok(Json(
card_service::get_owned(&state, id, user.user_id).await?,
))
}
pub async fn replace_card(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<DefinitionBody>,
) -> ApiResult<Json<CardRow>> {
Ok(Json(
card_service::replace(&state, id, user.user_id, &req.definition).await?,
))
}
pub async fn patch_card(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<DefinitionBody>,
) -> ApiResult<Json<CardRow>> {
Ok(Json(
card_service::patch(&state, id, user.user_id, &req.definition).await?,
))
}
pub async fn delete_card(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<CardRow>> {
Ok(Json(card_service::archive(&state, id, user.user_id).await?))
}
pub async fn publish_card(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<CardRow>> {
Ok(Json(
card_service::publish(&state, id, user.user_id, user.tier).await?,
))
}
pub async fn duplicate_card(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<CardRow>> {
Ok(Json(
card_service::duplicate(&state, id, user.user_id).await?,
))
}
/// Export the card's contact data as a downloadable `.vcf` file.
pub async fn export_vcf(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Response> {
let body = card_service::export_vcf(&state, id, user.user_id).await?;
Ok((
[
(header::CONTENT_TYPE, "text/vcard; charset=utf-8"),
(
header::CONTENT_DISPOSITION,
"attachment; filename=\"contact.vcf\"",
),
],
body,
)
.into_response())
}
/// Public — no auth. Resolves the active card for a handle (§6.6) and records a
/// best-effort `profile_visit` (PRD §22 acceptance: visit recorded ≤60s).
pub async fn get_card_by_handle(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(handle): Path<String>,
) -> ApiResult<Json<card_service::PublicProfile>> {
let profile = card_service::public_profile(&state, &handle).await?;
let ip = crate::handlers::analytics::client_ip(&headers);
let ua = crate::handlers::analytics::header_str(&headers, "user-agent");
// Best-effort: a failed analytics write must not break the page load.
let _ = crate::services::analytics_service::record(
&state,
profile.card.id,
"profile_visit",
ip.as_deref(),
ua.as_deref(),
)
.await;
Ok(Json(profile))
}
@@ -0,0 +1,14 @@
//! Liveness/readiness probe for the active/passive failover health check.
use axum::extract::State;
use axum::Json;
use serde_json::{json, Value};
use crate::error::ApiResult;
use crate::state::AppState;
/// Returns 200 with `{ "status": "ok" }` if the DB is reachable.
pub async fn health(State(state): State<AppState>) -> ApiResult<Json<Value>> {
sqlx::query("SELECT 1").execute(&state.db).await?;
Ok(Json(json!({ "status": "ok" })))
}
@@ -0,0 +1,6 @@
pub mod analytics;
pub mod assets;
pub mod auth;
pub mod cards;
pub mod health;
pub mod wallet;
@@ -0,0 +1,31 @@
//! Wallet pass handlers (PRD §13.3). Returns the `.pkpass` bytes with the
//! PassKit content type so the client can hand it straight to `PKAddPasses`.
use axum::extract::{Path, State};
use axum::http::header;
use axum::response::{IntoResponse, Response};
use uuid::Uuid;
use crate::error::ApiResult;
use crate::middleware::auth::AuthUser;
use crate::services::wallet_service;
use crate::state::AppState;
pub async fn apple_pass(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Response> {
let bytes = wallet_service::apple_pkpass(&state, id, user.user_id).await?;
Ok((
[
(header::CONTENT_TYPE, "application/vnd.apple.pkpass"),
(
header::CONTENT_DISPOSITION,
"attachment; filename=\"card.pkpass\"",
),
],
bytes,
)
.into_response())
}
@@ -0,0 +1,19 @@
//! CardClaws HTTP API.
//!
//! Exposed as a library so integration tests can build the router with injected
//! test doubles (in-memory cache, capturing email, fake Apple keys). The binary
//! (`main.rs`) wires the production implementations.
pub mod assets;
pub mod cache;
pub mod email;
pub mod error;
pub mod handlers;
pub mod middleware;
pub mod router;
pub mod services;
pub mod state;
pub mod validation;
pub use router::build_router;
pub use state::AppState;
@@ -0,0 +1,113 @@
//! Production entrypoint: load config + secrets, run migrations, wire the real
//! implementations into `AppState`, and serve.
use std::sync::Arc;
use cardclaws_api::assets::R2Store;
use cardclaws_api::cache::RedisCache;
use cardclaws_api::email::ResendEmailSender;
use cardclaws_api::{build_router, AppState};
use cardclaws_auth::apple::HttpJwkProvider;
use cardclaws_auth::JwtKeys;
use cardclaws_config::{Config, EnvSecretSource, SecretSource};
use cardclaws_wallet::apple::signer::PassSigner;
use cardclaws_wallet::apple::BrandAssets;
use cardclaws_wallet::strip_renderer;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
#[tokio::main]
async fn main() -> Result<(), BoxError> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,cardclaws_api=debug".into()),
)
.init();
let secrets = EnvSecretSource;
let config = Config::load(&secrets).await?;
// Extra secrets not part of the core Config struct.
let resend_key = secrets.get("RESEND_API_KEY").await.unwrap_or_default();
let email_from = secrets
.get("EMAIL_FROM")
.await
.unwrap_or_else(|| "CardClaws <[email protected]>".into());
let apple_audience = secrets
.get("APPLE_AUDIENCE")
.await
.unwrap_or_else(|| "com.cardclaws.app".into());
let db = cardclaws_db::connect(&config.database_url).await?;
cardclaws_db::migrate(&db).await?;
tracing::info!("migrations applied");
let cache = RedisCache::connect(&config.redis_url).await?;
let assets = R2Store::new(&config.r2)?;
let pass_signer = build_pass_signer(&secrets).await?;
// Brand glyphs bundled into every pass. Solid-fill placeholders for now;
// replaced by real CardClaws artwork when design assets land.
let brand = BrandAssets {
icon_png: strip_renderer::render_solid(58, 58, "#ff3b30")?,
logo_png: strip_renderer::render_solid(160, 50, "#ffffff")?,
};
let state = AppState {
db,
cache: Arc::new(cache),
email: Arc::new(ResendEmailSender::new(resend_key, email_from)),
assets: Arc::new(assets),
jwt: JwtKeys::new(&config.jwt_secret),
apple: Arc::new(HttpJwkProvider::new()),
apple_audience,
profile_base_url: config.profile_base_url.clone(),
ip_hash_secret: config.ip_hash_secret.clone(),
wallet: config.wallet.clone(),
pass_signer,
brand: Arc::new(brand),
};
let app = build_router(state);
let listener = tokio::net::TcpListener::bind(&config.bind_addr).await?;
tracing::info!(addr = %config.bind_addr, "cardclaws-api listening");
axum::serve(listener, app).await?;
Ok(())
}
/// Build the Apple pass signer. With `apple-signing` enabled, loads the Pass
/// Type ID P12 + WWDR intermediate and signs for real; otherwise uses a fake
/// signer (dev only) and warns loudly.
#[cfg(feature = "apple-signing")]
async fn build_pass_signer(secrets: &dyn SecretSource) -> Result<Arc<dyn PassSigner>, BoxError> {
use base64::Engine;
use cardclaws_wallet::apple::signer::OpenSslSigner;
let p12_b64 = secrets
.get("APPLE_PASS_P12_BASE64")
.await
.ok_or("APPLE_PASS_P12_BASE64 not set")?;
let p12 = base64::engine::general_purpose::STANDARD.decode(p12_b64.trim())?;
let password = secrets
.get("APPLE_PASS_P12_PASSWORD")
.await
.unwrap_or_default();
let wwdr = secrets
.get("APPLE_WWDR_PEM")
.await
.ok_or("APPLE_WWDR_PEM not set")?;
let signer = OpenSslSigner::from_p12(&p12, &password, wwdr.as_bytes())?;
tracing::info!("apple pass signing enabled (PKCS#7)");
Ok(Arc::new(signer))
}
#[cfg(not(feature = "apple-signing"))]
async fn build_pass_signer(_secrets: &dyn SecretSource) -> Result<Arc<dyn PassSigner>, BoxError> {
use cardclaws_wallet::apple::signer::FakePassSigner;
tracing::warn!(
"apple-signing feature is OFF: wallet passes are UNSIGNED and will not \
load on a real device. Build with --features apple-signing for production."
);
Ok(Arc::new(FakePassSigner))
}
@@ -0,0 +1,45 @@
//! `AuthUser` extractor: validates the `Authorization: Bearer <jwt>` header and
//! yields the authenticated user id + tier. Any handler that takes `AuthUser`
//! as an argument is automatically protected.
use axum::async_trait;
use axum::extract::FromRequestParts;
use axum::http::header::AUTHORIZATION;
use axum::http::request::Parts;
use cardclaws_types::{AppError, Tier};
use uuid::Uuid;
use crate::error::ApiError;
use crate::state::AppState;
pub struct AuthUser {
pub user_id: Uuid,
pub tier: Tier,
}
#[async_trait]
impl FromRequestParts<AppState> for AuthUser {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let token = parts
.headers
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "))
.ok_or(AppError::Unauthorized)?;
let claims = state
.jwt
.decode_access(token)
.map_err(|_| AppError::Unauthorized)?;
Ok(AuthUser {
user_id: claims.sub,
tier: claims.tier,
})
}
}
@@ -0,0 +1,26 @@
//! CORS policy (PRD §20.2): only the cardclaws.com origins and registered mobile
//! app origins are allowed. Mobile apps send requests without a browser Origin,
//! so they are unaffected by CORS.
use axum::http::{HeaderValue, Method};
use tower_http::cors::CorsLayer;
pub fn layer() -> CorsLayer {
let origins = [
"https://cardclaws.com".parse::<HeaderValue>().unwrap(),
"https://www.cardclaws.com".parse::<HeaderValue>().unwrap(),
];
CorsLayer::new()
.allow_origin(origins)
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::PATCH,
Method::DELETE,
])
.allow_headers([
axum::http::header::AUTHORIZATION,
axum::http::header::CONTENT_TYPE,
])
}
@@ -0,0 +1,3 @@
pub mod auth;
pub mod cors;
pub mod rate_limit;
@@ -0,0 +1,49 @@
//! Redis-backed fixed-window rate limiting (PRD §20.2). Exposed as a guard
//! function that handlers/services call with a key, limit, and window. Fails
//! **open** on cache errors so a Redis blip never takes the API down.
use cardclaws_types::AppError;
use crate::cache::Cache;
/// Authenticated default: 100 requests / minute.
pub const AUTHED_PER_MIN: i64 = 100;
/// Unauthenticated default: 20 requests / minute.
pub const UNAUTHED_PER_MIN: i64 = 20;
/// Login lockout: 10 failed attempts / 5 minutes (PRD §20.1).
pub const LOGIN_MAX_FAILS: i64 = 10;
pub const LOGIN_WINDOW_SECS: u64 = 5 * 60;
/// Increment the counter at `key` and reject if it exceeds `limit` within
/// `window_secs`. Returns the current count on success.
pub async fn check(
cache: &dyn Cache,
key: &str,
limit: i64,
window_secs: u64,
) -> Result<i64, AppError> {
match cache.incr(key, window_secs).await {
Ok(count) if count > limit => Err(AppError::RateLimited),
Ok(count) => Ok(count),
// Fail open: never let cache unavailability break the request path.
Err(_) => Ok(0),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::InMemoryCache;
#[tokio::test]
async fn blocks_after_limit() {
let cache = InMemoryCache::default();
for _ in 0..3 {
assert!(check(&cache, "k", 3, 60).await.is_ok());
}
assert!(matches!(
check(&cache, "k", 3, 60).await,
Err(AppError::RateLimited)
));
}
}
@@ -0,0 +1,46 @@
//! Route table (PRD §13). Only the auth surface and health are mounted in B1;
//! card/wallet/profile/analytics routes attach in later phases.
use axum::routing::{get, post};
use axum::Router;
use crate::handlers::{analytics, assets, auth, cards, health, wallet};
use crate::middleware::cors;
use crate::state::AppState;
pub fn build_router(state: AppState) -> Router {
let auth_routes = Router::new()
.route("/register", post(auth::register))
.route("/login", post(auth::login))
.route("/magic-link/request", post(auth::magic_link_request))
.route("/magic-link/verify", post(auth::magic_link_verify))
.route("/oauth/apple", post(auth::oauth_apple))
.route("/refresh", post(auth::refresh))
.route("/logout", post(auth::logout));
let v1 = Router::new()
.nest("/auth", auth_routes)
.route("/cards", get(cards::list_cards).post(cards::create_card))
.route(
"/cards/:id",
get(cards::get_card)
.put(cards::replace_card)
.patch(cards::patch_card)
.delete(cards::delete_card),
)
.route("/cards/:id/publish", post(cards::publish_card))
.route("/cards/:id/duplicate", post(cards::duplicate_card))
.route("/cards/:id/export/vcf", get(cards::export_vcf))
.route("/cards/:id/wallet/apple", post(wallet::apple_pass))
.route("/cards/handle/:handle", get(cards::get_card_by_handle))
.route("/cards/:id/analytics", get(analytics::summary))
.route("/analytics/event", post(analytics::ingest_event))
.route("/assets/upload", post(assets::presign_upload))
.route("/assets/*key", axum::routing::delete(assets::delete_asset));
Router::new()
.route("/health", get(health::health))
.nest("/v1", v1)
.layer(cors::layer())
.with_state(state)
}
@@ -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()
}
@@ -0,0 +1,36 @@
//! Shared application state, cloned into every handler by axum.
use std::sync::Arc;
use cardclaws_auth::apple::JwkProvider;
use cardclaws_auth::JwtKeys;
use cardclaws_config::WalletConfig;
use cardclaws_db::Db;
use cardclaws_wallet::apple::signer::PassSigner;
use cardclaws_wallet::apple::BrandAssets;
use crate::assets::ObjectStore;
use crate::cache::Cache;
use crate::email::EmailSender;
/// All shared dependencies. `Arc`-wrapped trait objects keep `AppState: Clone`
/// cheap while allowing test doubles to be injected (cache, Apple keys).
#[derive(Clone)]
pub struct AppState {
pub db: Db,
pub cache: Arc<dyn Cache>,
pub email: Arc<dyn EmailSender>,
pub assets: Arc<dyn ObjectStore>,
pub jwt: JwtKeys,
pub apple: Arc<dyn JwkProvider>,
/// Apple Services ID / bundle id the identity token must be addressed to.
pub apple_audience: String,
/// e.g. `https://cardclaws.com` — used to build profile URLs.
pub profile_base_url: String,
/// Seed for the daily-rotating IP hash salt (PRD §18.3).
pub ip_hash_secret: String,
/// Apple Wallet pass identity + signer + bundled brand assets.
pub wallet: WalletConfig,
pub pass_signer: Arc<dyn PassSigner>,
pub brand: Arc<BrandAssets>,
}
@@ -0,0 +1,115 @@
//! Lightweight field validation shared by handlers. Mirrors the mobile
//! `handleValidation` rules so client and server agree (PRD §6.6.1, §6.8.3).
use cardclaws_types::AppError;
/// Handles: 3-30 chars, lowercase alphanumeric and hyphen, no leading/trailing
/// hyphen. Reserved handles are rejected.
pub fn validate_handle(handle: &str) -> Result<(), AppError> {
let len = handle.chars().count();
if !(3..=30).contains(&len) {
return Err(AppError::Validation(
"handle must be 3-30 characters".into(),
));
}
if handle.starts_with('-') || handle.ends_with('-') {
return Err(AppError::Validation(
"handle cannot start or end with a hyphen".into(),
));
}
if !handle
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(AppError::Validation(
"handle may only contain lowercase letters, digits, and hyphens".into(),
));
}
if is_reserved(handle) {
return Err(AppError::Conflict("handle is reserved".into()));
}
Ok(())
}
const RESERVED: &[&str] = &[
"admin",
"support",
"cardclaws",
"help",
"api",
"www",
"app",
"about",
"login",
"logout",
"register",
"settings",
"profile",
"s",
"v1",
"assets",
];
fn is_reserved(handle: &str) -> bool {
// All two-character strings are reserved (§6.8.3); the length check already
// rejects <3, so we only need the explicit list here.
RESERVED.contains(&handle)
}
pub fn validate_email(email: &str) -> Result<(), AppError> {
// Deliberately permissive: exactly one '@', non-empty local + domain, and a
// dot in the domain. Full RFC 5322 validation is not worth the surface.
let parts: Vec<&str> = email.split('@').collect();
let ok = parts.len() == 2
&& !parts[0].is_empty()
&& parts[1].contains('.')
&& !parts[1].starts_with('.')
&& !parts[1].ends_with('.');
if ok {
Ok(())
} else {
Err(AppError::Validation("invalid email address".into()))
}
}
pub fn validate_password(password: &str) -> Result<(), AppError> {
if password.chars().count() < 8 {
return Err(AppError::Validation(
"password must be at least 8 characters".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_handles_pass() {
for h in ["omar", "red-claw", "card123", "a1b"] {
assert!(validate_handle(h).is_ok(), "{h} should be valid");
}
}
#[test]
fn invalid_handles_fail() {
for h in ["ab", "-omar", "omar-", "Omar", "om ar", "admin", "api"] {
assert!(validate_handle(h).is_err(), "{h} should be invalid");
}
}
#[test]
fn email_validation() {
assert!(validate_email("[email protected]").is_ok());
assert!(validate_email("bad").is_err());
assert!(validate_email("a@b").is_err());
assert!(validate_email("a@@b.com").is_err());
}
#[test]
fn password_min_length() {
assert!(validate_password("12345678").is_ok());
assert!(validate_password("short").is_err());
}
}
@@ -0,0 +1,122 @@
//! Integration tests for analytics ingest + summary (PRD §13.5, §18).
mod common;
use axum::http::StatusCode;
use serde_json::json;
use common::{unique_handle, TestApp};
async fn create_and_publish(app: &TestApp, token: &str) -> (String, String) {
let handle = unique_handle();
let def = json!({
"face": { "layers": [], "background": { "type": "solid", "value": "#101014" } },
"back": { "layers": [] }
});
let (_, created) = app
.request(
"POST",
"/v1/cards",
Some(token),
Some(json!({"handle": handle, "definition": def})),
)
.await;
let id = created["id"].as_str().unwrap().to_string();
app.request(
"POST",
&format!("/v1/cards/{id}/publish"),
Some(token),
None,
)
.await;
(id, handle)
}
#[tokio::test]
async fn profile_visit_recorded_on_public_lookup() {
let app = require_app!();
let token = app.register_and_token().await;
let (id, handle) = create_and_publish(&app, &token).await;
// Two public lookups => two profile visits.
app.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
.await;
app.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
.await;
let (status, body) = app
.request(
"GET",
&format!("/v1/cards/{id}/analytics"),
Some(&token),
None,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["totalVisits"], 2);
assert_eq!(body["visits24h"], 2);
}
#[tokio::test]
async fn client_event_ingest_increments_summary() {
let app = require_app!();
let token = app.register_and_token().await;
let (id, _) = create_and_publish(&app, &token).await;
let (status, _) = app
.request(
"POST",
"/v1/analytics/event",
None,
Some(json!({"card_id": id, "event_type": "contact_save"})),
)
.await;
assert_eq!(status, StatusCode::OK);
let (_, body) = app
.request(
"GET",
&format!("/v1/cards/{id}/analytics"),
Some(&token),
None,
)
.await;
assert_eq!(body["contactSaves"], 1);
}
#[tokio::test]
async fn ingest_rejects_server_only_event_type() {
let app = require_app!();
let token = app.register_and_token().await;
let (id, _) = create_and_publish(&app, &token).await;
// profile_visit is server-originated; clients can't post it.
let (status, body) = app
.request(
"POST",
"/v1/analytics/event",
None,
Some(json!({"card_id": id, "event_type": "profile_visit"})),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "validation");
}
#[tokio::test]
async fn analytics_summary_requires_ownership() {
let app = require_app!();
let owner = app.register_and_token().await;
let (id, _) = create_and_publish(&app, &owner).await;
let intruder = app.register_and_token().await;
let (status, _) = app
.request(
"GET",
&format!("/v1/cards/{id}/analytics"),
Some(&intruder),
None,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
@@ -0,0 +1,138 @@
//! Integration tests for asset presigning + vCard export (PRD §13.2, §13.7).
mod common;
use axum::http::StatusCode;
use serde_json::json;
use common::unique_handle;
#[tokio::test]
async fn presign_upload_returns_key_and_url() {
let app = require_app!();
let token = app.register_and_token().await;
let (status, body) = app
.request(
"POST",
"/v1/assets/upload",
Some(&token),
Some(json!({"ext": "png"})),
)
.await;
assert_eq!(status, StatusCode::OK);
let key = body["key"].as_str().unwrap();
assert!(key.ends_with(".png"));
assert!(key.starts_with("assets/"));
assert!(body["upload_url"].as_str().unwrap().starts_with("https://"));
}
#[tokio::test]
async fn presign_upload_requires_auth() {
let app = require_app!();
let (status, _) = app
.request(
"POST",
"/v1/assets/upload",
None,
Some(json!({"ext": "png"})),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn presign_rejects_unsupported_extension() {
let app = require_app!();
let token = app.register_and_token().await;
let (status, body) = app
.request(
"POST",
"/v1/assets/upload",
Some(&token),
Some(json!({"ext": "exe"})),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "validation");
}
#[tokio::test]
async fn delete_rejects_other_users_prefix() {
let app = require_app!();
let token = app.register_and_token().await;
// A key under someone else's prefix must be forbidden.
let (status, body) = app
.request(
"DELETE",
"/v1/assets/assets/00000000-0000-0000-0000-000000000000/x.png",
Some(&token),
None,
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["code"], "forbidden");
}
#[tokio::test]
async fn delete_own_asset_succeeds() {
let app = require_app!();
let token = app.register_and_token().await;
// Presign to learn our own key, then delete it.
let (_, body) = app
.request(
"POST",
"/v1/assets/upload",
Some(&token),
Some(json!({"ext": "png"})),
)
.await;
let key = body["key"].as_str().unwrap().to_string();
// The delete route is /v1/assets/*key and the key itself starts with
// "assets/", so the full path is /v1/assets/assets/{user}/{file}.png.
let (status, _) = app
.request("DELETE", &format!("/v1/assets/{key}"), Some(&token), None)
.await;
assert_eq!(status, StatusCode::OK);
assert!(app.assets.deleted.lock().unwrap().contains(&key));
}
#[tokio::test]
async fn export_vcf_returns_vcard_with_contact() {
let app = require_app!();
let token = app.register_and_token().await;
let handle = unique_handle();
let definition = json!({
"face": { "layers": [] },
"back": { "layers": [
{ "type": "contact", "fields": {
"phone": "+15551234567",
"email": "[email protected]",
"company": "RedClaw",
"title": "Founder"
}}
]}
});
let (_, created) = app
.request(
"POST",
"/v1/cards",
Some(&token),
Some(json!({"handle": handle, "definition": definition})),
)
.await;
let id = created["id"].as_str().unwrap();
let (status, content_type, body) = app
.request_raw("GET", &format!("/v1/cards/{id}/export/vcf"), Some(&token))
.await;
assert_eq!(status, StatusCode::OK);
assert!(content_type.starts_with("text/vcard"));
assert!(body.starts_with("BEGIN:VCARD"));
assert!(body.contains("EMAIL:[email protected]"));
assert!(body.contains("ORG:RedClaw"));
assert!(body.trim_end().ends_with("END:VCARD"));
}
@@ -0,0 +1,200 @@
//! Integration tests for the auth surface (PRD §13.1, §15.2). Run against a real
//! Postgres via `TEST_DATABASE_URL`; skipped cleanly when that is unset.
mod common;
use axum::http::StatusCode;
use serde_json::json;
use common::unique_identity;
fn register_body(email: &str, handle: &str) -> serde_json::Value {
json!({
"email": email,
"password": "correct horse battery",
"handle": handle,
"display_name": "Test User",
})
}
#[tokio::test]
async fn register_success_returns_tokens() {
let app = require_app!();
let (email, handle) = unique_identity();
let (status, body) = app
.post("/v1/auth/register", register_body(&email, &handle))
.await;
assert_eq!(status, StatusCode::OK);
assert!(body["access_token"].as_str().is_some());
assert!(body["refresh_token"].as_str().is_some());
assert_eq!(body["user"]["handle"], handle);
assert_eq!(body["user"]["tier"], "free");
}
#[tokio::test]
async fn register_duplicate_is_conflict() {
let app = require_app!();
let (email, handle) = unique_identity();
let (s1, _) = app
.post("/v1/auth/register", register_body(&email, &handle))
.await;
assert_eq!(s1, StatusCode::OK);
let (s2, body) = app
.post("/v1/auth/register", register_body(&email, &handle))
.await;
assert_eq!(s2, StatusCode::CONFLICT);
assert_eq!(body["code"], "conflict");
}
#[tokio::test]
async fn register_rejects_invalid_handle() {
let app = require_app!();
let (email, _) = unique_identity();
let (status, body) = app
.post("/v1/auth/register", register_body(&email, "ab"))
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "validation");
}
#[tokio::test]
async fn login_success_then_wrong_password() {
let app = require_app!();
let (email, handle) = unique_identity();
app.post("/v1/auth/register", register_body(&email, &handle))
.await;
let (ok_status, body) = app
.post(
"/v1/auth/login",
json!({"email": email, "password": "correct horse battery"}),
)
.await;
assert_eq!(ok_status, StatusCode::OK);
assert!(body["access_token"].as_str().is_some());
let (bad_status, _) = app
.post(
"/v1/auth/login",
json!({"email": email, "password": "wrong"}),
)
.await;
assert_eq!(bad_status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn login_locks_out_after_ten_failures() {
let app = require_app!();
let (email, handle) = unique_identity();
app.post("/v1/auth/register", register_body(&email, &handle))
.await;
// 10 wrong attempts are unauthorized; the 11th trips the lockout.
for _ in 0..10 {
let (status, _) = app
.post(
"/v1/auth/login",
json!({"email": email, "password": "nope"}),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
let (status, body) = app
.post(
"/v1/auth/login",
json!({"email": email, "password": "nope"}),
)
.await;
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
assert_eq!(body["code"], "rate_limited");
}
#[tokio::test]
async fn refresh_rotates_and_invalidates_old_token() {
let app = require_app!();
let (email, handle) = unique_identity();
let (_, reg) = app
.post("/v1/auth/register", register_body(&email, &handle))
.await;
let refresh = reg["refresh_token"].as_str().unwrap().to_string();
let (status, body) = app
.post("/v1/auth/refresh", json!({"refresh_token": refresh}))
.await;
assert_eq!(status, StatusCode::OK);
assert!(body["access_token"].as_str().is_some());
// The original refresh token must no longer work (rotation).
let (reuse_status, _) = app
.post("/v1/auth/refresh", json!({"refresh_token": refresh}))
.await;
assert_eq!(reuse_status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn logout_invalidates_refresh_token() {
let app = require_app!();
let (email, handle) = unique_identity();
let (_, reg) = app
.post("/v1/auth/register", register_body(&email, &handle))
.await;
let refresh = reg["refresh_token"].as_str().unwrap().to_string();
let (status, _) = app
.post("/v1/auth/logout", json!({"refresh_token": refresh}))
.await;
assert_eq!(status, StatusCode::OK);
let (after, _) = app
.post("/v1/auth/refresh", json!({"refresh_token": refresh}))
.await;
assert_eq!(after, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn magic_link_sends_email_and_verifies() {
let app = require_app!();
let (email, handle) = unique_identity();
app.post("/v1/auth/register", register_body(&email, &handle))
.await;
let (status, _) = app
.post("/v1/auth/magic-link/request", json!({"email": email}))
.await;
assert_eq!(status, StatusCode::OK);
let token = {
let sent = app.email.sent.lock().unwrap();
let msg = sent.iter().find(|m| m.to == email).expect("email sent");
extract_token(&msg.html)
};
let (verify_status, body) = app
.post("/v1/auth/magic-link/verify", json!({"token": token}))
.await;
assert_eq!(verify_status, StatusCode::OK);
assert_eq!(body["user"]["handle"], handle);
}
#[tokio::test]
async fn magic_link_unknown_email_sends_nothing() {
let app = require_app!();
let (email, _) = unique_identity();
let (status, _) = app
.post("/v1/auth/magic-link/request", json!({"email": email}))
.await;
// Always 200 to avoid enumeration, but no email is dispatched.
assert_eq!(status, StatusCode::OK);
let sent = app.email.sent.lock().unwrap();
assert!(sent.iter().all(|m| m.to != email));
}
/// Pull the `token=...` value out of the magic-link email HTML.
fn extract_token(html: &str) -> String {
let start = html.find("token=").expect("token in link") + "token=".len();
let rest = &html[start..];
let end = rest.find('"').unwrap_or(rest.len());
rest[..end].to_string()
}
@@ -0,0 +1,225 @@
//! Integration tests for the card surface (PRD §13.2, §15.2).
mod common;
use axum::http::StatusCode;
use serde_json::{json, Value};
use common::{unique_handle, TestApp};
fn definition() -> Value {
json!({
"face": { "layers": [], "background": { "type": "solid", "value": "#101014" } },
"back": { "layers": [], "background": { "type": "solid", "value": "#101014" } }
})
}
/// Create a draft card; returns its id and handle.
async fn create_card(app: &TestApp, token: &str) -> (String, String) {
let handle = unique_handle();
let (status, body) = app
.request(
"POST",
"/v1/cards",
Some(token),
Some(json!({ "handle": handle, "definition": definition() })),
)
.await;
assert_eq!(status, StatusCode::OK, "create failed: {body}");
(body["id"].as_str().unwrap().to_string(), handle)
}
#[tokio::test]
async fn create_card_success() {
let app = require_app!();
let token = app.register_and_token().await;
let (id, handle) = create_card(&app, &token).await;
assert!(!id.is_empty());
let (status, body) = app
.request("GET", &format!("/v1/cards/{id}"), Some(&token), None)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["handle"], handle);
assert_eq!(body["status"], "draft");
assert_eq!(body["version"], 1);
}
#[tokio::test]
async fn create_card_unauthorized() {
let app = require_app!();
let (status, _) = app
.request(
"POST",
"/v1/cards",
None,
Some(json!({ "handle": unique_handle(), "definition": definition() })),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn update_card_increments_version() {
let app = require_app!();
let token = app.register_and_token().await;
let (id, _) = create_card(&app, &token).await;
let (s1, b1) = app
.request(
"PUT",
&format!("/v1/cards/{id}"),
Some(&token),
Some(json!({ "definition": definition() })),
)
.await;
assert_eq!(s1, StatusCode::OK);
assert_eq!(b1["version"], 2);
let (_, b2) = app
.request(
"PUT",
&format!("/v1/cards/{id}"),
Some(&token),
Some(json!({ "definition": definition() })),
)
.await;
assert_eq!(b2["version"], 3);
}
#[tokio::test]
async fn patch_merges_definition_keys() {
let app = require_app!();
let token = app.register_and_token().await;
let (id, _) = create_card(&app, &token).await;
let (status, body) = app
.request(
"PATCH",
&format!("/v1/cards/{id}"),
Some(&token),
Some(json!({ "definition": { "settings": { "flipDurationMs": 250 } } })),
)
.await;
assert_eq!(status, StatusCode::OK);
// Original face key survives; new settings key is merged in.
assert!(body["definition"]["face"].is_object());
assert_eq!(body["definition"]["settings"]["flipDurationMs"], 250);
}
#[tokio::test]
async fn delete_card_archives_not_destroys() {
let app = require_app!();
let token = app.register_and_token().await;
let (id, _) = create_card(&app, &token).await;
let (status, body) = app
.request("DELETE", &format!("/v1/cards/{id}"), Some(&token), None)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["status"], "archived");
// Row still exists and is fetchable by its owner.
let (get_status, _) = app
.request("GET", &format!("/v1/cards/{id}"), Some(&token), None)
.await;
assert_eq!(get_status, StatusCode::OK);
}
#[tokio::test]
async fn publish_respects_free_tier_limit() {
let app = require_app!();
let token = app.register_and_token().await; // free tier => 1 active card
let (id_a, _) = create_card(&app, &token).await;
let (id_b, _) = create_card(&app, &token).await;
let (s_a, _) = app
.request(
"POST",
&format!("/v1/cards/{id_a}/publish"),
Some(&token),
None,
)
.await;
assert_eq!(s_a, StatusCode::OK);
let (s_b, body) = app
.request(
"POST",
&format!("/v1/cards/{id_b}/publish"),
Some(&token),
None,
)
.await;
assert_eq!(s_b, StatusCode::PAYMENT_REQUIRED);
assert_eq!(body["code"], "tier_limit");
}
#[tokio::test]
async fn get_card_by_handle_public_then_404_when_archived() {
let app = require_app!();
let token = app.register_and_token().await;
let (id, handle) = create_card(&app, &token).await;
// Draft is not publicly resolvable.
let (draft_status, _) = app
.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
.await;
assert_eq!(draft_status, StatusCode::NOT_FOUND);
// Publish -> public access works without auth.
app.request(
"POST",
&format!("/v1/cards/{id}/publish"),
Some(&token),
None,
)
.await;
let (active_status, body) = app
.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
.await;
assert_eq!(active_status, StatusCode::OK);
assert_eq!(body["handle"], handle);
// Archive -> no longer publicly resolvable.
app.request("DELETE", &format!("/v1/cards/{id}"), Some(&token), None)
.await;
let (archived_status, _) = app
.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
.await;
assert_eq!(archived_status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn cannot_access_another_users_card() {
let app = require_app!();
let owner = app.register_and_token().await;
let (id, _) = create_card(&app, &owner).await;
let intruder = app.register_and_token().await;
let (status, _) = app
.request("GET", &format!("/v1/cards/{id}"), Some(&intruder), None)
.await;
// NotFound (not Forbidden) so existence isn't leaked.
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn duplicate_creates_independent_draft() {
let app = require_app!();
let token = app.register_and_token().await;
let (id, handle) = create_card(&app, &token).await;
let (status, body) = app
.request(
"POST",
&format!("/v1/cards/{id}/duplicate"),
Some(&token),
None,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_ne!(body["id"].as_str().unwrap(), id);
assert_ne!(body["handle"].as_str().unwrap(), handle);
assert_eq!(body["status"], "draft");
}
@@ -0,0 +1,240 @@
//! Shared test harness: builds the real router against a test Postgres with
//! in-memory cache, capturing email, and a no-network Apple key provider.
//!
//! Tests are skipped (not failed) when `TEST_DATABASE_URL` is unset, so a plain
//! `cargo test` without a database still passes; CI sets it (see ci.yml).
// Each test binary (`auth_test`, `cards_test`, …) includes this whole module but
// uses only a subset of its helpers, so unused-helper warnings are expected.
#![allow(dead_code)]
use std::sync::Arc;
use async_trait::async_trait;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::Router;
use http_body_util::BodyExt;
use serde_json::Value;
use tower::ServiceExt;
use cardclaws_api::assets::InMemoryStore;
use cardclaws_api::cache::InMemoryCache;
use cardclaws_api::email::CapturingEmailSender;
use cardclaws_api::{build_router, AppState};
use cardclaws_auth::apple::{AppleAuthError, AppleJwks, JwkProvider};
use cardclaws_auth::JwtKeys;
use cardclaws_config::WalletConfig;
use cardclaws_wallet::apple::signer::FakePassSigner;
use cardclaws_wallet::apple::BrandAssets;
use cardclaws_wallet::strip_renderer;
/// Apple provider that never returns a usable key — fine for tests that don't
/// exercise the Apple happy path (which needs a signed token + private key).
struct NoopApple;
#[async_trait]
impl JwkProvider for NoopApple {
async fn jwks(&self) -> Result<AppleJwks, AppleAuthError> {
Ok(AppleJwks { keys: vec![] })
}
}
pub struct TestApp {
pub router: Router,
pub email: Arc<CapturingEmailSender>,
pub assets: Arc<InMemoryStore>,
}
/// Returns `None` when no test DB is configured (test should early-return).
pub async fn try_setup() -> Option<TestApp> {
let url = std::env::var("TEST_DATABASE_URL").ok()?;
let db = cardclaws_db::connect(&url).await.expect("connect test db");
cardclaws_db::migrate(&db).await.expect("run migrations");
let email = Arc::new(CapturingEmailSender::default());
let assets = Arc::new(InMemoryStore::default());
let state = AppState {
db,
cache: Arc::new(InMemoryCache::default()),
email: email.clone(),
assets: assets.clone(),
jwt: JwtKeys::new("test-jwt-secret"),
apple: Arc::new(NoopApple),
apple_audience: "com.cardclaws.test".into(),
profile_base_url: "https://cardclaws.test".into(),
ip_hash_secret: "test-ip-salt".into(),
wallet: WalletConfig {
apple_pass_type_id: "pass.com.cardclaws.test".into(),
apple_team_id: "TEST123".into(),
organization_name: "CardClaws".into(),
},
pass_signer: Arc::new(FakePassSigner),
brand: Arc::new(BrandAssets {
icon_png: strip_renderer::render_solid(58, 58, "#ff3b30").unwrap(),
logo_png: strip_renderer::render_solid(160, 50, "#ffffff").unwrap(),
}),
};
Some(TestApp {
router: build_router(state),
email,
assets,
})
}
/// Print a skip notice and return — keeps `cargo test` green without a DB.
#[macro_export]
macro_rules! require_app {
() => {{
match $crate::common::try_setup().await {
Some(app) => app,
None => {
eprintln!("skipping: TEST_DATABASE_URL not set");
return;
}
}
}};
}
impl TestApp {
pub async fn post(&self, path: &str, body: Value) -> (StatusCode, Value) {
let req = Request::builder()
.method("POST")
.uri(path)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = self.router.clone().oneshot(req).await.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
(status, json)
}
/// Issue an arbitrary request, optionally authenticated and/or with a JSON
/// body. Returns the status and parsed JSON body.
pub async fn request(
&self,
method: &str,
path: &str,
token: Option<&str>,
body: Option<Value>,
) -> (StatusCode, Value) {
let mut builder = Request::builder().method(method).uri(path);
if let Some(t) = token {
builder = builder.header("authorization", format!("Bearer {t}"));
}
let req = match body {
Some(b) => builder
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&b).unwrap()))
.unwrap(),
None => builder.body(Body::empty()).unwrap(),
};
let resp = self.router.clone().oneshot(req).await.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
(status, json)
}
/// Like `request`, but returns the raw (status, content-type, body string)
/// for non-JSON responses such as vCard downloads.
pub async fn request_raw(
&self,
method: &str,
path: &str,
token: Option<&str>,
) -> (StatusCode, String, String) {
let mut builder = Request::builder().method(method).uri(path);
if let Some(t) = token {
builder = builder.header("authorization", format!("Bearer {t}"));
}
let resp = self
.router
.clone()
.oneshot(builder.body(Body::empty()).unwrap())
.await
.unwrap();
let status = resp.status();
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
(
status,
content_type,
String::from_utf8_lossy(&bytes).to_string(),
)
}
/// Like `request`, but returns the raw response bytes (for binary downloads
/// such as `.pkpass`). Returns (status, content-type, body bytes).
pub async fn request_bytes(
&self,
method: &str,
path: &str,
token: Option<&str>,
) -> (StatusCode, String, Vec<u8>) {
let mut builder = Request::builder().method(method).uri(path);
if let Some(t) = token {
builder = builder.header("authorization", format!("Bearer {t}"));
}
let resp = self
.router
.clone()
.oneshot(builder.body(Body::empty()).unwrap())
.await
.unwrap();
let status = resp.status();
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let bytes = resp
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.to_vec();
(status, content_type, bytes)
}
/// Register a fresh user and return its access token.
pub async fn register_and_token(&self) -> String {
let (email, handle) = unique_identity();
let (status, body) = self
.post(
"/v1/auth/register",
serde_json::json!({
"email": email,
"password": "correct horse battery",
"handle": handle,
"display_name": "Card Owner",
}),
)
.await;
assert_eq!(status, StatusCode::OK, "registration failed: {body}");
body["access_token"].as_str().unwrap().to_string()
}
}
/// Generate a unique (email, handle) pair so tests sharing one DB never collide.
pub fn unique_identity() -> (String, String) {
let id = uuid::Uuid::new_v4().simple().to_string();
let short = &id[..12];
(format!("u{short}@cardclaws.test"), format!("u{short}"))
}
/// Generate a unique card handle.
pub fn unique_handle() -> String {
let id = uuid::Uuid::new_v4().simple().to_string();
format!("c{}", &id[..12])
}
@@ -0,0 +1,86 @@
//! Integration test for the Apple Wallet pass endpoint (PRD §13.3). Exercises
//! the full path: card -> service -> wallet crate -> `.pkpass` bytes.
mod common;
use std::io::{Cursor, Read};
use axum::http::StatusCode;
use serde_json::json;
use common::unique_handle;
#[tokio::test]
async fn apple_pass_endpoint_returns_valid_pkpass() {
let app = require_app!();
let token = app.register_and_token().await;
let handle = unique_handle();
let definition = json!({
"face": { "layers": [], "background": { "type": "solid", "value": "#202028" } },
"back": { "layers": [
{ "type": "contact", "fields": {
"email": "[email protected]",
"company": "RedClaw",
"title": "Founder"
}}
]}
});
let (_, created) = app
.request(
"POST",
"/v1/cards",
Some(&token),
Some(json!({"handle": handle, "definition": definition})),
)
.await;
let id = created["id"].as_str().unwrap();
let (status, content_type, bytes) = app
.request_bytes(
"POST",
&format!("/v1/cards/{id}/wallet/apple"),
Some(&token),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(content_type, "application/vnd.apple.pkpass");
// A .pkpass is a zip — magic bytes "PK".
assert_eq!(&bytes[0..2], b"PK");
// Open the bundle and confirm pass.json's QR points at this card's profile.
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
let names: Vec<String> = (0..archive.len())
.map(|i| archive.by_index(i).unwrap().name().to_string())
.collect();
assert!(names.iter().any(|n| n == "pass.json"));
assert!(names.iter().any(|n| n == "strip.png"));
assert!(names.iter().any(|n| n == "manifest.json"));
assert!(names.iter().any(|n| n == "signature"));
let mut pass_json = String::new();
archive
.by_name("pass.json")
.unwrap()
.read_to_string(&mut pass_json)
.unwrap();
let pass: serde_json::Value = serde_json::from_str(&pass_json).unwrap();
assert_eq!(
pass["barcode"]["message"],
format!("https://cardclaws.test/{handle}")
);
}
#[tokio::test]
async fn apple_pass_requires_auth() {
let app = require_app!();
let (status, _, _) = app
.request_bytes(
"POST",
"/v1/cards/00000000-0000-0000-0000-000000000000/wallet/apple",
None,
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
@@ -0,0 +1,27 @@
[package]
name = "cardclaws-auth"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
cardclaws-types = { workspace = true }
jsonwebtoken = { workspace = true }
argon2 = { workspace = true }
sha2 = { workspace = true }
rand = { workspace = true }
base64 = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
reqwest = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
@@ -0,0 +1,138 @@
//! Sign in with Apple: verify the identity token (a JWT signed by Apple, RS256)
//! against Apple's published JWKS.
//!
//! The JWKS fetch is behind a [`JwkProvider`] trait so tests can supply keys
//! without network access (and so the real provider can cache them).
use async_trait::async_trait;
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use serde::Deserialize;
use thiserror::Error;
const APPLE_ISSUER: &str = "https://appleid.apple.com";
const APPLE_JWKS_URL: &str = "https://appleid.apple.com/auth/keys";
#[derive(Debug, Error)]
pub enum AppleAuthError {
#[error("malformed identity token")]
MalformedToken,
#[error("no matching Apple signing key for kid")]
UnknownKey,
#[error("identity token verification failed")]
Verification,
#[error("failed to fetch Apple keys: {0}")]
Fetch(String),
}
/// A single JSON Web Key (RSA) from Apple's JWKS.
#[derive(Debug, Clone, Deserialize)]
pub struct AppleJwk {
pub kid: String,
pub n: String,
pub e: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AppleJwks {
pub keys: Vec<AppleJwk>,
}
impl AppleJwks {
fn find(&self, kid: &str) -> Option<&AppleJwk> {
self.keys.iter().find(|k| k.kid == kid)
}
}
/// Verified claims we care about from the Apple identity token.
#[derive(Debug, Clone, Deserialize)]
pub struct AppleClaims {
/// Stable, unique Apple user identifier.
pub sub: String,
pub email: Option<String>,
}
/// Source of Apple's signing keys.
#[async_trait]
pub trait JwkProvider: Send + Sync {
async fn jwks(&self) -> Result<AppleJwks, AppleAuthError>;
}
/// Production provider that fetches the JWKS over HTTPS.
pub struct HttpJwkProvider {
client: reqwest::Client,
}
impl HttpJwkProvider {
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
}
}
}
impl Default for HttpJwkProvider {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl JwkProvider for HttpJwkProvider {
async fn jwks(&self) -> Result<AppleJwks, AppleAuthError> {
self.client
.get(APPLE_JWKS_URL)
.send()
.await
.map_err(|e| AppleAuthError::Fetch(e.to_string()))?
.json::<AppleJwks>()
.await
.map_err(|e| AppleAuthError::Fetch(e.to_string()))
}
}
/// Verify an Apple identity token. `audience` is the app's client_id (the
/// Services ID / bundle id) the token must be addressed to.
pub async fn verify_identity_token(
provider: &dyn JwkProvider,
token: &str,
audience: &str,
) -> Result<AppleClaims, AppleAuthError> {
let header = decode_header(token).map_err(|_| AppleAuthError::MalformedToken)?;
let kid = header.kid.ok_or(AppleAuthError::MalformedToken)?;
let jwks = provider.jwks().await?;
let jwk = jwks.find(&kid).ok_or(AppleAuthError::UnknownKey)?;
let key =
DecodingKey::from_rsa_components(&jwk.n, &jwk.e).map_err(|_| AppleAuthError::UnknownKey)?;
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[APPLE_ISSUER]);
validation.set_audience(&[audience]);
decode::<AppleClaims>(token, &key, &validation)
.map(|d| d.claims)
.map_err(|_| AppleAuthError::Verification)
}
#[cfg(test)]
mod tests {
use super::*;
struct EmptyProvider;
#[async_trait]
impl JwkProvider for EmptyProvider {
async fn jwks(&self) -> Result<AppleJwks, AppleAuthError> {
Ok(AppleJwks { keys: vec![] })
}
}
#[tokio::test]
async fn malformed_token_rejected() {
let err = verify_identity_token(&EmptyProvider, "garbage", "com.cardclaws.app")
.await
.unwrap_err();
assert!(matches!(err, AppleAuthError::MalformedToken));
}
}
@@ -0,0 +1,95 @@
//! Access-token (JWT) encode/decode. HS256 over the configured secret. Access
//! tokens are short-lived (15 min, PRD §6.8.1).
use chrono::Utc;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use thiserror::Error;
use uuid::Uuid;
use cardclaws_types::auth::AccessClaims;
use cardclaws_types::Tier;
/// Access-token lifetime in seconds (15 minutes).
pub const ACCESS_TOKEN_TTL_SECS: i64 = 15 * 60;
#[derive(Debug, Error)]
pub enum JwtError {
#[error("failed to encode token")]
Encode,
#[error("invalid or expired token")]
Invalid,
}
/// Holds the symmetric signing key. Cheap to clone (key bytes are shared).
#[derive(Clone)]
pub struct JwtKeys {
encoding: EncodingKey,
decoding: DecodingKey,
}
impl JwtKeys {
pub fn new(secret: &str) -> Self {
Self {
encoding: EncodingKey::from_secret(secret.as_bytes()),
decoding: DecodingKey::from_secret(secret.as_bytes()),
}
}
/// Mint an access token for a user at a given tier.
pub fn encode_access(&self, user_id: Uuid, tier: Tier) -> Result<String, JwtError> {
let now = Utc::now().timestamp();
let claims = AccessClaims {
sub: user_id,
tier,
iat: now,
exp: now + ACCESS_TOKEN_TTL_SECS,
};
encode(&Header::default(), &claims, &self.encoding).map_err(|_| JwtError::Encode)
}
/// Validate an access token and return its claims. Expiry is enforced.
pub fn decode_access(&self, token: &str) -> Result<AccessClaims, JwtError> {
let mut validation = Validation::default();
// Access tokens carry no `aud` claim; only `exp` is enforced.
validation.validate_aud = false;
decode::<AccessClaims>(token, &self.decoding, &validation)
.map(|data| data.claims)
.map_err(|_| JwtError::Invalid)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_then_decode_roundtrips() {
let keys = JwtKeys::new("super-secret");
let id = Uuid::new_v4();
let token = keys.encode_access(id, Tier::Pro).unwrap();
let claims = keys.decode_access(&token).unwrap();
assert_eq!(claims.sub, id);
assert_eq!(claims.tier, Tier::Pro);
assert!(claims.exp > claims.iat);
}
#[test]
fn wrong_secret_rejected() {
let keys = JwtKeys::new("secret-a");
let other = JwtKeys::new("secret-b");
let token = keys.encode_access(Uuid::new_v4(), Tier::Free).unwrap();
assert!(matches!(
other.decode_access(&token),
Err(JwtError::Invalid)
));
}
#[test]
fn garbage_token_rejected() {
let keys = JwtKeys::new("secret");
assert!(matches!(
keys.decode_access("not.a.jwt"),
Err(JwtError::Invalid)
));
}
}
@@ -0,0 +1,15 @@
//! Authentication primitives: password hashing (Argon2id), JWT access tokens,
//! opaque refresh/magic-link tokens, and Sign in with Apple verification.
//!
//! This crate is intentionally storage-agnostic — it produces and validates
//! credentials but does not touch the database or Redis. The API service wires
//! these primitives to `cardclaws-db` and the session store.
pub mod apple;
pub mod jwt;
pub mod magic_link;
pub mod password;
pub mod token;
pub use jwt::{JwtError, JwtKeys};
pub use password::PasswordError;
@@ -0,0 +1,33 @@
//! Magic-link tokens: single-use, 32 bytes of entropy, 15-minute expiry
//! (PRD §20.1). Storage (Redis, keyed by hash → email) lives in the API service;
//! this module just mints the token and its lookup hash.
use crate::token::{generate_opaque_token, hash_token};
/// Magic-link validity window in seconds (15 minutes).
pub const MAGIC_LINK_TTL_SECS: u64 = 15 * 60;
/// A freshly minted magic-link token. `token` is emailed to the user; `hash` is
/// the Redis key the verify step looks up.
pub struct MintedToken {
pub token: String,
pub hash: String,
}
pub fn mint() -> MintedToken {
let token = generate_opaque_token();
let hash = hash_token(&token);
MintedToken { token, hash }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::token::hash_token;
#[test]
fn minted_hash_matches_token() {
let m = mint();
assert_eq!(m.hash, hash_token(&m.token));
}
}
@@ -0,0 +1,67 @@
//! Argon2id password hashing with the parameters mandated by PRD §20.1
//! (time cost 3, memory 64 MiB, parallelism 4).
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::{Algorithm, Argon2, Params, Version};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PasswordError {
#[error("password hashing failed")]
Hash,
#[error("invalid stored hash")]
InvalidHash,
}
fn argon2() -> Argon2<'static> {
// m_cost is in KiB: 65536 KiB = 64 MiB.
let params = Params::new(65_536, 3, 4, None).expect("static argon2 params are valid");
Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
}
/// Hash a plaintext password into a PHC string suitable for storage.
pub fn hash_password(plaintext: &str) -> Result<String, PasswordError> {
let salt = SaltString::generate(&mut OsRng);
argon2()
.hash_password(plaintext.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|_| PasswordError::Hash)
}
/// Verify a plaintext password against a stored PHC hash. Returns `Ok(false)`
/// for a well-formed hash that simply does not match; `Err` only for a
/// malformed stored hash.
pub fn verify_password(plaintext: &str, stored_hash: &str) -> Result<bool, PasswordError> {
let parsed = PasswordHash::new(stored_hash).map_err(|_| PasswordError::InvalidHash)?;
Ok(argon2()
.verify_password(plaintext.as_bytes(), &parsed)
.is_ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_then_verify_roundtrips() {
let hash = hash_password("correct horse battery staple").unwrap();
assert!(verify_password("correct horse battery staple", &hash).unwrap());
assert!(!verify_password("wrong password", &hash).unwrap());
}
#[test]
fn distinct_salts_produce_distinct_hashes() {
let a = hash_password("same").unwrap();
let b = hash_password("same").unwrap();
assert_ne!(a, b, "each hash must use a fresh random salt");
}
#[test]
fn malformed_hash_errors() {
assert!(matches!(
verify_password("x", "not-a-phc-string"),
Err(PasswordError::InvalidHash)
));
}
}
@@ -0,0 +1,53 @@
//! Opaque token generation and hashing for refresh tokens and magic links.
//!
//! The plaintext token is returned to the client exactly once; only its SHA-256
//! hash is ever persisted, so a database leak cannot be replayed.
use base64::Engine;
use rand::RngCore;
use sha2::{Digest, Sha256};
/// Generate a cryptographically-random, URL-safe opaque token (32 bytes of
/// entropy → 43-char base64url string).
pub fn generate_opaque_token() -> String {
let mut bytes = [0u8; 32];
let mut rng = rand::rngs::OsRng;
rng.fill_bytes(&mut bytes);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
/// Hash a token for storage/lookup. Deterministic (no per-call salt) because we
/// must be able to look the token up by hash.
pub fn hash_token(token: &str) -> String {
let digest = Sha256::digest(token.as_bytes());
hex(&digest)
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tokens_are_unique_and_url_safe() {
let a = generate_opaque_token();
let b = generate_opaque_token();
assert_ne!(a, b);
assert!(!a.contains('+') && !a.contains('/') && !a.contains('='));
}
#[test]
fn hash_is_deterministic_and_64_hex_chars() {
let t = "some-token";
assert_eq!(hash_token(t), hash_token(t));
assert_eq!(hash_token(t).len(), 64);
assert_ne!(hash_token("a"), hash_token("b"));
}
}
@@ -0,0 +1,18 @@
[package]
name = "cardclaws-config"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
cardclaws-types = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
@@ -0,0 +1,10 @@
//! Configuration + secret loading.
//!
//! Secrets come from a [`SecretSource`]. In production this is Infisical (PRD
//! §7.2/§9.1); in local dev and tests it is the process environment. Code that
//! needs a secret depends on the trait, never on the concrete source, so tests
//! can inject a fake without any network.
mod loader;
pub use loader::{Config, ConfigError, EnvSecretSource, R2Config, SecretSource, WalletConfig};
@@ -0,0 +1,146 @@
use std::collections::HashMap;
use async_trait::async_trait;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("missing required config key: {0}")]
Missing(String),
#[error("invalid value for {key}: {reason}")]
Invalid { key: String, reason: String },
}
/// Source of named secrets/config values. The production implementation talks to
/// Infisical; tests and local dev use [`EnvSecretSource`].
#[async_trait]
pub trait SecretSource: Send + Sync {
async fn get(&self, key: &str) -> Option<String>;
}
/// Reads secrets from process environment variables. Loaded once at startup.
#[derive(Debug, Default)]
pub struct EnvSecretSource;
#[async_trait]
impl SecretSource for EnvSecretSource {
async fn get(&self, key: &str) -> Option<String> {
std::env::var(key).ok()
}
}
/// In-memory source, primarily for tests and callers that assemble config from
/// a non-environment source.
#[derive(Debug, Default)]
#[allow(dead_code)]
pub struct MapSecretSource(pub HashMap<String, String>);
#[async_trait]
impl SecretSource for MapSecretSource {
async fn get(&self, key: &str) -> Option<String> {
self.0.get(key).cloned()
}
}
/// Fully-resolved application configuration.
#[derive(Debug, Clone)]
pub struct Config {
pub database_url: String,
pub redis_url: String,
pub jwt_secret: String,
/// Base URL for public profiles, e.g. `https://cardclaws.com`.
pub profile_base_url: String,
/// Per-day rotating salt seed used for hashing visitor IPs (PRD §18.3).
pub ip_hash_secret: String,
pub bind_addr: String,
pub r2: R2Config,
pub wallet: WalletConfig,
}
/// Apple Wallet pass identity (PRD §10). Signing material (P12/WWDR) is loaded
/// separately and only when the `apple-signing` feature is enabled.
#[derive(Debug, Clone)]
pub struct WalletConfig {
pub apple_pass_type_id: String,
pub apple_team_id: String,
pub organization_name: String,
}
/// Cloudflare R2 (S3-compatible) object storage config. Defaults are dev
/// placeholders; production supplies real values via Infisical.
#[derive(Debug, Clone)]
pub struct R2Config {
pub endpoint: String,
pub bucket: String,
pub access_key: String,
pub secret_key: String,
}
impl Config {
/// Resolve all required config from a [`SecretSource`]. Fails fast with a
/// precise error if anything required is missing.
pub async fn load(src: &dyn SecretSource) -> Result<Self, ConfigError> {
async fn require(src: &dyn SecretSource, key: &str) -> Result<String, ConfigError> {
src.get(key)
.await
.ok_or_else(|| ConfigError::Missing(key.to_string()))
}
async fn optional(src: &dyn SecretSource, key: &str, default: &str) -> String {
src.get(key).await.unwrap_or_else(|| default.to_string())
}
Ok(Config {
database_url: require(src, "DATABASE_URL").await?,
redis_url: optional(src, "REDIS_URL", "redis://127.0.0.1:6379").await,
jwt_secret: require(src, "JWT_SECRET").await?,
profile_base_url: optional(src, "PROFILE_BASE_URL", "https://cardclaws.com").await,
ip_hash_secret: require(src, "IP_HASH_SECRET").await?,
bind_addr: optional(src, "BIND_ADDR", "0.0.0.0:8080").await,
r2: R2Config {
endpoint: optional(
src,
"R2_ENDPOINT",
"https://example.r2.cloudflarestorage.com",
)
.await,
bucket: optional(src, "R2_BUCKET", "cardclaws-assets").await,
access_key: optional(src, "R2_ACCESS_KEY", "").await,
secret_key: optional(src, "R2_SECRET_KEY", "").await,
},
wallet: WalletConfig {
apple_pass_type_id: optional(src, "APPLE_PASS_TYPE_ID", "pass.com.cardclaws.card")
.await,
apple_team_id: optional(src, "APPLE_TEAM_ID", "TEAMID0000").await,
organization_name: optional(src, "ORGANIZATION_NAME", "CardClaws").await,
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn src() -> MapSecretSource {
MapSecretSource(HashMap::from([
("DATABASE_URL".into(), "postgres://localhost/cc".into()),
("JWT_SECRET".into(), "test-secret".into()),
("IP_HASH_SECRET".into(), "salt-seed".into()),
]))
}
#[tokio::test]
async fn loads_required_and_defaults() {
let cfg = Config::load(&src()).await.unwrap();
assert_eq!(cfg.database_url, "postgres://localhost/cc");
assert_eq!(cfg.profile_base_url, "https://cardclaws.com");
assert_eq!(cfg.bind_addr, "0.0.0.0:8080");
}
#[tokio::test]
async fn missing_required_fails() {
let empty = MapSecretSource(HashMap::new());
let err = Config::load(&empty).await.unwrap_err();
assert!(matches!(err, ConfigError::Missing(k) if k == "DATABASE_URL"));
}
}
@@ -0,0 +1,22 @@
[package]
name = "cardclaws-db"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
cardclaws-types = { workspace = true }
sqlx = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
@@ -0,0 +1,68 @@
-- 0001_initial.sql — core tables (PRD §9.3) plus the cardclaws_sessions table
-- that §9.3 omitted but §6.8.1/§20.1 require.
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
handle TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
tier TEXT NOT NULL DEFAULT 'free'
CHECK (tier IN ('free', 'pro', 'team', 'enterprise')),
-- Argon2id PHC string. NULL for accounts created purely via OAuth/magic link.
password_hash TEXT,
avatar_r2_key TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE cards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
handle TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'active', 'archived')),
definition JSONB NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_cards_owner ON cards(owner_id);
CREATE INDEX idx_cards_handle ON cards(handle) WHERE status = 'active';
-- Refresh-token sessions. The refresh token itself is never stored; only a
-- SHA-256 hash so a DB leak cannot be replayed. Rotated on every use (§20.1).
CREATE TABLE cardclaws_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
refresh_token_hash TEXT NOT NULL UNIQUE,
device_fingerprint TEXT,
last_active_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_sessions_user ON cardclaws_sessions(user_id);
CREATE TABLE share_links (
token TEXT PRIMARY KEY,
card_id UUID NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
modality TEXT NOT NULL,
campaign TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ
);
CREATE INDEX idx_share_links_card ON share_links(card_id);
CREATE TABLE wallet_registrations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
card_id UUID NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
platform TEXT NOT NULL CHECK (platform IN ('apple', 'google')),
device_library_id TEXT,
push_token TEXT,
registered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_wallet_reg_card ON wallet_registrations(card_id);
@@ -0,0 +1,28 @@
-- 0002_analytics.sql — raw event log and hourly rollups (PRD §9.3 / §18).
-- Added in B2 so the analytics write path (B4) has a home before week 7.
CREATE TABLE analytics_events (
id BIGSERIAL PRIMARY KEY,
card_id UUID NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
share_token TEXT REFERENCES share_links(token),
-- SHA-256(ip + per-day salt). Never raw IP (§18.3).
ip_hash TEXT,
country TEXT,
city TEXT,
user_agent TEXT,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_analytics_card_time ON analytics_events(card_id, occurred_at DESC);
CREATE TABLE analytics_rollups_hourly (
card_id UUID NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
hour TIMESTAMPTZ NOT NULL,
visits INTEGER NOT NULL DEFAULT 0,
qr_scans INTEGER NOT NULL DEFAULT 0,
nfc_taps INTEGER NOT NULL DEFAULT 0,
saves INTEGER NOT NULL DEFAULT 0,
link_clicks INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (card_id, hour)
);
@@ -0,0 +1,11 @@
//! Database access layer (sqlx / PostgreSQL).
//!
//! Queries use the runtime `query_as` API (not the compile-time `query!`
//! macros) so the workspace builds without a live database or an offline query
//! cache. Each `queries` module owns the SQL for one table.
pub mod models;
pub mod pool;
pub mod queries;
pub use pool::{connect, migrate, Db};
@@ -0,0 +1,14 @@
use serde::Serialize;
use sqlx::FromRow;
/// Aggregated card metrics for the dashboard summary (PRD §6.7.1).
#[derive(Debug, Clone, FromRow, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalyticsSummary {
pub total_visits: i64,
pub visits_7d: i64,
pub visits_24h: i64,
pub qr_scans: i64,
pub contact_saves: i64,
pub link_clicks: i64,
}
@@ -0,0 +1,20 @@
use chrono::{DateTime, Utc};
use serde::Serialize;
use sqlx::FromRow;
use uuid::Uuid;
/// Raw `cards` row. The `definition` column holds the full `CardDefinition`
/// JSON; callers deserialize it with `serde_json` as needed. Serializable so it
/// can be returned directly as the card API response body.
#[derive(Debug, Clone, FromRow, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CardRow {
pub id: Uuid,
pub owner_id: Uuid,
pub handle: String,
pub status: String,
pub definition: serde_json::Value,
pub version: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -0,0 +1,7 @@
//! Row types that map 1:1 to table columns, plus conversions into the domain
//! types from `cardclaws-types`.
pub mod analytics;
pub mod card;
pub mod session;
pub mod user;
@@ -0,0 +1,15 @@
use chrono::{DateTime, Utc};
use sqlx::FromRow;
use uuid::Uuid;
/// Raw `cardclaws_sessions` row.
#[derive(Debug, Clone, FromRow)]
pub struct SessionRow {
pub id: Uuid,
pub user_id: Uuid,
pub refresh_token_hash: String,
pub device_fingerprint: Option<String>,
pub last_active_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}
@@ -0,0 +1,40 @@
use std::str::FromStr;
use cardclaws_types::{Tier, User};
use chrono::{DateTime, Utc};
use sqlx::FromRow;
use uuid::Uuid;
/// Raw `users` row. `tier` is stored as TEXT, so we parse it into [`Tier`] when
/// converting to the domain [`User`].
#[derive(Debug, Clone, FromRow)]
pub struct UserRow {
pub id: Uuid,
pub email: String,
pub handle: String,
pub display_name: String,
pub tier: String,
pub password_hash: Option<String>,
pub avatar_r2_key: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl UserRow {
/// Convert into the domain type. An unrecognized tier string falls back to
/// `free` rather than panicking — the DB CHECK constraint already guards it.
pub fn into_domain(self) -> User {
let tier = Tier::from_str(&self.tier).unwrap_or(Tier::Free);
User {
id: self.id,
email: self.email,
handle: self.handle,
display_name: self.display_name,
tier,
avatar_r2_key: self.avatar_r2_key,
password_hash: self.password_hash,
created_at: self.created_at,
updated_at: self.updated_at,
}
}
}
@@ -0,0 +1,20 @@
use std::time::Duration;
use sqlx::postgres::PgPoolOptions;
/// The shared connection pool type used throughout the backend.
pub type Db = sqlx::PgPool;
/// Open a pooled connection to PostgreSQL.
pub async fn connect(database_url: &str) -> Result<Db, sqlx::Error> {
PgPoolOptions::new()
.max_connections(20)
.acquire_timeout(Duration::from_secs(5))
.connect(database_url)
.await
}
/// Run all pending migrations embedded from `./migrations`.
pub async fn migrate(db: &Db) -> Result<(), sqlx::migrate::MigrateError> {
sqlx::migrate!("./migrations").run(db).await
}
@@ -0,0 +1,50 @@
//! Analytics event writes and summary aggregation (PRD §18).
//!
//! Phase 1 writes events directly to `analytics_events`. The Redis write-buffer
//! + hourly rollup path (PRD §18.1/§18.2) is a Phase 2 optimization.
use uuid::Uuid;
use crate::models::analytics::AnalyticsSummary;
use crate::Db;
pub struct NewEvent<'a> {
pub card_id: Uuid,
pub event_type: &'a str,
pub ip_hash: Option<&'a str>,
pub user_agent: Option<&'a str>,
}
pub async fn insert_event(db: &Db, ev: NewEvent<'_>) -> Result<(), sqlx::Error> {
sqlx::query(
r#"INSERT INTO analytics_events (card_id, event_type, ip_hash, user_agent)
VALUES ($1, $2, $3, $4)"#,
)
.bind(ev.card_id)
.bind(ev.event_type)
.bind(ev.ip_hash)
.bind(ev.user_agent)
.execute(db)
.await?;
Ok(())
}
/// Aggregate metrics for one card across the standard windows.
pub async fn summary(db: &Db, card_id: Uuid) -> Result<AnalyticsSummary, sqlx::Error> {
sqlx::query_as(
r#"
SELECT
COUNT(*) FILTER (WHERE event_type = 'profile_visit') AS total_visits,
COUNT(*) FILTER (WHERE event_type = 'profile_visit' AND occurred_at > now() - interval '7 days') AS visits_7d,
COUNT(*) FILTER (WHERE event_type = 'profile_visit' AND occurred_at > now() - interval '24 hours') AS visits_24h,
COUNT(*) FILTER (WHERE event_type = 'qr_scan') AS qr_scans,
COUNT(*) FILTER (WHERE event_type = 'contact_save') AS contact_saves,
COUNT(*) FILTER (WHERE event_type = 'link_click') AS link_clicks
FROM analytics_events
WHERE card_id = $1
"#,
)
.bind(card_id)
.fetch_one(db)
.await
}
@@ -0,0 +1,104 @@
//! Card table queries.
use uuid::Uuid;
use crate::models::card::CardRow;
use crate::Db;
pub struct NewCard<'a> {
pub owner_id: Uuid,
pub handle: &'a str,
pub definition: &'a serde_json::Value,
}
pub async fn insert(db: &Db, new: NewCard<'_>) -> Result<CardRow, sqlx::Error> {
sqlx::query_as(
r#"
INSERT INTO cards (owner_id, handle, definition)
VALUES ($1, $2, $3)
RETURNING id, owner_id, handle, status, definition, version,
created_at, updated_at
"#,
)
.bind(new.owner_id)
.bind(new.handle)
.bind(new.definition)
.fetch_one(db)
.await
}
pub async fn find_by_id(db: &Db, id: Uuid) -> Result<Option<CardRow>, sqlx::Error> {
sqlx::query_as(
r#"SELECT id, owner_id, handle, status, definition, version, created_at, updated_at
FROM cards WHERE id = $1"#,
)
.bind(id)
.fetch_optional(db)
.await
}
pub async fn list_by_owner(db: &Db, owner_id: Uuid) -> Result<Vec<CardRow>, sqlx::Error> {
sqlx::query_as(
r#"SELECT id, owner_id, handle, status, definition, version, created_at, updated_at
FROM cards WHERE owner_id = $1 ORDER BY created_at DESC"#,
)
.bind(owner_id)
.fetch_all(db)
.await
}
/// Public lookup: only `active` cards are resolvable by handle (PRD §6.6,
/// review A2 — the profile resolves the active card's handle).
pub async fn find_active_by_handle(db: &Db, handle: &str) -> Result<Option<CardRow>, sqlx::Error> {
sqlx::query_as(
r#"SELECT id, owner_id, handle, status, definition, version, created_at, updated_at
FROM cards WHERE handle = $1 AND status = 'active'"#,
)
.bind(handle)
.fetch_optional(db)
.await
}
/// Count a user's `active` cards — used to enforce per-tier limits (§6.8.2).
pub async fn count_active_for_owner(db: &Db, owner_id: Uuid) -> Result<i64, sqlx::Error> {
let (count,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM cards WHERE owner_id = $1 AND status = 'active'")
.bind(owner_id)
.fetch_one(db)
.await?;
Ok(count)
}
/// Replace the definition and bump the version atomically.
pub async fn update_definition(
db: &Db,
id: Uuid,
definition: &serde_json::Value,
) -> Result<CardRow, sqlx::Error> {
sqlx::query_as(
r#"
UPDATE cards
SET definition = $2, version = version + 1, updated_at = now()
WHERE id = $1
RETURNING id, owner_id, handle, status, definition, version, created_at, updated_at
"#,
)
.bind(id)
.bind(definition)
.fetch_one(db)
.await
}
pub async fn set_status(db: &Db, id: Uuid, status: &str) -> Result<CardRow, sqlx::Error> {
sqlx::query_as(
r#"
UPDATE cards SET status = $2, updated_at = now()
WHERE id = $1
RETURNING id, owner_id, handle, status, definition, version, created_at, updated_at
"#,
)
.bind(id)
.bind(status)
.fetch_one(db)
.await
}
@@ -0,0 +1,7 @@
//! SQL query functions, one module per table. Functions take `&Db` (or a
//! transaction) and return domain types or row models.
pub mod analytics;
pub mod cards;
pub mod sessions;
pub mod users;
@@ -0,0 +1,66 @@
//! Session (refresh-token) queries. Tokens are stored only as SHA-256 hashes.
use chrono::{DateTime, Utc};
use uuid::Uuid;
use crate::models::session::SessionRow;
use crate::Db;
pub struct NewSession<'a> {
pub user_id: Uuid,
pub refresh_token_hash: &'a str,
pub device_fingerprint: Option<&'a str>,
pub expires_at: DateTime<Utc>,
}
pub async fn insert(db: &Db, new: NewSession<'_>) -> Result<SessionRow, sqlx::Error> {
sqlx::query_as(
r#"
INSERT INTO cardclaws_sessions
(user_id, refresh_token_hash, device_fingerprint, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, refresh_token_hash, device_fingerprint,
last_active_at, expires_at, created_at
"#,
)
.bind(new.user_id)
.bind(new.refresh_token_hash)
.bind(new.device_fingerprint)
.bind(new.expires_at)
.fetch_one(db)
.await
}
/// Look up a live (non-expired) session by its token hash.
pub async fn find_live_by_hash(
db: &Db,
token_hash: &str,
) -> Result<Option<SessionRow>, sqlx::Error> {
sqlx::query_as(
r#"SELECT id, user_id, refresh_token_hash, device_fingerprint,
last_active_at, expires_at, created_at
FROM cardclaws_sessions
WHERE refresh_token_hash = $1 AND expires_at > now()"#,
)
.bind(token_hash)
.fetch_optional(db)
.await
}
/// Delete a session by id (used both for rotation and explicit logout).
pub async fn delete(db: &Db, id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM cardclaws_sessions WHERE id = $1")
.bind(id)
.execute(db)
.await?;
Ok(())
}
/// Delete every session for a user (logout-everywhere / account deletion).
pub async fn delete_all_for_user(db: &Db, user_id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM cardclaws_sessions WHERE user_id = $1")
.bind(user_id)
.execute(db)
.await?;
Ok(())
}
@@ -0,0 +1,85 @@
//! User table queries.
use cardclaws_types::User;
use uuid::Uuid;
use crate::models::user::UserRow;
use crate::Db;
/// Parameters for inserting a new user.
pub struct NewUser<'a> {
pub email: &'a str,
pub handle: &'a str,
pub display_name: &'a str,
pub password_hash: Option<&'a str>,
}
/// Insert a user. Returns a `Conflict`-style sqlx error if email/handle is taken
/// (the caller maps unique-violation to a 409).
pub async fn insert(db: &Db, new: NewUser<'_>) -> Result<User, sqlx::Error> {
let row: UserRow = sqlx::query_as(
r#"
INSERT INTO users (email, handle, display_name, password_hash)
VALUES ($1, $2, $3, $4)
RETURNING id, email, handle, display_name, tier, password_hash,
avatar_r2_key, created_at, updated_at
"#,
)
.bind(new.email)
.bind(new.handle)
.bind(new.display_name)
.bind(new.password_hash)
.fetch_one(db)
.await?;
Ok(row.into_domain())
}
pub async fn find_by_email(db: &Db, email: &str) -> Result<Option<User>, sqlx::Error> {
let row: Option<UserRow> = sqlx::query_as(
r#"SELECT id, email, handle, display_name, tier, password_hash,
avatar_r2_key, created_at, updated_at
FROM users WHERE email = $1"#,
)
.bind(email)
.fetch_optional(db)
.await?;
Ok(row.map(UserRow::into_domain))
}
pub async fn find_by_id(db: &Db, id: Uuid) -> Result<Option<User>, sqlx::Error> {
let row: Option<UserRow> = sqlx::query_as(
r#"SELECT id, email, handle, display_name, tier, password_hash,
avatar_r2_key, created_at, updated_at
FROM users WHERE id = $1"#,
)
.bind(id)
.fetch_optional(db)
.await?;
Ok(row.map(UserRow::into_domain))
}
/// Returns true if the handle is already taken (any user). Used when
/// auto-generating a unique handle for OAuth sign-ups.
pub async fn handle_taken(db: &Db, handle: &str) -> Result<bool, sqlx::Error> {
let exists: Option<(i32,)> = sqlx::query_as("SELECT 1 FROM users WHERE handle = $1 LIMIT 1")
.bind(handle)
.fetch_optional(db)
.await?;
Ok(exists.is_some())
}
/// Returns true if either the email or the handle is already taken. Used to give
/// a clear 409 before attempting the insert.
pub async fn email_or_handle_taken(
db: &Db,
email: &str,
handle: &str,
) -> Result<bool, sqlx::Error> {
let exists: Option<(i32,)> =
sqlx::query_as("SELECT 1 FROM users WHERE email = $1 OR handle = $2 LIMIT 1")
.bind(email)
.bind(handle)
.fetch_optional(db)
.await?;
Ok(exists.is_some())
}
@@ -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>,
}
@@ -0,0 +1,31 @@
[package]
name = "cardclaws-wallet"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[lints]
workspace = true
[features]
# Real Apple PKCS#7 signing pulls in the (heavy) openssl dependency. Off by
# default so the crate and its tests build fast against the fake signer; the
# production binary enables it. The signing code is fully implemented either way.
default = []
apple-signing = ["dep:openssl"]
[dependencies]
cardclaws-types = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha1 = { workspace = true }
zip = { workspace = true }
image = { workspace = true }
thiserror = { workspace = true }
openssl = { version = "0.10", features = ["vendored"], optional = true }
[dev-dependencies]
zip = { workspace = true }
serde_json = { workspace = true }
sha1 = { workspace = true }
@@ -0,0 +1,50 @@
//! `manifest.json`: a map of every bundled filename to the SHA-1 hash of its
//! contents (PassKit spec). The strip image is included here — that is the whole
//! point of the plan's A1 correction.
use std::collections::BTreeMap;
use sha1::{Digest, Sha1};
/// Build the manifest JSON string from the file set. Keys are sorted (BTreeMap)
/// so the output is deterministic.
pub fn build_manifest(files: &BTreeMap<String, Vec<u8>>) -> String {
let entries: BTreeMap<&String, String> = files
.iter()
.map(|(name, bytes)| (name, sha1_hex(bytes)))
.collect();
// Hand-serialize to guarantee stable key ordering regardless of serde_json
// map feature flags.
let body = entries
.iter()
.map(|(name, hash)| format!("\"{name}\":\"{hash}\""))
.collect::<Vec<_>>()
.join(",");
format!("{{{body}}}")
}
fn sha1_hex(bytes: &[u8]) -> String {
let digest = Sha1::digest(bytes);
digest.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_hashes_match_file_contents() {
let mut files = BTreeMap::new();
files.insert("pass.json".to_string(), b"{}".to_vec());
files.insert("strip.png".to_string(), b"\x89PNG".to_vec());
let manifest = build_manifest(&files);
// Known SHA-1 of "{}" is bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f.
assert!(manifest.contains("\"pass.json\":\"bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f\""));
// Strip image MUST appear in the manifest (the A1 correction).
assert!(manifest.contains("\"strip.png\":\""));
let parsed: serde_json::Value = serde_json::from_str(&manifest).unwrap();
assert!(parsed.get("strip.png").is_some());
}
}
@@ -0,0 +1,64 @@
//! Apple `.pkpass` generation.
pub mod manifest;
pub mod packager;
pub mod pass_builder;
pub mod signer;
use std::collections::BTreeMap;
use crate::error::WalletError;
use crate::strip_renderer;
use signer::PassSigner;
/// Everything needed to build one pass. Identity (`pass_type_id`, `team_id`) and
/// brand assets come from config; the rest from the card.
pub struct PassInput {
pub serial_number: String,
pub pass_type_id: String,
pub team_id: String,
pub organization_name: String,
pub holder_name: String,
pub title: Option<String>,
pub company: Option<String>,
pub email: Option<String>,
pub phone: Option<String>,
pub website: Option<String>,
/// The profile URL encoded in the QR barcode (PRD §6.3.1).
pub profile_url: String,
/// Hex background color used to render the strip image.
pub background_hex: String,
}
/// Brand assets bundled into every pass (CardClaws icon + logo PNGs).
pub struct BrandAssets {
pub icon_png: Vec<u8>,
pub logo_png: Vec<u8>,
}
/// Build a complete, signed `.pkpass` zip in memory.
///
/// Pipeline (PRD §10.1, corrected): build pass.json → render strip → assemble
/// the file set → hash every file into manifest.json → sign the manifest →
/// zip it all.
pub fn build_pkpass(
input: &PassInput,
brand: &BrandAssets,
signer: &dyn PassSigner,
) -> Result<Vec<u8>, WalletError> {
let pass_json = serde_json::to_vec(&pass_builder::build_pass_json(input))
.map_err(|e| WalletError::Build(e.to_string()))?;
let strip_png = strip_renderer::render_strip(&input.background_hex)?;
// BTreeMap keeps file ordering deterministic (stable manifest + zip).
let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
files.insert("pass.json".into(), pass_json);
files.insert("icon.png".into(), brand.icon_png.clone());
files.insert("logo.png".into(), brand.logo_png.clone());
files.insert("strip.png".into(), strip_png);
let manifest_json = manifest::build_manifest(&files);
let signature = signer.sign_manifest(manifest_json.as_bytes())?;
packager::package(&files, &manifest_json, &signature)
}
@@ -0,0 +1,71 @@
//! Zips the bundle into a `.pkpass`. Contents: every payload/brand file, plus
//! `manifest.json` and the detached `signature`.
use std::collections::BTreeMap;
use std::io::{Cursor, Write};
use zip::write::SimpleFileOptions;
use zip::ZipWriter;
use crate::error::WalletError;
pub fn package(
files: &BTreeMap<String, Vec<u8>>,
manifest_json: &str,
signature: &[u8],
) -> Result<Vec<u8>, WalletError> {
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
let opts = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let mut write = |name: &str, bytes: &[u8]| -> Result<(), WalletError> {
zip.start_file(name, opts)
.map_err(|e| WalletError::Packaging(e.to_string()))?;
zip.write_all(bytes)
.map_err(|e| WalletError::Packaging(e.to_string()))?;
Ok(())
};
for (name, bytes) in files {
write(name, bytes)?;
}
write("manifest.json", manifest_json.as_bytes())?;
write("signature", signature)?;
let cursor = zip
.finish()
.map_err(|e| WalletError::Packaging(e.to_string()))?;
Ok(cursor.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
#[test]
fn produces_openable_zip_with_required_entries() {
let mut files = BTreeMap::new();
files.insert("pass.json".to_string(), b"{}".to_vec());
files.insert("strip.png".to_string(), b"\x89PNG".to_vec());
let zip_bytes = package(&files, "{\"pass.json\":\"abc\"}", b"sig").unwrap();
let mut archive = zip::ZipArchive::new(Cursor::new(zip_bytes)).unwrap();
let mut names: Vec<String> = (0..archive.len())
.map(|i| archive.by_index(i).unwrap().name().to_string())
.collect();
names.sort();
assert_eq!(
names,
vec!["manifest.json", "pass.json", "signature", "strip.png"]
);
let mut manifest = String::new();
archive
.by_name("manifest.json")
.unwrap()
.read_to_string(&mut manifest)
.unwrap();
assert!(manifest.contains("pass.json"));
}
}
@@ -0,0 +1,109 @@
//! Constructs `pass.json` per the PassKit spec (PRD §6.3.1). Type `generic`.
use serde_json::{json, Value};
use super::PassInput;
pub fn build_pass_json(input: &PassInput) -> Value {
let mut secondary = Vec::new();
if let Some(company) = &input.company {
secondary.push(json!({ "key": "company", "label": "COMPANY", "value": company }));
}
if let Some(email) = &input.email {
secondary.push(json!({ "key": "email", "label": "EMAIL", "value": email }));
}
let mut auxiliary = Vec::new();
if let Some(phone) = &input.phone {
auxiliary.push(json!({ "key": "phone", "label": "PHONE", "value": phone }));
}
if let Some(website) = &input.website {
auxiliary.push(json!({ "key": "website", "label": "WEBSITE", "value": website }));
}
let mut back = vec![json!({
"key": "profile",
"label": "PROFILE",
"value": input.profile_url,
})];
if let Some(email) = &input.email {
back.push(json!({ "key": "back_email", "label": "EMAIL", "value": email }));
}
json!({
"formatVersion": 1,
"passTypeIdentifier": input.pass_type_id,
"teamIdentifier": input.team_id,
"organizationName": input.organization_name,
"serialNumber": input.serial_number,
"description": format!("{}'s CardClaws card", input.holder_name),
"barcode": {
"format": "PKBarcodeFormatQR",
"message": input.profile_url,
"messageEncoding": "iso-8859-1",
},
"barcodes": [{
"format": "PKBarcodeFormatQR",
"message": input.profile_url,
"messageEncoding": "iso-8859-1",
}],
"generic": {
"headerFields": [
{ "key": "name", "value": input.holder_name }
],
"primaryFields": [
{ "key": "title", "value": input.title.clone().unwrap_or_default() }
],
"secondaryFields": secondary,
"auxiliaryFields": auxiliary,
"backFields": back,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn input() -> PassInput {
PassInput {
serial_number: "serial-123".into(),
pass_type_id: "pass.com.cardclaws.card".into(),
team_id: "TEAM123".into(),
organization_name: "CardClaws".into(),
holder_name: "Omar Sobh".into(),
title: Some("Founder".into()),
company: Some("RedClaw".into()),
email: Some("[email protected]".into()),
phone: Some("+15551234567".into()),
website: Some("https://redclaw.dev".into()),
profile_url: "https://cardclaws.com/omar".into(),
background_hex: "#101014".into(),
}
}
#[test]
fn pass_json_has_required_passkit_fields() {
let pass = build_pass_json(&input());
for key in [
"formatVersion",
"passTypeIdentifier",
"teamIdentifier",
"organizationName",
"serialNumber",
"description",
"barcode",
"generic",
] {
assert!(pass.get(key).is_some(), "missing required field {key}");
}
assert_eq!(pass["formatVersion"], 1);
}
#[test]
fn barcode_message_is_profile_url() {
let pass = build_pass_json(&input());
assert_eq!(pass["barcode"]["message"], "https://cardclaws.com/omar");
assert_eq!(pass["barcode"]["format"], "PKBarcodeFormatQR");
}
}
@@ -0,0 +1,101 @@
//! Manifest signing. PassKit requires `signature` to be a PKCS#7 **detached**
//! DER signature over `manifest.json`, made with the Pass Type ID certificate,
//! its private key, and the Apple WWDR intermediate in the chain (PRD §20.3).
//!
//! The real signer uses OpenSSL and is gated behind the `apple-signing` feature
//! so default builds/tests stay fast. Tests use [`FakePassSigner`].
use crate::error::WalletError;
pub trait PassSigner: Send + Sync {
/// Produce the detached PKCS#7 DER signature over `manifest`.
fn sign_manifest(&self, manifest: &[u8]) -> Result<Vec<u8>, WalletError>;
}
/// Deterministic non-cryptographic signer for tests. Produces a stable,
/// non-empty byte string so bundle assembly can be verified without certs.
pub struct FakePassSigner;
impl PassSigner for FakePassSigner {
fn sign_manifest(&self, manifest: &[u8]) -> Result<Vec<u8>, WalletError> {
let mut sig = b"FAKE-PKCS7-SIGNATURE:".to_vec();
sig.extend_from_slice(&(manifest.len() as u32).to_be_bytes());
Ok(sig)
}
}
#[cfg(feature = "apple-signing")]
mod openssl_signer {
use openssl::pkcs7::{Pkcs7, Pkcs7Flags};
use openssl::pkey::{PKey, Private};
use openssl::stack::Stack;
use openssl::x509::X509;
use super::PassSigner;
use crate::error::WalletError;
/// Production signer holding the Pass Type ID cert + key and the WWDR
/// intermediate, loaded once at startup and kept in memory (never on disk).
pub struct OpenSslSigner {
cert: X509,
pkey: PKey<Private>,
wwdr: X509,
}
impl OpenSslSigner {
/// Load from a PKCS#12 (P12) blob (cert + key) and the WWDR intermediate
/// in PEM. `p12_password` unlocks the P12.
pub fn from_p12(
p12_der: &[u8],
p12_password: &str,
wwdr_pem: &[u8],
) -> Result<Self, WalletError> {
let p12 = openssl::pkcs12::Pkcs12::from_der(p12_der)
.map_err(|e| WalletError::Signing(e.to_string()))?;
let parsed = p12
.parse2(p12_password)
.map_err(|e| WalletError::Signing(e.to_string()))?;
let cert = parsed
.cert
.ok_or_else(|| WalletError::Signing("P12 missing certificate".into()))?;
let pkey = parsed
.pkey
.ok_or_else(|| WalletError::Signing("P12 missing private key".into()))?;
let wwdr = X509::from_pem(wwdr_pem).map_err(|e| WalletError::Signing(e.to_string()))?;
Ok(Self { cert, pkey, wwdr })
}
}
impl PassSigner for OpenSslSigner {
fn sign_manifest(&self, manifest: &[u8]) -> Result<Vec<u8>, WalletError> {
let mut certs = Stack::new().map_err(|e| WalletError::Signing(e.to_string()))?;
certs
.push(self.wwdr.clone())
.map_err(|e| WalletError::Signing(e.to_string()))?;
// Detached + binary: the signature does not embed the manifest.
let flags = Pkcs7Flags::DETACHED | Pkcs7Flags::BINARY;
let pkcs7 = Pkcs7::sign(&self.cert, &self.pkey, &certs, manifest, flags)
.map_err(|e| WalletError::Signing(e.to_string()))?;
pkcs7
.to_der()
.map_err(|e| WalletError::Signing(e.to_string()))
}
}
}
#[cfg(feature = "apple-signing")]
pub use openssl_signer::OpenSslSigner;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fake_signer_is_deterministic_and_nonempty() {
let s = FakePassSigner;
let a = s.sign_manifest(b"manifest").unwrap();
let b = s.sign_manifest(b"manifest").unwrap();
assert_eq!(a, b);
assert!(!a.is_empty());
}
}
@@ -0,0 +1,15 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum WalletError {
#[error("pass build error: {0}")]
Build(String),
#[error("strip render error: {0}")]
Render(String),
#[error("manifest error: {0}")]
Manifest(String),
#[error("signing error: {0}")]
Signing(String),
#[error("packaging error: {0}")]
Packaging(String),
}
@@ -0,0 +1,14 @@
//! Wallet pass generation (PRD §10). Phase 1 implements the Apple `.pkpass`
//! pipeline; Google Wallet (JWT) lands in Phase 2.
//!
//! KEY CORRECTION vs. the PRD draft (plan review A1): the strip image is a
//! **bundled file** inside the `.pkpass` zip and is included in the manifest
//! SHA-1 hashes — it is NOT an `stripImage` URL. The pipeline here renders the
//! strip, writes it into the bundle, hashes every file in the manifest, signs
//! the manifest (PKCS#7 detached), and zips everything.
pub mod apple;
pub mod error;
pub mod strip_renderer;
pub use error::WalletError;
@@ -0,0 +1,69 @@
//! Renders the pass strip image and other bundled PNGs.
//!
//! Phase 1 renders a solid fill derived from the card's background color, at the
//! PassKit strip dimensions (1125x432 @3x, PRD §6.3.4). A later milestone will
//! composite the actual card-face top-third; the bundle contract (a real PNG
//! hashed into the manifest) is identical either way.
use std::io::Cursor;
use image::{ImageFormat, Rgba, RgbaImage};
use crate::error::WalletError;
/// PassKit strip image size at @3x (PRD §6.3.4).
pub const STRIP_WIDTH: u32 = 1125;
pub const STRIP_HEIGHT: u32 = 432;
/// Render the strip image as a solid fill of `background_hex`.
pub fn render_strip(background_hex: &str) -> Result<Vec<u8>, WalletError> {
render_solid(STRIP_WIDTH, STRIP_HEIGHT, background_hex)
}
/// Render a solid-color PNG. Used for the strip and for placeholder brand glyphs
/// until real assets are supplied.
pub fn render_solid(width: u32, height: u32, hex: &str) -> Result<Vec<u8>, WalletError> {
let [r, g, b] = parse_hex(hex)?;
let img = RgbaImage::from_pixel(width, height, Rgba([r, g, b, 255]));
let mut buf = Cursor::new(Vec::new());
img.write_to(&mut buf, ImageFormat::Png)
.map_err(|e| WalletError::Render(e.to_string()))?;
Ok(buf.into_inner())
}
/// Parse `#RRGGBB` (or `RRGGBB`) into RGB bytes.
fn parse_hex(hex: &str) -> Result<[u8; 3], WalletError> {
let h = hex.trim_start_matches('#');
if h.len() != 6 {
return Err(WalletError::Render(format!("invalid hex color: {hex}")));
}
let parse = |s: &str| u8::from_str_radix(s, 16);
match (parse(&h[0..2]), parse(&h[2..4]), parse(&h[4..6])) {
(Ok(r), Ok(g), Ok(b)) => Ok([r, g, b]),
_ => Err(WalletError::Render(format!("invalid hex color: {hex}"))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_is_valid_png_of_expected_size() {
let bytes = render_strip("#101014").unwrap();
// PNG magic number.
assert_eq!(
&bytes[0..8],
&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]
);
let decoded = image::load_from_memory(&bytes).unwrap();
assert_eq!(decoded.width(), STRIP_WIDTH);
assert_eq!(decoded.height(), STRIP_HEIGHT);
}
#[test]
fn rejects_bad_hex() {
assert!(render_solid(10, 10, "nope").is_err());
assert!(render_solid(10, 10, "#12345").is_err());
}
}
@@ -0,0 +1,145 @@
//! End-to-end Apple pass tests (PRD §15.2). Uses the fake signer so no cert is
//! needed; the bundle structure, manifest hashes, strip image, zip, and QR URL
//! are all exercised against the real pipeline.
use std::io::{Cursor, Read};
use cardclaws_wallet::apple::signer::FakePassSigner;
use cardclaws_wallet::apple::{build_pkpass, BrandAssets, PassInput};
use cardclaws_wallet::strip_renderer;
fn input() -> PassInput {
PassInput {
serial_number: "card-abc-123".into(),
pass_type_id: "pass.com.cardclaws.card".into(),
team_id: "TEAM123".into(),
organization_name: "CardClaws".into(),
holder_name: "Omar Sobh".into(),
title: Some("Founder & CEO".into()),
company: Some("RedClaw Systems".into()),
email: Some("[email protected]".into()),
phone: Some("+15551234567".into()),
website: Some("https://redclaw.dev".into()),
profile_url: "https://cardclaws.com/omar".into(),
background_hex: "#101014".into(),
}
}
fn brand() -> BrandAssets {
BrandAssets {
icon_png: strip_renderer::render_solid(58, 58, "#ff3b30").unwrap(),
logo_png: strip_renderer::render_solid(160, 50, "#ffffff").unwrap(),
}
}
fn build() -> zip::ZipArchive<Cursor<Vec<u8>>> {
let bytes = build_pkpass(&input(), &brand(), &FakePassSigner).unwrap();
zip::ZipArchive::new(Cursor::new(bytes)).unwrap()
}
fn read_entry(archive: &mut zip::ZipArchive<Cursor<Vec<u8>>>, name: &str) -> Vec<u8> {
let mut buf = Vec::new();
archive
.by_name(name)
.unwrap()
.read_to_end(&mut buf)
.unwrap();
buf
}
#[test]
fn test_packager_produces_valid_zip() {
let mut archive = build();
let mut names: Vec<String> = (0..archive.len())
.map(|i| archive.by_index(i).unwrap().name().to_string())
.collect();
names.sort();
assert_eq!(
names,
vec![
"icon.png",
"logo.png",
"manifest.json",
"pass.json",
"signature",
"strip.png",
]
);
}
#[test]
fn test_pass_json_structure_valid() {
let mut archive = build();
let pass: serde_json::Value =
serde_json::from_slice(&read_entry(&mut archive, "pass.json")).unwrap();
for key in [
"formatVersion",
"passTypeIdentifier",
"teamIdentifier",
"serialNumber",
"generic",
"barcode",
] {
assert!(pass.get(key).is_some(), "pass.json missing {key}");
}
assert_eq!(pass["generic"]["headerFields"][0]["value"], "Omar Sobh");
assert_eq!(
pass["generic"]["primaryFields"][0]["value"],
"Founder & CEO"
);
}
#[test]
fn test_manifest_sha1_correct() {
let mut archive = build();
let manifest: serde_json::Value =
serde_json::from_slice(&read_entry(&mut archive, "manifest.json")).unwrap();
// Every bundled file (including strip.png) must have a manifest entry whose
// hash matches the actual bytes in the zip.
for name in ["pass.json", "icon.png", "logo.png", "strip.png"] {
let bytes = read_entry(&mut archive, name);
let expected = sha1_hex(&bytes);
assert_eq!(
manifest[name].as_str().unwrap(),
expected,
"manifest hash mismatch for {name}"
);
}
}
#[test]
fn test_strip_image_is_bundled_not_a_url() {
// The plan's A1 correction: strip image is a real bundled PNG referenced in
// the manifest, and pass.json carries NO stripImage URL field.
let mut archive = build();
let strip = read_entry(&mut archive, "strip.png");
assert_eq!(&strip[0..4], &[0x89, b'P', b'N', b'G']);
let pass: serde_json::Value =
serde_json::from_slice(&read_entry(&mut archive, "pass.json")).unwrap();
assert!(pass.get("stripImage").is_none());
}
#[test]
fn test_signature_present_and_nonempty() {
let mut archive = build();
let sig = read_entry(&mut archive, "signature");
assert!(!sig.is_empty());
}
#[test]
fn test_pass_qr_url_matches_profile() {
let mut archive = build();
let pass: serde_json::Value =
serde_json::from_slice(&read_entry(&mut archive, "pass.json")).unwrap();
assert_eq!(pass["barcode"]["message"], "https://cardclaws.com/omar");
}
fn sha1_hex(bytes: &[u8]) -> String {
use sha1::{Digest, Sha1};
Sha1::digest(bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
@@ -0,0 +1,107 @@
import { MAX_HISTORY, newLayerId, useCardStore } from "../../src/stores/cardStore";
import { CardDefinition, DEFAULT_SETTINGS, emptySide, TextLayer } from "../../src/types/card";
function baseCard(): CardDefinition {
return {
id: "card-1",
ownerId: "user-1",
handle: "omar",
version: 1,
face: emptySide(),
back: emptySide(),
palette: { colors: [] },
settings: DEFAULT_SETTINGS,
};
}
function textLayer(text: string): TextLayer {
return {
id: newLayerId(),
type: "text",
x: 0.1,
y: 0.1,
width: 0.8,
height: 0.1,
opacity: 1,
zIndex: 1,
text,
fontFamily: "System",
fontWeight: 700,
fontSize: 28,
lineHeight: 32,
letterSpacing: 0,
color: "#ffffff",
align: "left",
};
}
beforeEach(() => {
useCardStore.getState().load(baseCard());
});
describe("cardStore undo/redo", () => {
it("starts with no history", () => {
const s = useCardStore.getState();
expect(s.canUndo()).toBe(false);
expect(s.canRedo()).toBe(false);
expect(s.card?.face.layers).toHaveLength(0);
});
it("adds a layer and can undo/redo it", () => {
const s = useCardStore.getState();
s.addLayer("face", textLayer("Omar"));
expect(useCardStore.getState().card?.face.layers).toHaveLength(1);
expect(useCardStore.getState().canUndo()).toBe(true);
useCardStore.getState().undo();
expect(useCardStore.getState().card?.face.layers).toHaveLength(0);
expect(useCardStore.getState().canRedo()).toBe(true);
useCardStore.getState().redo();
expect(useCardStore.getState().card?.face.layers).toHaveLength(1);
});
it("a new edit clears the redo stack", () => {
const s = useCardStore.getState();
s.addLayer("face", textLayer("A"));
useCardStore.getState().undo();
expect(useCardStore.getState().canRedo()).toBe(true);
useCardStore.getState().addLayer("face", textLayer("B"));
expect(useCardStore.getState().canRedo()).toBe(false);
expect(useCardStore.getState().card?.face.layers[0]).toMatchObject({ text: "B" });
});
it("updateLayer is reversible", () => {
const layer = textLayer("Before");
useCardStore.getState().addLayer("face", layer);
useCardStore.getState().updateLayer("face", layer.id, { text: "After" } as Partial<TextLayer>);
const after = useCardStore.getState().card?.face.layers[0] as TextLayer;
expect(after.text).toBe("After");
useCardStore.getState().undo();
const before = useCardStore.getState().card?.face.layers[0] as TextLayer;
expect(before.text).toBe("Before");
});
it("caps history at MAX_HISTORY and can still undo that many times", () => {
for (let i = 0; i < MAX_HISTORY + 10; i++) {
useCardStore.getState().addLayer("face", textLayer(`L${i}`));
}
expect(useCardStore.getState().past.length).toBe(MAX_HISTORY);
let undos = 0;
while (useCardStore.getState().canUndo()) {
useCardStore.getState().undo();
undos++;
}
expect(undos).toBe(MAX_HISTORY);
});
it("undo/redo are no-ops at the ends", () => {
expect(() => useCardStore.getState().undo()).not.toThrow();
expect(() => useCardStore.getState().redo()).not.toThrow();
expect(useCardStore.getState().card?.face.layers).toHaveLength(0);
});
});
@@ -0,0 +1,48 @@
import { quantize } from "../../src/engine/renderer/colorQuantizer";
/** Build an RGBA buffer from a list of [r,g,b] colors, each repeated `count`x. */
function buffer(colors: Array<[number, number, number, number]>): number[] {
const out: number[] = [];
for (const [r, g, b, count] of colors) {
for (let i = 0; i < count; i++) out.push(r, g, b, 255);
}
return out;
}
describe("colorQuantizer (k-means)", () => {
it("recovers the dominant colors from a two-color image", () => {
// 70 red pixels, 30 blue pixels.
const rgba = buffer([
[255, 0, 0, 70],
[0, 0, 255, 30],
]);
const palette = quantize(rgba, { k: 2 });
expect(palette.length).toBe(2);
// Most dominant cluster first → red.
expect(palette[0]).toBe("#ff0000");
expect(palette).toContain("#0000ff");
});
it("skips fully transparent pixels", () => {
const rgba = [
255, 255, 255, 0, // transparent white — ignored
0, 128, 0, 255, // opaque green
0, 128, 0, 255,
];
const palette = quantize(rgba, { k: 4 });
expect(palette).toEqual(["#008000"]);
});
it("returns empty for an empty buffer", () => {
expect(quantize([], { k: 8 })).toEqual([]);
});
it("is deterministic across runs", () => {
const rgba = buffer([
[10, 20, 30, 40],
[200, 100, 50, 25],
[5, 250, 120, 35],
]);
expect(quantize(rgba, { k: 3 })).toEqual(quantize(rgba, { k: 3 }));
});
});
@@ -0,0 +1,30 @@
import { isValidHandle, validateHandle } from "../../src/utils/handleValidation";
describe("handleValidation", () => {
it("accepts valid handles", () => {
for (const h of ["omar", "red-claw", "card123", "a1b"]) {
expect(isValidHandle(h)).toBe(true);
}
});
it("rejects too short / too long", () => {
expect(validateHandle("ab")).toBe("too_short");
expect(validateHandle("a".repeat(31))).toBe("too_long");
});
it("rejects leading/trailing hyphen", () => {
expect(validateHandle("-omar")).toBe("leading_or_trailing_hyphen");
expect(validateHandle("omar-")).toBe("leading_or_trailing_hyphen");
});
it("rejects invalid characters", () => {
expect(validateHandle("Omar")).toBe("invalid_chars");
expect(validateHandle("om ar")).toBe("invalid_chars");
expect(validateHandle("omar!")).toBe("invalid_chars");
});
it("rejects reserved handles", () => {
expect(validateHandle("admin")).toBe("reserved");
expect(validateHandle("api")).toBe("reserved");
});
});
@@ -0,0 +1,25 @@
import { qrMatrix } from "../../src/engine/renderer/qrGenerator";
describe("qrGenerator", () => {
it("produces a non-empty square matrix", () => {
const m = qrMatrix("https://cardclaws.com/omar");
expect(m.length).toBeGreaterThan(0);
expect(m.every((row) => row.length === m.length)).toBe(true);
});
it("encodes a finder pattern in the top-left corner", () => {
// Every QR code has a 7x7 finder pattern at (0,0): dark border, light ring,
// dark 3x3 core.
const m = qrMatrix("hello");
expect(m[0].slice(0, 7).every((d) => d)).toBe(true); // top row dark
expect(m[1][0]).toBe(true);
expect(m[1][1]).toBe(false); // inner light ring
expect(m[3][3]).toBe(true); // dark core
});
it("longer payloads need a larger matrix", () => {
const small = qrMatrix("a");
const large = qrMatrix("x".repeat(300));
expect(large.length).toBeGreaterThan(small.length);
});
});
@@ -0,0 +1,48 @@
import { buildVcard } from "../../src/engine/renderer/vcfExporter";
describe("vcfExporter (RFC 6350)", () => {
it("produces a well-formed vCard 3.0", () => {
const vcf = buildVcard("Omar Sobh", {
title: "Founder",
company: "RedClaw",
email: "[email protected]",
phone: "+15551234567",
website: "https://redclaw.dev",
});
expect(vcf.startsWith("BEGIN:VCARD\r\nVERSION:3.0\r\n")).toBe(true);
expect(vcf).toContain("FN:Omar Sobh\r\n");
expect(vcf).toContain("N:Sobh;Omar;;;\r\n");
expect(vcf).toContain("ORG:RedClaw\r\n");
expect(vcf).toContain("TITLE:Founder\r\n");
expect(vcf).toContain("TEL;TYPE=CELL:+15551234567\r\n");
expect(vcf).toContain("EMAIL:[email protected]\r\n");
expect(vcf.trimEnd().endsWith("END:VCARD")).toBe(true);
});
it("omits absent fields", () => {
const vcf = buildVcard("Solo", {});
expect(vcf).not.toContain("ORG:");
expect(vcf).not.toContain("TEL");
expect(vcf).toContain("FN:Solo\r\n");
});
it("escapes special characters", () => {
const vcf = buildVcard("Solo", { company: "Red;Claw, Inc" });
expect(vcf).toContain("ORG:Red\\;Claw\\, Inc\r\n");
});
it("matches the backend output byte-for-byte for a known input", () => {
const vcf = buildVcard("Omar Sobh", {
title: "Founder & CEO",
company: "RedClaw Systems",
email: "[email protected]",
phone: "+15551234567",
website: "https://redclaw.dev",
});
const expected =
"BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Omar Sobh\r\nN:Sobh;Omar;;;\r\n" +
"ORG:RedClaw Systems\r\nTITLE:Founder & CEO\r\nTEL;TYPE=CELL:+15551234567\r\n" +
"EMAIL:[email protected]\r\nURL:https://redclaw.dev\r\nEND:VCARD\r\n";
expect(vcf).toBe(expected);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { ExpoConfig } from "expo/config";
// Expo app configuration (PRD §8). iOS-first for Phase 1.
const config: ExpoConfig = {
name: "CardClaws",
slug: "cardclaws",
scheme: "cardclaws",
version: "0.1.0",
orientation: "portrait",
userInterfaceStyle: "automatic",
ios: {
bundleIdentifier: "com.cardclaws.app",
supportsTablet: false,
},
android: {
package: "com.cardclaws.app",
},
plugins: ["expo-router"],
experiments: {
typedRoutes: true,
},
extra: {
apiBase: process.env.EXPO_PUBLIC_API_BASE ?? "http://localhost:8080",
},
};
export default config;
+124
View File
@@ -0,0 +1,124 @@
import { useRouter } from "expo-router";
import { useState } from "react";
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { login, register } from "../../src/api/auth";
import { validateHandle } from "../../src/utils/handleValidation";
export default function LoginScreen() {
const router = useRouter();
const [mode, setMode] = useState<"login" | "register">("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [handle, setHandle] = useState("");
const [displayName, setDisplayName] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async () => {
setError(null);
if (mode === "register") {
const handleErr = validateHandle(handle);
if (handleErr) {
setError(`Handle invalid: ${handleErr.replace(/_/g, " ")}`);
return;
}
}
setBusy(true);
try {
if (mode === "register") {
await register({ email, password, handle, displayName });
} else {
await login(email, password);
}
router.replace("/(tabs)/cards");
} catch {
setError(mode === "login" ? "Invalid email or password" : "Could not create account");
} finally {
setBusy(false);
}
};
return (
<View style={styles.root}>
<Text style={styles.title}>CardClaws</Text>
<Text style={styles.tagline}>The other side of you.</Text>
{mode === "register" && (
<>
<Field placeholder="Display name" value={displayName} onChangeText={setDisplayName} />
<Field
placeholder="Handle (cardclaws.com/you)"
value={handle}
onChangeText={setHandle}
autoCapitalize="none"
/>
</>
)}
<Field
placeholder="Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<Field placeholder="Password" value={password} onChangeText={setPassword} secureTextEntry />
{error && <Text style={styles.error}>{error}</Text>}
<Pressable style={styles.primary} onPress={submit} disabled={busy}>
{busy ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.primaryText}>{mode === "login" ? "Sign in" : "Create account"}</Text>
)}
</Pressable>
<Pressable onPress={() => setMode(mode === "login" ? "register" : "login")}>
<Text style={styles.switch}>
{mode === "login" ? "Need an account? Register" : "Have an account? Sign in"}
</Text>
</Pressable>
</View>
);
}
function Field(props: React.ComponentProps<typeof TextInput>) {
return (
<TextInput
{...props}
style={styles.input}
placeholderTextColor="#6b6b70"
/>
);
}
const styles = StyleSheet.create({
root: { flex: 1, justifyContent: "center", padding: 24, gap: 12, backgroundColor: "#0a0a0c" },
title: { color: "#f5f5f7", fontSize: 40, fontWeight: "800", letterSpacing: -1 },
tagline: { color: "#9a9aa0", marginBottom: 24 },
input: {
backgroundColor: "#1a1a1f",
color: "#f5f5f7",
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 14,
fontSize: 16,
},
error: { color: "#ff453a" },
primary: {
backgroundColor: "#ff3b30",
borderRadius: 14,
paddingVertical: 16,
alignItems: "center",
marginTop: 8,
},
primaryText: { color: "#fff", fontWeight: "700", fontSize: 17 },
switch: { color: "#9a9aa0", textAlign: "center", marginTop: 16 },
});
+18
View File
@@ -0,0 +1,18 @@
import { Tabs } from "expo-router";
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
headerStyle: { backgroundColor: "#0a0a0c" },
headerTintColor: "#f5f5f7",
tabBarStyle: { backgroundColor: "#0a0a0c", borderTopColor: "#1a1a1f" },
tabBarActiveTintColor: "#ff3b30",
tabBarInactiveTintColor: "#6b6b70",
}}
>
<Tabs.Screen name="cards" options={{ title: "Cards" }} />
<Tabs.Screen name="settings" options={{ title: "Settings" }} />
</Tabs>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { FlatList, Pressable, StyleSheet, Text, View } from "react-native";
import { createCard, listCards } from "../../src/api/cards";
import { useAuthStore } from "../../src/stores/authStore";
import { CardDefinition, DEFAULT_SETTINGS, emptySide } from "../../src/types/card";
function defaultCard(handle: string, ownerId: string): CardDefinition {
return {
id: "",
ownerId,
handle,
version: 1,
face: emptySide("#1b1b2f"),
back: emptySide("#1b1b2f"),
palette: {
colors: [
{ name: "Ocean", hex: "#1b1b2f", role: "background" },
{ name: "Snow", hex: "#f5f5f7", role: "text" },
{ name: "Claw", hex: "#ff3b30", role: "accent" },
],
},
settings: DEFAULT_SETTINGS,
};
}
export default function CardsScreen() {
const router = useRouter();
const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user);
const { data: cards = [], isLoading } = useQuery({
queryKey: ["cards"],
queryFn: listCards,
});
const create = useMutation({
mutationFn: () => {
const suffix = Math.floor(Math.random() * 1e4).toString(36);
const handle = `${(user?.handle ?? "card").slice(0, 22)}-${suffix}`;
return createCard(handle, defaultCard(handle, user?.id ?? ""));
},
onSuccess: (card) => {
queryClient.invalidateQueries({ queryKey: ["cards"] });
router.push(`/builder/${card.id}`);
},
});
return (
<View style={styles.root}>
<FlatList
data={cards}
keyExtractor={(c) => c.id}
contentContainerStyle={styles.list}
ListEmptyComponent={
isLoading ? null : <Text style={styles.empty}>No cards yet. Create your first.</Text>
}
renderItem={({ item }) => (
<Pressable style={styles.card} onPress={() => router.push(`/card/${item.id}/view`)}>
<Text style={styles.handle}>@{item.handle}</Text>
<Text style={styles.status}>{item.status}</Text>
<Pressable onPress={() => router.push(`/builder/${item.id}`)} hitSlop={8}>
<Text style={styles.edit}>Edit</Text>
</Pressable>
</Pressable>
)}
/>
<Pressable style={styles.fab} onPress={() => create.mutate()} disabled={create.isPending}>
<Text style={styles.fabText}>+ New card</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: "#0a0a0c" },
list: { padding: 16, gap: 12 },
empty: { color: "#6b6b70", textAlign: "center", marginTop: 64 },
card: {
backgroundColor: "#15151a",
borderRadius: 16,
padding: 18,
flexDirection: "row",
alignItems: "center",
gap: 12,
},
handle: { color: "#f5f5f7", fontSize: 18, fontWeight: "700", flex: 1 },
status: { color: "#9a9aa0", textTransform: "uppercase", fontSize: 12 },
edit: { color: "#ff3b30", fontWeight: "600" },
fab: {
position: "absolute",
bottom: 24,
alignSelf: "center",
backgroundColor: "#ff3b30",
borderRadius: 28,
paddingHorizontal: 28,
paddingVertical: 16,
},
fabText: { color: "#fff", fontWeight: "700", fontSize: 16 },
});
+55
View File
@@ -0,0 +1,55 @@
import { useRouter } from "expo-router";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { logout } from "../../src/api/auth";
import { useAuthStore } from "../../src/stores/authStore";
export default function SettingsScreen() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const onLogout = async () => {
await logout();
router.replace("/(auth)/login");
};
return (
<View style={styles.root}>
<View style={styles.row}>
<Text style={styles.label}>Signed in as</Text>
<Text style={styles.value}>{user?.email ?? "—"}</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Handle</Text>
<Text style={styles.value}>@{user?.handle ?? "—"}</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Plan</Text>
<Text style={styles.value}>{user?.tier ?? "free"}</Text>
</View>
<Pressable style={styles.logout} onPress={onLogout}>
<Text style={styles.logoutText}>Sign out</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: "#0a0a0c", padding: 16, gap: 4 },
row: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 16,
borderBottomColor: "#1a1a1f",
borderBottomWidth: 1,
},
label: { color: "#9a9aa0", fontSize: 15 },
value: { color: "#f5f5f7", fontSize: 15, fontWeight: "600" },
logout: {
marginTop: 32,
backgroundColor: "#15151a",
borderRadius: 14,
paddingVertical: 16,
alignItems: "center",
},
logoutText: { color: "#ff453a", fontWeight: "700", fontSize: 16 },
});
+23
View File
@@ -0,0 +1,23 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { GestureHandlerRootView } from "react-native-gesture-handler";
const queryClient = new QueryClient();
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClient}>
<StatusBar style="light" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: "#0a0a0c" },
headerTintColor: "#f5f5f7",
contentStyle: { backgroundColor: "#0a0a0c" },
}}
/>
</QueryClientProvider>
</GestureHandlerRootView>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useEffect } from "react";
import { ActivityIndicator, Alert, Pressable, StyleSheet, Text, View } from "react-native";
import { getCard, publishCard, saveCard } from "../../src/api/cards";
import { BuilderCanvas } from "../../src/components/builder/BuilderCanvas";
import { useCardStore } from "../../src/stores/cardStore";
export default function BuilderScreen() {
const { cardId } = useLocalSearchParams<{ cardId: string }>();
const router = useRouter();
const queryClient = useQueryClient();
const load = useCardStore((s) => s.load);
const storeCard = useCardStore((s) => s.card);
const { data, isLoading } = useQuery({
queryKey: ["card", cardId],
queryFn: () => getCard(cardId),
enabled: !!cardId,
});
useEffect(() => {
if (data) load(data.definition);
}, [data, load]);
const save = useMutation({
mutationFn: () => {
const def = useCardStore.getState().card;
if (!def) throw new Error("no card loaded");
return saveCard(cardId, def);
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["cards"] }),
});
const publish = useMutation({
mutationFn: async () => {
const def = useCardStore.getState().card;
if (def) await saveCard(cardId, def);
return publishCard(cardId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cards"] });
router.replace(`/card/${cardId}/view`);
},
onError: () => Alert.alert("Couldn't publish", "Check your plan's active-card limit."),
});
if (isLoading || !storeCard) {
return (
<View style={styles.center}>
<ActivityIndicator color="#ff3b30" />
</View>
);
}
return (
<View style={{ flex: 1 }}>
<Stack.Screen
options={{
title: "Builder",
headerRight: () => (
<View style={styles.headerActions}>
<Pressable onPress={() => save.mutate()} disabled={save.isPending}>
<Text style={styles.headerBtn}>Save</Text>
</Pressable>
<Pressable onPress={() => publish.mutate()} disabled={publish.isPending}>
<Text style={[styles.headerBtn, styles.publish]}>Publish</Text>
</Pressable>
</View>
),
}}
/>
<BuilderCanvas side="face" />
</View>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: "#0a0a0c" },
headerActions: { flexDirection: "row", gap: 16 },
headerBtn: { color: "#f5f5f7", fontWeight: "600", fontSize: 16 },
publish: { color: "#ff3b30" },
});
@@ -0,0 +1,27 @@
import { useQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams } from "expo-router";
import { ActivityIndicator, View } from "react-native";
import { getCard } from "../../../src/api/cards";
import { CardViewer } from "../../../src/components/card/CardViewer";
export default function CardViewScreen() {
const { cardId } = useLocalSearchParams<{ cardId: string }>();
const { data, isLoading } = useQuery({
queryKey: ["card", cardId],
queryFn: () => getCard(cardId),
enabled: !!cardId,
});
return (
<View style={{ flex: 1, backgroundColor: "#0a0a0c" }}>
<Stack.Screen options={{ title: "", headerTransparent: true }} />
{isLoading || !data ? (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
<ActivityIndicator color="#ff3b30" />
</View>
) : (
<CardViewer card={data.definition} />
)}
</View>
);
}
+8
View File
@@ -0,0 +1,8 @@
// Entry redirect: authenticated users land on their cards, others on login.
import { Redirect } from "expo-router";
import { useAuthStore } from "../src/stores/authStore";
export default function Index() {
const authed = useAuthStore((s) => s.accessToken !== null);
return <Redirect href={authed ? "/(tabs)/cards" : "/(auth)/login"} />;
}
+8
View File
@@ -0,0 +1,8 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
// Reanimated's babel plugin must be listed last.
plugins: ["react-native-reanimated/plugin"],
};
};
+16
View File
@@ -0,0 +1,16 @@
// Two test surfaces:
// - Pure-logic unit suites (__tests__/unit) run under ts-jest with no React
// Native dependency, so they pass in CI without a simulator (PRD §15.2 logic
// suites: handle validation, colour quantiser, vCard, undo/redo store, QR).
// - Component suites (__tests__/component) would use the `jest-expo` preset and
// a simulator-class environment; they are authored but not part of the
// headless unit run.
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
roots: ["<rootDir>/__tests__/unit", "<rootDir>/src"],
testMatch: ["**/__tests__/unit/**/*.test.ts"],
transform: {
"^.+\\.ts$": ["ts-jest", { tsconfig: "tsconfig.spec.json" }],
},
};
+17667
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "cardclaws-mobile",
"version": "0.1.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"ios": "expo run:ios",
"android": "expo run:android",
"test": "jest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@shopify/react-native-skia": "1.2.3",
"@tanstack/react-query": "^5.51.0",
"axios": "^1.7.2",
"expo": "~51.0.28",
"expo-haptics": "~13.0.1",
"expo-router": "~3.5.23",
"expo-status-bar": "~1.12.1",
"react": "18.2.0",
"react-native": "0.74.5",
"react-native-gesture-handler": "~2.16.1",
"react-native-mmkv": "^2.12.2",
"react-native-reanimated": "~3.10.1",
"react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1",
"qrcode": "^1.5.4",
"zustand": "^4.5.4"
},
"devDependencies": {
"@testing-library/react-native": "^12.5.1",
"@types/jest": "^29.5.12",
"@types/qrcode": "^1.5.5",
"@types/react": "~18.2.79",
"jest": "^29.7.0",
"jest-expo": "~51.0.4",
"react-test-renderer": "18.2.0",
"ts-jest": "^29.2.4",
"typescript": "~5.3.3"
}
}
+58
View File
@@ -0,0 +1,58 @@
// Auth API calls (PRD §13.1).
import { AuthUser, useAuthStore } from "../stores/authStore";
import { api } from "./client";
interface TokenResponse {
access_token: string;
refresh_token: string;
user: {
id: string;
email: string;
handle: string;
display_name: string;
tier: AuthUser["tier"];
};
}
function commit(res: TokenResponse): void {
useAuthStore.getState().setSession({
accessToken: res.access_token,
refreshToken: res.refresh_token,
user: {
id: res.user.id,
email: res.user.email,
handle: res.user.handle,
displayName: res.user.display_name,
tier: res.user.tier,
},
});
}
export async function register(input: {
email: string;
password: string;
handle: string;
displayName: string;
}): Promise<void> {
const res = await api.post<TokenResponse>("/v1/auth/register", {
email: input.email,
password: input.password,
handle: input.handle,
display_name: input.displayName,
});
commit(res.data);
}
export async function login(email: string, password: string): Promise<void> {
const res = await api.post<TokenResponse>("/v1/auth/login", { email, password });
commit(res.data);
}
export async function logout(): Promise<void> {
const { refreshToken, clear } = useAuthStore.getState();
if (refreshToken) {
await api.post("/v1/auth/logout", { refresh_token: refreshToken }).catch(() => {});
}
clear();
}
+44
View File
@@ -0,0 +1,44 @@
// Card API calls (PRD §13.2).
import { CardDefinition } from "../types/card";
import { api } from "./client";
export interface CardRecord {
id: string;
ownerId: string;
handle: string;
status: "draft" | "active" | "archived";
definition: CardDefinition;
version: number;
createdAt: string;
updatedAt: string;
}
export async function listCards(): Promise<CardRecord[]> {
const res = await api.get<CardRecord[]>("/v1/cards");
return res.data;
}
export async function getCard(id: string): Promise<CardRecord> {
const res = await api.get<CardRecord>(`/v1/cards/${id}`);
return res.data;
}
export async function createCard(handle: string, definition: CardDefinition): Promise<CardRecord> {
const res = await api.post<CardRecord>("/v1/cards", { handle, definition });
return res.data;
}
export async function saveCard(id: string, definition: CardDefinition): Promise<CardRecord> {
const res = await api.put<CardRecord>(`/v1/cards/${id}`, { definition });
return res.data;
}
export async function publishCard(id: string): Promise<CardRecord> {
const res = await api.post<CardRecord>(`/v1/cards/${id}/publish`);
return res.data;
}
export function appleWalletUrl(id: string): string {
return `${api.defaults.baseURL}/v1/cards/${id}/wallet/apple`;
}
+36
View File
@@ -0,0 +1,36 @@
// Axios instance with auth + refresh interceptors (PRD §8.1).
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
import { useAuthStore } from "../stores/authStore";
export const API_BASE = process.env.EXPO_PUBLIC_API_BASE ?? "http://localhost:8080";
export const api = axios.create({ baseURL: API_BASE });
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken;
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// On a 401, attempt a single refresh-token rotation and replay the request.
let refreshing: Promise<boolean> | null = null;
api.interceptors.response.use(
(res) => res,
async (error: AxiosError) => {
const original = error.config as InternalAxiosRequestConfig & { _retried?: boolean };
if (error.response?.status === 401 && original && !original._retried) {
original._retried = true;
refreshing ??= useAuthStore.getState().refresh();
const ok = await refreshing;
refreshing = null;
if (ok) {
const token = useAuthStore.getState().accessToken;
if (token) original.headers.Authorization = `Bearer ${token}`;
return api(original);
}
}
return Promise.reject(error);
},
);

Some files were not shown because too many files have changed in this diff Show More