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

Greenfield implementation of CardClaws Phase 1 across three surfaces.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-04 10:17:26 -05:00
co-authored by Claude Opus 4.8
commit c30d3afeec
128 changed files with 36279 additions and 0 deletions
@@ -0,0 +1,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())
}