Phase 1 foundation: backend, web profile, and mobile app
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:
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
Generated
+3606
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"} />;
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
Generated
+17667
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user