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,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"));
}
}