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,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()
}