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

Greenfield implementation of CardClaws Phase 1 across three surfaces.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-04 10:17:26 -05:00
co-authored by Claude Opus 4.8
commit c30d3afeec
128 changed files with 36279 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
// Auth session store, persisted to encrypted MMKV (PRD §8.1). Tokens survive
// app restarts; refresh rotates the pair against /v1/auth/refresh.
import axios from "axios";
import { MMKV } from "react-native-mmkv";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
const API_BASE = process.env.EXPO_PUBLIC_API_BASE ?? "http://localhost:8080";
const storage = new MMKV({ id: "cardclaws-auth", encryptionKey: "cardclaws" });
export interface AuthUser {
id: string;
email: string;
handle: string;
displayName: string;
tier: "free" | "pro" | "team" | "enterprise";
}
interface AuthState {
accessToken: string | null;
refreshToken: string | null;
user: AuthUser | null;
isAuthenticated: () => boolean;
setSession: (tokens: { accessToken: string; refreshToken: string; user: AuthUser }) => void;
clear: () => void;
refresh: () => Promise<boolean>;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
accessToken: null,
refreshToken: null,
user: null,
isAuthenticated: () => get().accessToken !== null,
setSession: ({ accessToken, refreshToken, user }) =>
set({ accessToken, refreshToken, user }),
clear: () => set({ accessToken: null, refreshToken: null, user: null }),
refresh: async () => {
const token = get().refreshToken;
if (!token) return false;
try {
const res = await axios.post(`${API_BASE}/v1/auth/refresh`, {
refresh_token: token,
});
set({
accessToken: res.data.access_token,
refreshToken: res.data.refresh_token,
});
return true;
} catch {
get().clear();
return false;
}
},
}),
{
name: "cardclaws-auth",
storage: createJSONStorage(() => ({
getItem: (k) => storage.getString(k) ?? null,
setItem: (k, v) => storage.set(k, v),
removeItem: (k) => storage.delete(k),
})),
},
),
);
+114
View File
@@ -0,0 +1,114 @@
// Builder state with undo/redo (PRD §8.3, §15.2 `cardStore.test.ts`).
//
// Mutations snapshot the card definition before applying, pushing onto a capped
// history stack. This is the memento form of the PRD's command pattern — every
// edit is reversible and the 50-step stack is bounded. Implemented on a vanilla
// Zustand store so it is usable both in React and in headless unit tests.
import { create } from "zustand";
import { BackgroundConfig, CardDefinition, Layer } from "../types/card";
export const MAX_HISTORY = 50;
export type Side = "face" | "back";
interface CardState {
card: CardDefinition | null;
past: CardDefinition[];
future: CardDefinition[];
load: (card: CardDefinition) => void;
addLayer: (side: Side, layer: Layer) => void;
updateLayer: (side: Side, layerId: string, patch: Partial<Layer>) => void;
removeLayer: (side: Side, layerId: string) => void;
setBackground: (side: Side, background: BackgroundConfig) => void;
undo: () => void;
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
export function newLayerId(): string {
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
if (c?.randomUUID) return c.randomUUID();
return `layer-${Math.floor(Math.random() * 1e9).toString(36)}`;
}
export const useCardStore = create<CardState>((set, get) => {
/** Apply a mutation while recording the pre-state for undo. */
const mutate = (fn: (card: CardDefinition) => CardDefinition) => {
const { card, past } = get();
if (!card) return;
const nextPast = [...past, clone(card)];
if (nextPast.length > MAX_HISTORY) nextPast.shift();
set({ card: fn(clone(card)), past: nextPast, future: [] });
};
const editSide = (
card: CardDefinition,
side: Side,
fn: (layers: Layer[]) => Layer[],
): CardDefinition => {
card[side] = { ...card[side], layers: fn(card[side].layers) };
return card;
};
return {
card: null,
past: [],
future: [],
load: (card) => set({ card: clone(card), past: [], future: [] }),
addLayer: (side, layer) =>
mutate((card) => editSide(card, side, (layers) => [...layers, layer])),
updateLayer: (side, layerId, patch) =>
mutate((card) =>
editSide(card, side, (layers) =>
layers.map((l) => (l.id === layerId ? ({ ...l, ...patch } as Layer) : l)),
),
),
removeLayer: (side, layerId) =>
mutate((card) =>
editSide(card, side, (layers) => layers.filter((l) => l.id !== layerId)),
),
setBackground: (side, background) =>
mutate((card) => {
card[side] = { ...card[side], background };
return card;
}),
undo: () => {
const { card, past, future } = get();
if (!card || past.length === 0) return;
const previous = past[past.length - 1];
set({
card: previous,
past: past.slice(0, -1),
future: [...future, clone(card)],
});
},
redo: () => {
const { card, past, future } = get();
if (!card || future.length === 0) return;
const next = future[future.length - 1];
set({
card: next,
past: [...past, clone(card)],
future: future.slice(0, -1),
});
},
canUndo: () => get().past.length > 0,
canRedo: () => get().future.length > 0,
};
});