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,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),
|
||||
})),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user