Phase 2 backend: Google Wallet pass + profile contact form

- cardclaws-wallet::google — inline GenericObject + RS256 `savetowallet` JWT
  (Rs256Signer via jsonwebtoken; FakeGoogleSigner test double)
- POST /v1/cards/{id}/wallet/google → { saveUrl }
- POST /v1/profile/{handle}/contact — validate, rate-limit, record
  contact_form_submission, email the owner (HTML-escaped, Resend)
- WalletConfig gains google issuer id + service account email

84 backend tests; fmt + clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-04 12:32:21 -05:00
co-authored by Claude Opus 4.8
parent 4770efd861
commit 8ae1d8f263
20 changed files with 545 additions and 1 deletions
@@ -23,6 +23,10 @@ sha1 = { workspace = true }
zip = { workspace = true }
image = { workspace = true }
thiserror = { workspace = true }
# Google Wallet "save" JWT (RS256). jsonwebtoken is pure-Rust, so this is always
# compiled (unlike the openssl-backed Apple signer).
jsonwebtoken = { workspace = true }
base64 = { workspace = true }
openssl = { version = "0.10", features = ["vendored"], optional = true }
[dev-dependencies]
@@ -0,0 +1,68 @@
//! Signs the Google Wallet `savetowallet` JWT (RS256) with the issuer service
//! account key. A fake signer (test double) lets the link-assembly logic be
//! verified without a real key.
use base64::Engine;
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use serde_json::Value;
use crate::error::WalletError;
pub trait GoogleWalletSigner: Send + Sync {
/// Sign the claims into a compact JWT string.
fn sign(&self, claims: &Value) -> Result<String, WalletError>;
}
/// Production signer: RS256 over the service account private key (PEM).
pub struct Rs256Signer {
key: EncodingKey,
}
impl Rs256Signer {
pub fn from_pem(pem: &[u8]) -> Result<Self, WalletError> {
let key =
EncodingKey::from_rsa_pem(pem).map_err(|e| WalletError::Signing(e.to_string()))?;
Ok(Self { key })
}
}
impl GoogleWalletSigner for Rs256Signer {
fn sign(&self, claims: &Value) -> Result<String, WalletError> {
jsonwebtoken::encode(&Header::new(Algorithm::RS256), claims, &self.key)
.map_err(|e| WalletError::Signing(e.to_string()))
}
}
/// Test double: emits a structurally-valid `header.payload.signature` token with
/// a non-cryptographic signature, so tests can decode and assert the payload.
pub struct FakeGoogleSigner;
impl GoogleWalletSigner for FakeGoogleSigner {
fn sign(&self, claims: &Value) -> Result<String, WalletError> {
let b64 = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
let header = b64(br#"{"alg":"RS256","typ":"JWT"}"#);
let payload =
b64(&serde_json::to_vec(claims).map_err(|e| WalletError::Signing(e.to_string()))?);
Ok(format!("{header}.{payload}.fakesig"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn fake_signer_payload_is_decodable() {
let claims = json!({ "aud": "google", "typ": "savetowallet" });
let jwt = FakeGoogleSigner.sign(&claims).unwrap();
let parts: Vec<&str> = jwt.split('.').collect();
assert_eq!(parts.len(), 3);
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(parts[1])
.unwrap();
let decoded: Value = serde_json::from_slice(&payload).unwrap();
assert_eq!(decoded["aud"], "google");
}
}
@@ -0,0 +1,37 @@
//! Google Wallet generic pass (PRD §10.3). The "Add to Google Wallet" button
//! deep-links to `https://pay.google.com/gp/v/save/{JWT}`, where the JWT is an
//! RS256-signed `savetowallet` token carrying the GenericObject inline (so no
//! Google Wallet API round-trip is needed to mint the link).
pub mod jwt_signer;
pub mod object_builder;
use crate::error::WalletError;
use jwt_signer::GoogleWalletSigner;
const SAVE_URL_PREFIX: &str = "https://pay.google.com/gp/v/save/";
/// Inputs for one Google Wallet object (mirrors the Apple `PassInput`).
pub struct GoogleInput {
/// Service account email (the JWT `iss`).
pub issuer_email: String,
/// Fully-qualified class id, e.g. `3388000000022195611.cardclaws_generic`.
pub class_id: String,
/// Fully-qualified object id, e.g. `3388000000022195611.<card-id>`.
pub object_id: String,
pub holder_name: String,
pub title: Option<String>,
pub company: Option<String>,
pub profile_url: String,
pub background_hex: String,
}
/// Build the full "Add to Google Wallet" save URL.
pub fn build_save_link(
input: &GoogleInput,
signer: &dyn GoogleWalletSigner,
) -> Result<String, WalletError> {
let claims = object_builder::build_claims(input);
let jwt = signer.sign(&claims)?;
Ok(format!("{SAVE_URL_PREFIX}{jwt}"))
}
@@ -0,0 +1,97 @@
//! Builds the `savetowallet` JWT claims, including the inline GenericObject.
use serde_json::{json, Value};
use super::GoogleInput;
/// Build the JWT claims for the save link. The GenericObject is embedded under
/// `payload.genericObjects` so the link is self-contained.
pub fn build_claims(input: &GoogleInput) -> Value {
json!({
"iss": input.issuer_email,
"aud": "google",
"typ": "savetowallet",
"origins": [],
"payload": {
"genericObjects": [ build_object(input) ]
}
})
}
fn build_object(input: &GoogleInput) -> Value {
let subheader = match (&input.title, &input.company) {
(Some(t), Some(c)) => format!("{t} · {c}"),
(Some(t), None) => t.clone(),
(None, Some(c)) => c.clone(),
(None, None) => String::new(),
};
json!({
"id": input.object_id,
"classId": input.class_id,
"state": "ACTIVE",
"hexBackgroundColor": input.background_hex,
"cardTitle": {
"defaultValue": { "language": "en", "value": "CardClaws" }
},
"header": {
"defaultValue": { "language": "en", "value": input.holder_name }
},
"subheader": {
"defaultValue": { "language": "en", "value": subheader }
},
"barcode": {
"type": "QR_CODE",
"value": input.profile_url,
"alternateText": input.profile_url
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn input() -> GoogleInput {
GoogleInput {
issuer_email: "[email protected]".into(),
class_id: "338800.cardclaws_generic".into(),
object_id: "338800.card-abc".into(),
holder_name: "Omar Sobh".into(),
title: Some("Founder".into()),
company: Some("RedClaw".into()),
profile_url: "https://cardclaws.com/omar".into(),
background_hex: "#101014".into(),
}
}
#[test]
fn claims_have_savetowallet_shape() {
let c = build_claims(&input());
assert_eq!(c["aud"], "google");
assert_eq!(c["typ"], "savetowallet");
assert_eq!(c["iss"], "[email protected]");
let obj = &c["payload"]["genericObjects"][0];
assert_eq!(obj["id"], "338800.card-abc");
assert_eq!(obj["classId"], "338800.cardclaws_generic");
}
#[test]
fn barcode_is_qr_to_profile() {
let c = build_claims(&input());
let barcode = &c["payload"]["genericObjects"][0]["barcode"];
assert_eq!(barcode["type"], "QR_CODE");
assert_eq!(barcode["value"], "https://cardclaws.com/omar");
}
#[test]
fn header_is_holder_and_subheader_combines_title_company() {
let c = build_claims(&input());
let obj = &c["payload"]["genericObjects"][0];
assert_eq!(obj["header"]["defaultValue"]["value"], "Omar Sobh");
assert_eq!(
obj["subheader"]["defaultValue"]["value"],
"Founder · RedClaw"
);
}
}
@@ -9,6 +9,7 @@
pub mod apple;
pub mod error;
pub mod google;
pub mod strip_renderer;
pub use error::WalletError;